File size: 8,978 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
"""openTARS 3.0: servidores compatíveis com a API da OpenAI.

vLLM, LM Studio, llama.cpp (llama-server), LocalAI, Jan... todos falam
/v1/models e /v1/chat/completions. Com o endereço configurado
(opentars --servidor http://localhost:1234/v1), os modelos desse servidor
entram no catálogo como "api:<nome>" e o openTARS conversa com eles, com
ferramentas, igual faz com o Ollama.

Aqui fica só o que é da API: converter a conversa (formato do Ollama, que
o resto do openTARS usa) pro formato OpenAI, ler o streaming (SSE) e
converter as chamadas de ferramenta de volta. Sem estado global."""

import json
import re

PREFIXO = "api:"


def eh_modelo_api(tag):
    return isinstance(tag, str) and tag.startswith(PREFIXO)


def nome_no_servidor(tag):
    return tag[len(PREFIXO):] if eh_modelo_api(tag) else tag


def normalizar_url(url):
    """'localhost:1234' -> 'http://localhost:1234/v1'; mantém /v1 se já tiver."""
    url = (url or "").strip().rstrip("/")
    if not url:
        return ""
    if not re.match(r"^https?://", url):
        # sem http(s):// escrito: servidor da própria máquina/rede local usa http; qualquer outro, https
        # (a chave de API não pode ir em texto puro pela internet só porque faltou digitar o "https://")
        local = re.match(r"^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|\[::1\]|[^/]*\.local)", url, re.I)
        url = ("http://" if local else "https://") + url
    if not re.search(r"/v\d+[a-z]*(/openai)?$", url):
        url += "/v1"
    return url


# ------------------------------------------------------------
# Conversa: Ollama -> OpenAI
# ------------------------------------------------------------

def _conteudo_com_imagens(texto, imagens):
    partes = [{"type": "text", "text": texto or ""}]
    for b64 in imagens or ():
        partes.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}})
    return partes


def para_openai(mensagens):
    """Mensagens no formato do Ollama -> formato OpenAI.

    Diferenças: a chamada de ferramenta da OpenAI tem 'id' e argumentos em
    TEXTO JSON; o resultado ('tool') aponta pro id. O Ollama não usa ids:
    os resultados vêm na ordem das chamadas, então os ids são dados aqui,
    em ordem. Imagens viram partes 'image_url'."""
    saida = []
    pendentes = []  # ids de chamadas ainda sem resultado, na ordem
    n = 0
    for m in mensagens:
        papel = m.get("role")
        conteudo = m.get("content") or ""
        if papel == "assistant" and m.get("tool_calls"):
            chamadas = []
            for ch in m["tool_calls"]:
                funcao = ch.get("function", {}) or {}
                argumentos = funcao.get("arguments", {})
                if not isinstance(argumentos, str):
                    argumentos = json.dumps(argumentos or {}, ensure_ascii=False)
                n += 1
                ident = ch.get("id") or f"call_{n}"
                pendentes.append(ident)
                chamadas.append({"id": ident, "type": "function",
                                 "function": {"name": funcao.get("name", ""), "arguments": argumentos}})
            saida.append({"role": "assistant", "content": conteudo or None, "tool_calls": chamadas})
        elif papel == "tool":
            if pendentes:
                saida.append({"role": "tool", "tool_call_id": pendentes.pop(0), "content": conteudo})
            else:  # resultado órfão (conversa antiga cortada): vira texto
                saida.append({"role": "user", "content": f"[tool result] {conteudo}"})
        elif papel in ("system", "user", "assistant"):
            while pendentes:     # chamada sem resultado: os servidores OpenAI recusam (400); fecha com um resultado vazio
                saida.append({"role": "tool", "tool_call_id": pendentes.pop(0), "content": "(no result)"})
            if m.get("images"):
                saida.append({"role": papel, "content": _conteudo_com_imagens(conteudo, m["images"])})
            else:
                saida.append({"role": papel, "content": conteudo})
    return saida


def corpo_chat(modelo, mensagens, tools=None, stream=True, temperatura=None, formato=None):
    corpo = {"model": nome_no_servidor(modelo), "messages": para_openai(mensagens), "stream": stream}
    if tools:
        corpo["tools"] = tools  # o formato do openTARS já é o da OpenAI
    if temperatura is not None:
        corpo["temperature"] = temperatura
    if formato:
        corpo["response_format"] = {"type": "json_schema",
                                    "json_schema": {"name": "resposta", "schema": formato, "strict": True}}
    return corpo


# ------------------------------------------------------------
# Streaming (SSE) -> eventos
# ------------------------------------------------------------

def _argumentos(texto):
    if isinstance(texto, dict):
        return texto
    try:
        valor = json.loads(texto or "{}")
        return valor if isinstance(valor, dict) else {}
    except ValueError:
        achado = re.search(r"\{.*\}", texto or "", re.S)
        if achado:
            try:
                return json.loads(achado.group(0))
            except ValueError:
                pass
        return {}


class LeitorStream:
    """Junta os pedaços do streaming. alimentar(linha) devolve os eventos
    novos: [('pensamento', txt) | ('resposta', txt) | ('erro', txt) | ('fim', motivo)].
    Depois do fim, .chamadas tem as chamadas de ferramenta no formato do Ollama."""

    def __init__(self):
        self._chamadas = {}   # índice -> {"name", "arguments" (texto)}
        self.conteudo = []
        self.pensamento = []
        self.terminou = False
        self.motivo = None

    def alimentar(self, linha):
        if isinstance(linha, bytes):
            linha = linha.decode("utf-8", "replace")
        linha = (linha or "").strip()
        if not linha or linha.startswith(":") or not linha.startswith("data:"):
            return []
        dados = linha[5:].strip()
        if dados == "[DONE]":
            self.terminou = True
            return [("fim", self.motivo)]
        try:
            pedaco = json.loads(dados)
        except ValueError:
            return []
        if not isinstance(pedaco, dict):
            return []
        if pedaco.get("error"):
            erro = pedaco["error"]
            return [("erro", erro.get("message") if isinstance(erro, dict) else str(erro))]
        eventos = []
        for escolha in pedaco.get("choices") or []:
            if not isinstance(escolha, dict):
                continue
            delta = escolha.get("delta") or escolha.get("message") or {}
            raciocinio = delta.get("reasoning_content") or delta.get("reasoning")
            if raciocinio:
                self.pensamento.append(raciocinio)
                eventos.append(("pensamento", raciocinio))
            if delta.get("content"):
                self.conteudo.append(delta["content"])
                eventos.append(("resposta", delta["content"]))
            for ch in delta.get("tool_calls") or []:
                i = ch.get("index", len(self._chamadas))
                atual = self._chamadas.setdefault(i, {"name": "", "arguments": ""})
                funcao = ch.get("function") or {}
                if funcao.get("name"):
                    atual["name"] += funcao["name"]
                if funcao.get("arguments"):
                    args = funcao["arguments"]
                    atual["arguments"] += args if isinstance(args, str) else json.dumps(args)
            if escolha.get("finish_reason"):
                self.motivo = escolha["finish_reason"]
        return eventos

    @property
    def chamadas(self):
        return [{"function": {"name": c["name"], "arguments": _argumentos(c["arguments"])}}
                for _, c in sorted(self._chamadas.items()) if c["name"]]


def resposta_unica(dados):
    """Texto de uma resposta não-streaming."""
    try:
        mensagem = dados["choices"][0]["message"]
    except (KeyError, IndexError, TypeError):
        return None
    return (mensagem.get("content") or "").strip()


# ------------------------------------------------------------
# Modelos do servidor
# ------------------------------------------------------------
_RE_VISAO = re.compile(r"(^|[-_/.])(vl|vision|llava|pixtral|moondream|minicpm-v|gemma-?3|gemma-?4|qwen2\.5-?omni)",
                       re.I)


def ler_modelos(dados):
    """/v1/models -> lista de ids (sem os de embedding)."""
    ids = []
    for item in (dados or {}).get("data") or []:
        ident = item.get("id") if isinstance(item, dict) else None
        if ident and not re.search(r"embed|bge-|minilm|rerank", ident, re.I):
            ids.append(ident)
    return ids


def capacidades_pelo_nome(ident):
    caps = {"tools"}
    if _RE_VISAO.search(ident):
        caps.add("vision")
    if re.search(r"qwen3|deepseek-r1|qwq|reason|think", ident, re.I):
        caps.add("thinking")
    return caps