This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
"""LLM provider abstraction.
|
||||
|
||||
All providers translate a *canonical* message list into their own API shape and
|
||||
expose a single ``chat()`` method that streams assistant text via a callback and
|
||||
returns the final assistant message (including any tool calls).
|
||||
"""
|
||||
from .base import Provider, ProviderError, ToolSpec
|
||||
from .factory import build_provider
|
||||
|
||||
__all__ = ["Provider", "ProviderError", "ToolSpec", "build_provider"]
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Anthropic Claude provider (Messages API, streaming)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .base import CancelFn, CancelWatchdog, Provider, ProviderError, TextCallback, ToolSpec
|
||||
|
||||
_TIMEOUT = (15, 600)
|
||||
_ANTHROPIC_VERSION = "2023-06-01"
|
||||
_MAX_TOKENS = 4096
|
||||
_MAX_RETRIES = 6 # auto-retry on rate-limit (429) / overloaded
|
||||
|
||||
|
||||
class AnthropicProvider(Provider):
|
||||
name = "anthropic"
|
||||
supports_vision = True
|
||||
|
||||
def _url(self) -> str:
|
||||
base = str(self.conf.get("base_url") or "https://api.anthropic.com").rstrip("/")
|
||||
return f"{base}/v1/messages"
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
key = self.conf.get("api_key")
|
||||
if not key:
|
||||
raise ProviderError("Anthropic API key is not configured.")
|
||||
return {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": key,
|
||||
"anthropic-version": _ANTHROPIC_VERSION,
|
||||
}
|
||||
|
||||
_FALLBACK_MODELS = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]
|
||||
|
||||
def list_models(self):
|
||||
self.last_error = ""
|
||||
base = str(self.conf.get("base_url") or "https://api.anthropic.com").rstrip("/")
|
||||
try:
|
||||
resp = self._request("GET", f"{base}/v1/models", headers=self._headers(),
|
||||
timeout=(10, 30))
|
||||
if resp.status_code < 400:
|
||||
data = resp.json().get("data", [])
|
||||
ids = [m.get("id") for m in data if isinstance(m, dict) and m.get("id")]
|
||||
if ids:
|
||||
return ids
|
||||
self.last_error = "Anthropic API responded but returned no models — using the built-in fallback list."
|
||||
else:
|
||||
self.last_error = f"Anthropic API error {resp.status_code}: {resp.text[:200]}"
|
||||
except ProviderError as exc:
|
||||
self.last_error = str(exc)
|
||||
except requests.RequestException as exc:
|
||||
self.last_error = f"Could not reach the Anthropic API: {exc}"
|
||||
except ValueError as exc:
|
||||
self.last_error = f"Anthropic API returned an invalid (non-JSON) response: {exc}"
|
||||
return list(self._FALLBACK_MODELS)
|
||||
|
||||
@staticmethod
|
||||
def _split(messages: List[Dict[str, Any]]):
|
||||
system_parts: List[str] = []
|
||||
api: List[Dict[str, Any]] = []
|
||||
for m in messages:
|
||||
role = m["role"]
|
||||
if role == "system":
|
||||
if m.get("content"):
|
||||
system_parts.append(m["content"])
|
||||
elif role == "tool":
|
||||
block = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": m.get("tool_call_id", ""),
|
||||
"content": m.get("content", ""),
|
||||
}
|
||||
if api and api[-1]["role"] == "user" and api[-1].get("_tool"):
|
||||
api[-1]["content"].append(block)
|
||||
else:
|
||||
api.append({"role": "user", "content": [block], "_tool": True})
|
||||
elif role == "assistant":
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
if m.get("content"):
|
||||
blocks.append({"type": "text", "text": m["content"]})
|
||||
for tc in m.get("tool_calls", []) or []:
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": tc["id"],
|
||||
"name": tc["name"],
|
||||
"input": tc.get("arguments", {}),
|
||||
})
|
||||
api.append({"role": "assistant", "content": blocks or [{"type": "text", "text": ""}]})
|
||||
else: # user
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
# Preview tab's region-selection → AI fix flow: a list of
|
||||
# canonical content blocks (see providers/base.py docstring).
|
||||
blocks = []
|
||||
for block in content:
|
||||
if block.get("type") == "image":
|
||||
blocks.append({"type": "image", "source": {
|
||||
"type": "base64",
|
||||
"media_type": block.get("mime", "image/png"),
|
||||
"data": block.get("data", ""),
|
||||
}})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": block.get("text", "")})
|
||||
api.append({"role": "user", "content": blocks})
|
||||
else:
|
||||
api.append({"role": "user", "content": [{"type": "text", "text": content}]})
|
||||
for msg in api:
|
||||
msg.pop("_tool", None)
|
||||
return "\n\n".join(system_parts), api
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[ToolSpec]] = None,
|
||||
on_text: Optional[TextCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_reasoning: Optional[TextCallback] = None,
|
||||
) -> Dict[str, Any]:
|
||||
work = list(messages) # local copy we can trim on context overflow
|
||||
payload: Dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"max_tokens": _MAX_TOKENS,
|
||||
"stream": True,
|
||||
}
|
||||
if tools:
|
||||
tool_defs = [t.to_anthropic() for t in tools]
|
||||
# Prompt caching: mark the end of the (large, stable) tool list so
|
||||
# Anthropic caches the whole tools+system prefix and reuses it across
|
||||
# the many turns of one agent loop. Only the growing message tail
|
||||
# changes each turn, so this turns most of the per-turn input into a
|
||||
# cache read (~10% the cost + far lower latency). Unsupported prefixes
|
||||
# simply aren't cached — no error — so this is safe on any gateway.
|
||||
tool_defs[-1] = {**tool_defs[-1], "cache_control": {"type": "ephemeral"}}
|
||||
payload["tools"] = tool_defs
|
||||
|
||||
text_parts: List[str] = []
|
||||
# Per content-block scratch for tool_use assembly.
|
||||
blocks: Dict[int, Dict[str, Any]] = {}
|
||||
usage_seen: Dict[str, Any] = {} # real token counts from stream events
|
||||
|
||||
for attempt in range(1, _MAX_RETRIES + 2):
|
||||
system, api_messages = self._split(work)
|
||||
payload["messages"] = api_messages
|
||||
if system:
|
||||
# Structured system block + cache_control so the (large, stable)
|
||||
# system prompt — tool guide, skills, security rules — is cached
|
||||
# and reused across the agent loop instead of re-sent every turn.
|
||||
payload["system"] = [{
|
||||
"type": "text", "text": system,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}]
|
||||
else:
|
||||
payload.pop("system", None)
|
||||
try:
|
||||
resp = self._request(
|
||||
"POST", self._url(), headers=self._headers(), json=payload,
|
||||
stream=True, timeout=_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ProviderError(f"Could not reach the Anthropic API: {exc}") from exc
|
||||
# requests/urllib3 falls back to Latin-1 for text/* responses whose
|
||||
# Content-Type omits an explicit charset (common for SSE streams) —
|
||||
# every non-ASCII UTF-8 byte pair then gets misread as two Latin-1
|
||||
# characters ("ô" → "ô"), corrupting every non-English reply. The
|
||||
# body is always UTF-8 JSON/SSE in practice, so force it explicitly
|
||||
# rather than trust the guess.
|
||||
resp.encoding = "utf-8"
|
||||
|
||||
if resp.status_code >= 400:
|
||||
code = resp.status_code
|
||||
wait = self._retry_after(resp)
|
||||
err = self._error_text(resp)
|
||||
resp.close()
|
||||
# Rate limited / overloaded — wait and retry instead of failing.
|
||||
if code in (429, 529) and attempt <= _MAX_RETRIES:
|
||||
if self._wait_or_cancel(wait, cancel, on_text, attempt):
|
||||
return {"role": "assistant", "content": "", "tool_calls": []}
|
||||
continue
|
||||
# Prompt too long — auto-compress and retry. First try dropping the
|
||||
# oldest turn; if there's nothing left to drop (e.g. the very first
|
||||
# message of a new conversation is itself oversized, typically from
|
||||
# a large attachment), shrink that message's own content instead of
|
||||
# giving up immediately.
|
||||
if code == 400 and attempt <= _MAX_RETRIES and self._is_context_overflow(err):
|
||||
work, changed = self._drop_oldest_turn(work)
|
||||
note = "\n✂ Lịch sử quá dài — tự nén bớt rồi thử lại…\n"
|
||||
if not changed:
|
||||
work, changed = self._shrink_last_message(work)
|
||||
note = "\n✂ Tin nhắn/đính kèm quá dài cho model này — tự cắt bớt nội dung rồi thử lại…\n"
|
||||
if changed:
|
||||
if on_text:
|
||||
on_text(note)
|
||||
continue
|
||||
if self._is_context_overflow(err):
|
||||
raise ProviderError(self._friendly_context_error(err))
|
||||
raise ProviderError(err)
|
||||
break # 200 OK → stream below
|
||||
|
||||
# Stream the body — same mid-stream drop handling as the OpenAI
|
||||
# provider: retry silently when nothing arrived yet, keep a partial
|
||||
# answer with a note instead of surfacing the raw transport error.
|
||||
stream_retries = 0
|
||||
while True:
|
||||
try:
|
||||
with CancelWatchdog(resp, cancel):
|
||||
for raw in resp.iter_lines(decode_unicode=True):
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not raw or not raw.startswith("data:"):
|
||||
continue
|
||||
data = raw[len("data:"):].strip()
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
evt = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
etype = evt.get("type")
|
||||
if etype == "message_start":
|
||||
u = (evt.get("message") or {}).get("usage") or {}
|
||||
usage_seen["in"] = u.get("input_tokens", 0)
|
||||
usage_seen["cache"] = u.get("cache_read_input_tokens", 0)
|
||||
elif etype == "message_delta":
|
||||
u = evt.get("usage") or {}
|
||||
if u.get("output_tokens"):
|
||||
usage_seen["out"] = u["output_tokens"]
|
||||
if etype == "content_block_start":
|
||||
idx = evt.get("index", 0)
|
||||
cb = evt.get("content_block", {})
|
||||
if cb.get("type") == "tool_use":
|
||||
blocks[idx] = {"id": cb.get("id", ""), "name": cb.get("name", ""), "json": ""}
|
||||
elif etype == "content_block_delta":
|
||||
idx = evt.get("index", 0)
|
||||
delta = evt.get("delta", {})
|
||||
if delta.get("type") == "text_delta":
|
||||
piece = delta.get("text", "")
|
||||
if piece:
|
||||
text_parts.append(piece)
|
||||
if on_text:
|
||||
on_text(piece)
|
||||
elif delta.get("type") == "thinking_delta":
|
||||
# Extended-thinking reasoning — activity only, not the answer.
|
||||
if on_reasoning and delta.get("thinking"):
|
||||
on_reasoning(delta["thinking"])
|
||||
elif delta.get("type") == "input_json_delta" and idx in blocks:
|
||||
blocks[idx]["json"] += delta.get("partial_json", "")
|
||||
elif etype == "message_stop":
|
||||
break
|
||||
elif etype == "error":
|
||||
raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}")
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
resp.close()
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if text_parts or blocks:
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n")
|
||||
break
|
||||
stream_retries += 1
|
||||
if stream_retries > 2:
|
||||
raise ProviderError(
|
||||
f"Kết nối tới Anthropic bị ngắt giữa chừng (đã thử lại {stream_retries - 1} lần): {exc}"
|
||||
) from exc
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt — đang thử lại…\n")
|
||||
system, api_messages = self._split(work)
|
||||
payload["messages"] = api_messages
|
||||
if system:
|
||||
payload["system"] = system
|
||||
try:
|
||||
resp = self._request(
|
||||
"POST", self._url(), headers=self._headers(), json=payload,
|
||||
stream=True, timeout=_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc2:
|
||||
raise ProviderError(f"Could not reach the Anthropic API: {exc2}") from exc2
|
||||
resp.encoding = "utf-8" # same Latin-1-fallback fix as the initial request
|
||||
if resp.status_code >= 400:
|
||||
err = self._error_text(resp)
|
||||
resp.close()
|
||||
raise ProviderError(err)
|
||||
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(blocks):
|
||||
b = blocks[idx]
|
||||
try:
|
||||
args = json.loads(b["json"]) if b["json"].strip() else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": b["json"]}
|
||||
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
|
||||
|
||||
# Dashboard usage event — real counts from the stream's usage events,
|
||||
# else a ~4 chars/token estimate. Never breaks the turn.
|
||||
try:
|
||||
from ..core import usage_tracker as ut
|
||||
|
||||
if usage_seen:
|
||||
ut.record(self.name, self.model, usage_seen.get("in", 0),
|
||||
usage_seen.get("out", 0), usage_seen.get("cache", 0))
|
||||
else:
|
||||
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
|
||||
ut.record(self.name, self.model, ut.estimate_tokens(sent),
|
||||
ut.estimate_tokens(got), 0, estimated=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return {"role": "assistant", "content": "".join(text_parts), "tool_calls": tool_calls}
|
||||
|
||||
@staticmethod
|
||||
def _error_text(resp: requests.Response) -> str:
|
||||
try:
|
||||
body = resp.json()
|
||||
msg = body.get("error", {}).get("message") or json.dumps(body)
|
||||
except ValueError:
|
||||
msg = resp.text[:300]
|
||||
return f"Anthropic error {resp.status_code}: {msg}"
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Provider base classes and the canonical message/tool model.
|
||||
|
||||
Canonical message shapes (provider-agnostic)::
|
||||
|
||||
{"role": "system", "content": "..."}
|
||||
{"role": "user", "content": "..."}
|
||||
{"role": "assistant", "content": "...", "tool_calls": [ToolCall, ...]}
|
||||
{"role": "tool", "tool_call_id": "...", "name": "...", "content": "..."}
|
||||
|
||||
A ToolCall is ``{"id": str, "name": str, "arguments": dict}``.
|
||||
|
||||
A user/assistant message's ``content`` is USUALLY a plain string, but MAY
|
||||
instead be a list of content blocks when an image is attached (Preview tab's
|
||||
region-selection → AI fix flow is the only caller today)::
|
||||
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "..."},
|
||||
{"type": "image", "data": "<base64>", "mime": "image/png"},
|
||||
]}
|
||||
|
||||
Build the image block with :func:`image_content_block`. Each provider's
|
||||
``chat()`` translates a list ``content`` into its own wire format (Anthropic's
|
||||
``source.base64`` blocks / OpenAI's ``image_url`` data-URI blocks) — see
|
||||
``_split``/``_to_api_messages`` in ``anthropic.py``/``openai_compat.py``.
|
||||
Only providers with ``supports_vision = True`` should be sent one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
|
||||
def image_content_block(image_bytes: bytes, mime: str = "image/png") -> Dict[str, Any]:
|
||||
"""The canonical image content block (see module docstring) for
|
||||
``image_bytes`` — base64-encodes once here so every call site/provider
|
||||
shares the exact same encoding."""
|
||||
return {"type": "image", "data": base64.b64encode(image_bytes).decode("ascii"), "mime": mime}
|
||||
|
||||
|
||||
_MAX_RETRIES = 6 # auto-retry on rate-limit (429) up to this many times
|
||||
|
||||
# Appended to a gateway's "model not found/unavailable" error (see
|
||||
# openai_compat.py::_error_text) — recoverable by picking a different model,
|
||||
# not a real outage. ui/chat_panel.py checks for this exact marker to decide
|
||||
# whether to restore the user's typed message into the composer so they can
|
||||
# just switch model and resend instead of retyping the whole prompt.
|
||||
MODEL_NOT_FOUND_HINT = "\n→ Hãy chọn model khác trong ⚙ Settings rồi gửi lại tin nhắn."
|
||||
|
||||
|
||||
def is_model_not_found_error(err: str) -> bool:
|
||||
return MODEL_NOT_FOUND_HINT in (err or "")
|
||||
|
||||
|
||||
# Callback invoked with each streamed text fragment.
|
||||
TextCallback = Callable[[str], None]
|
||||
# Returns True when the caller wants to abort the in-flight request.
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
|
||||
class CancelWatchdog:
|
||||
"""Closes a streaming response as soon as ``cancel()`` reports True.
|
||||
|
||||
``resp.iter_lines()`` only gets a chance to check ``cancel()`` between
|
||||
chunks actually received from the socket — if the server goes quiet
|
||||
(e.g. "thinking" with no bytes sent yet), that blocking read can't be
|
||||
pre-empted from outside and Stop has no visible effect until the next
|
||||
byte arrives or the read timeout elapses (up to 600s). This runs a
|
||||
lightweight poller (same 0.2s-poll style as ``deps.run_cancellable``'s
|
||||
subprocess cancellation) alongside the blocking read and force-closes
|
||||
the response the moment cancellation is requested, which unblocks
|
||||
``iter_lines()`` with a ``requests.RequestException`` the caller already
|
||||
treats as a cancelled stream."""
|
||||
|
||||
def __init__(self, resp, cancel: Optional[CancelFn], poll_secs: float = 0.15):
|
||||
self._resp = resp
|
||||
self._cancel = cancel
|
||||
self._poll_secs = poll_secs
|
||||
self._done = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def __enter__(self) -> "CancelWatchdog":
|
||||
if self._cancel is not None:
|
||||
self._thread = threading.Thread(target=self._watch, daemon=True)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def _watch(self) -> None:
|
||||
while not self._done.is_set():
|
||||
# Support both Callable and threading.Event
|
||||
if hasattr(self._cancel, "is_set"):
|
||||
cancelled = self._cancel.is_set()
|
||||
else:
|
||||
cancelled = bool(self._cancel())
|
||||
if cancelled:
|
||||
try:
|
||||
self._resp.close()
|
||||
except Exception: # noqa: BLE001 — best-effort, never crash the watchdog
|
||||
pass
|
||||
return
|
||||
self._done.wait(self._poll_secs)
|
||||
|
||||
def __exit__(self, *exc_info) -> None:
|
||||
self._done.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
|
||||
# Reasoning models (Qwen3, DeepSeek-R1, …) stream their private "thinking" apart
|
||||
# from the answer. This matches an inline <think>…</think> block so we can drop it
|
||||
# from the visible answer when a server inlines it into the content stream.
|
||||
_THINK_BLOCK = re.compile(r"<think>.*?</think>\s*", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
class ThinkStreamSplitter:
|
||||
"""Splits a *streamed* content string into answer text and reasoning text.
|
||||
|
||||
Many OpenAI-compatible gateways inline a reasoning model's thoughts as a
|
||||
``<think>…</think>`` block right inside the ``content`` stream (instead of a
|
||||
separate ``reasoning_content`` field). Feeding every chunk through this
|
||||
splitter routes the text inside ``<think>…</think>`` to ``on_reasoning`` (so the
|
||||
UI shows a "Thinking" indicator) and everything else to ``on_text`` (the visible
|
||||
answer). Tags that straddle chunk boundaries are handled by holding back a small
|
||||
tail until the next chunk arrives; call :meth:`flush` when the stream ends."""
|
||||
|
||||
_OPEN = "<think>"
|
||||
_CLOSE = "</think>"
|
||||
|
||||
def __init__(self, on_text=None, on_reasoning=None):
|
||||
self._on_text = on_text
|
||||
self._on_reasoning = on_reasoning
|
||||
self._buf = ""
|
||||
self._in_think = False
|
||||
|
||||
def feed(self, piece: str) -> None:
|
||||
if not piece:
|
||||
return
|
||||
self._buf += piece
|
||||
self._drain()
|
||||
|
||||
def flush(self) -> None:
|
||||
if self._buf:
|
||||
self._emit(self._buf)
|
||||
self._buf = ""
|
||||
|
||||
# -- internals -----------------------------------------------------
|
||||
def _emit(self, text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
cb = self._on_reasoning if self._in_think else self._on_text
|
||||
if cb:
|
||||
cb(text)
|
||||
|
||||
def _partial_tail(self, tag: str) -> int:
|
||||
"""How many trailing chars of the buffer could be the start of ``tag``
|
||||
(so we hold them back rather than emit a half-written tag)."""
|
||||
for k in range(min(len(tag) - 1, len(self._buf)), 0, -1):
|
||||
if self._buf[-k:].lower() == tag[:k].lower():
|
||||
return k
|
||||
return 0
|
||||
|
||||
def _drain(self) -> None:
|
||||
while self._buf:
|
||||
tag = self._CLOSE if self._in_think else self._OPEN
|
||||
idx = self._buf.lower().find(tag)
|
||||
if idx == -1:
|
||||
keep = self._partial_tail(tag)
|
||||
cut = len(self._buf) - keep
|
||||
if cut > 0:
|
||||
self._emit(self._buf[:cut])
|
||||
self._buf = self._buf[cut:]
|
||||
return
|
||||
self._emit(self._buf[:idx])
|
||||
self._buf = self._buf[idx + len(tag):]
|
||||
self._in_think = not self._in_think
|
||||
|
||||
|
||||
class ProviderError(RuntimeError):
|
||||
"""Raised for any provider/transport failure (network, auth, bad status)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSpec:
|
||||
"""A tool the model may call. ``parameters`` is a JSON-Schema object."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: Dict[str, Any]
|
||||
|
||||
def to_openai(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters,
|
||||
},
|
||||
}
|
||||
|
||||
def to_anthropic(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"input_schema": self.parameters,
|
||||
}
|
||||
|
||||
|
||||
class Provider:
|
||||
"""Abstract provider. Subclasses implement :meth:`chat`."""
|
||||
|
||||
name = "base"
|
||||
# Can this provider's chat() accept a list-of-blocks `content` (image
|
||||
# attached)? False by default — a provider must opt in once it actually
|
||||
# translates the block shape in its own request-building code.
|
||||
supports_vision = False
|
||||
|
||||
def __init__(self, conf: Dict[str, Any]):
|
||||
self.conf = conf
|
||||
self.model = conf.get("model", "")
|
||||
# Set by list_models() on failure (network/auth/bad-response) instead of
|
||||
# silently swallowing the error — Settings' "Test connection" / "Load
|
||||
# models" surfaces this so "model won't load" has a concrete reason.
|
||||
self.last_error = ""
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[ToolSpec]] = None,
|
||||
on_text: Optional[TextCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_reasoning: Optional[TextCallback] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run one turn.
|
||||
|
||||
Streams answer fragments via ``on_text`` and (for reasoning models) private
|
||||
"thinking" fragments via ``on_reasoning`` — the caller uses the latter only
|
||||
to show a live "thinking" indicator, never as part of the answer. Returns the
|
||||
canonical assistant message ``{"role": "assistant", "content": str,
|
||||
"tool_calls": [...]}``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def strip_think(text: str) -> str:
|
||||
"""Remove any inline ``<think>…</think>`` block from a final answer — a
|
||||
safety net for servers that fold reasoning into the content stream instead
|
||||
of a separate reasoning field."""
|
||||
if not text or "<think>" not in text.lower():
|
||||
return text
|
||||
return _THINK_BLOCK.sub("", text).strip()
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Return available model ids for this provider (empty if unsupported).
|
||||
On failure, subclasses set ``self.last_error`` with a human-readable
|
||||
reason instead of just returning ``[]``."""
|
||||
return []
|
||||
|
||||
def test_connection(self) -> "tuple[bool, str]":
|
||||
"""Best-effort connectivity check for Settings' 'Test connection'
|
||||
button — calls list_models() and turns the result into a message the
|
||||
user can actually act on (vs. a silent empty model list).
|
||||
|
||||
Checked ``last_error`` FIRST, even when models is non-empty: some
|
||||
providers (Anthropic) return a built-in fallback list on failure, so a
|
||||
non-empty result alone doesn't prove the connection actually worked."""
|
||||
self.last_error = ""
|
||||
models = self.list_models()
|
||||
if self.last_error:
|
||||
return False, self.last_error
|
||||
if models:
|
||||
return True, f"OK — {len(models)} model(s) available."
|
||||
return False, "No models returned. Check base_url/API key and network access."
|
||||
|
||||
# -- shared helpers ------------------------------------------------
|
||||
@staticmethod
|
||||
def _is_cancelled(cancel) -> bool:
|
||||
"""Check cancel — supports both Callable and threading.Event (immediate)."""
|
||||
if cancel is None:
|
||||
return False
|
||||
# threading.Event or anything with is_set() — O(1), no function call overhead
|
||||
if hasattr(cancel, "is_set"):
|
||||
return cancel.is_set()
|
||||
# Legacy callable (worker.is_cancelled bound method)
|
||||
try:
|
||||
return bool(cancel())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _retry_after(resp) -> int:
|
||||
"""Seconds to wait before retrying a 429 — from the Retry-After header or
|
||||
a 'try again in Ns' hint in the body; capped to keep the UI responsive."""
|
||||
ra = getattr(resp, "headers", {}).get("Retry-After")
|
||||
if ra:
|
||||
try:
|
||||
return min(120, max(1, int(float(ra))))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
m = re.search(r"in\s+(\d+)\s*s", resp.text)
|
||||
if m:
|
||||
return min(120, max(1, int(m.group(1))))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 20
|
||||
|
||||
def _wait_or_cancel(self, seconds: int, cancel, on_text, attempt: int) -> bool:
|
||||
"""Sleep ``seconds`` in small steps (so Stop works). Returns True if the
|
||||
user cancelled during the wait."""
|
||||
if on_text:
|
||||
on_text(f"\n⏳ Rate limit — waiting {seconds}s, then retrying (attempt {attempt})…\n")
|
||||
for _ in range(max(1, seconds * 2)):
|
||||
if self._is_cancelled(cancel):
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_context_overflow(text: str) -> bool:
|
||||
"""True when an error means the prompt exceeded the model context window."""
|
||||
t = (text or "").lower()
|
||||
return any(s in t for s in (
|
||||
"context length", "context window", "maximum context", "context_length_exceeded",
|
||||
"input tokens", "reduce the length", "too many tokens", "maximum_tokens",
|
||||
"max_tokens", "prompt is too long",
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _drop_oldest_turn(messages: List[Dict[str, Any]]):
|
||||
"""Drop the oldest complete user→(assistant/tool) turn, keeping leading
|
||||
system messages. Returns ``(new_messages, changed)``."""
|
||||
n = len(messages)
|
||||
i = 0
|
||||
while i < n and messages[i].get("role") == "system":
|
||||
i += 1
|
||||
if i >= n:
|
||||
return messages, False
|
||||
j = i + 1
|
||||
while j < n and messages[j].get("role") != "user":
|
||||
j += 1
|
||||
if j >= n:
|
||||
return messages, False # only one turn left — can't trim further
|
||||
return messages[:i] + messages[j:], True
|
||||
|
||||
# Below this, a message's own content is truncated rather than dropped —
|
||||
# so shrinking never removes a whole turn's worth of context, only shaves
|
||||
# the oversized one down.
|
||||
_MIN_SHRINKABLE_CHARS = 2000
|
||||
|
||||
@classmethod
|
||||
def _shrink_last_message(cls, messages: List[Dict[str, Any]]):
|
||||
"""Cut the last message's own text content in half.
|
||||
|
||||
``_drop_oldest_turn`` can't help when the overflow is inside a single
|
||||
turn — most commonly the very first message of a new conversation,
|
||||
oversized because a large file attachment's extracted text got
|
||||
embedded directly into that message's content. Without this, such a
|
||||
turn can NEVER be auto-compacted (there is nothing "older" to drop)
|
||||
and the raw gateway error would surface to the user every time.
|
||||
Returns ``(new_messages, changed)``."""
|
||||
if not messages:
|
||||
return messages, False
|
||||
last = messages[-1]
|
||||
content = last.get("content")
|
||||
if not isinstance(content, str) or len(content) < cls._MIN_SHRINKABLE_CHARS:
|
||||
return messages, False # nothing left worth shrinking
|
||||
half = len(content) // 2
|
||||
trimmed = content[:half] + "\n\n…(nội dung đã bị cắt bớt tự động vì quá dài cho model này)…"
|
||||
new_last = dict(last)
|
||||
new_last["content"] = trimmed
|
||||
return messages[:-1] + [new_last], True
|
||||
|
||||
@staticmethod
|
||||
def _friendly_context_error(err: str) -> str:
|
||||
return (
|
||||
"Nội dung quá dài cho model này ngay cả sau khi tự nén lịch sử/cắt bớt "
|
||||
"tin nhắn. Hãy xoá bớt file đính kèm, chia nhỏ yêu cầu, hoặc đổi sang một "
|
||||
f"model có context lớn hơn.\n\n{err}"
|
||||
)
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.name}:{self.model}"
|
||||
|
||||
# -- TLS: auto-recover from a self-signed/internal-CA gateway ------
|
||||
def _request(self, method: str, url: str, **kwargs):
|
||||
"""Like ``requests.post``/``requests.get`` (dispatched by ``method``),
|
||||
with one difference: if the gateway presents a self-signed/internal
|
||||
certificate that fails normal verification, this transparently
|
||||
captures and pins that EXACT certificate (trust on first use) and
|
||||
retries once — instead of making the user hunt down a .pem file in
|
||||
Settings. Skipped when an explicit CA bundle is already configured,
|
||||
since that is a deliberate choice.
|
||||
|
||||
Dispatches via ``requests.<method>`` (not ``requests.request``) so
|
||||
tests/callers that patch ``requests.post``/``requests.get`` directly
|
||||
keep working."""
|
||||
from ..core import tls_trust
|
||||
|
||||
return tls_trust.request(method, url, ca_bundle=self.conf.get("ca_bundle"), **kwargs)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Build a provider instance from the application config."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from .anthropic import AnthropicProvider
|
||||
from .base import Provider, ProviderError
|
||||
from .openai_compat import OpenAICompatProvider
|
||||
|
||||
_REGISTRY = {
|
||||
"openai_compat": OpenAICompatProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
# All OpenAI-compatible endpoints (Ollama's /v1 server, the Copilot chat API,
|
||||
# and OpenAI itself) speak the same Chat Completions protocol.
|
||||
"ollama": OpenAICompatProvider,
|
||||
"github_copilot": OpenAICompatProvider,
|
||||
"codex": OpenAICompatProvider,
|
||||
}
|
||||
|
||||
|
||||
def build_provider(name: str, conf: Dict[str, Any]) -> Provider:
|
||||
cls = _REGISTRY.get(name)
|
||||
if cls is None:
|
||||
raise ProviderError(f"Unsupported provider: {name}")
|
||||
return cls(conf)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""OpenAI-compatible provider (internal gateways, Azure OpenAI, LiteLLM, vLLM...).
|
||||
|
||||
Targets the ``POST {base_url}/chat/completions`` streaming endpoint with the
|
||||
standard function-calling schema. Works with any server that speaks the OpenAI
|
||||
Chat Completions API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .base import (
|
||||
CancelFn, CancelWatchdog, MODEL_NOT_FOUND_HINT, Provider, ProviderError,
|
||||
TextCallback, ThinkStreamSplitter, ToolSpec,
|
||||
)
|
||||
|
||||
_TIMEOUT = (5, 30) # (connect, read) seconds — lower for faster Stop response
|
||||
_MAX_RETRIES = 6 # auto-retry on rate-limit (429) up to this many times
|
||||
|
||||
|
||||
class OpenAICompatProvider(Provider):
|
||||
name = "openai_compat"
|
||||
supports_vision = True
|
||||
|
||||
def _url(self) -> str:
|
||||
base = str(self.conf.get("base_url", "")).rstrip("/")
|
||||
if not base:
|
||||
raise ProviderError("base_url is not configured for the OpenAI-compatible provider.")
|
||||
return f"{base}/chat/completions"
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
key = self.conf.get("api_key")
|
||||
if key:
|
||||
headers["Authorization"] = f"Bearer {key}"
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def _to_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for m in messages:
|
||||
role = m["role"]
|
||||
if role == "assistant" and m.get("tool_calls"):
|
||||
out.append({
|
||||
"role": "assistant",
|
||||
"content": m.get("content") or "",
|
||||
"tool_calls": [{
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc["name"],
|
||||
"arguments": json.dumps(tc.get("arguments", {}), ensure_ascii=False),
|
||||
},
|
||||
} for tc in m["tool_calls"]],
|
||||
})
|
||||
elif role == "tool":
|
||||
out.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": m.get("tool_call_id", ""),
|
||||
"content": m.get("content", ""),
|
||||
})
|
||||
else:
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
# Preview tab's region-selection → AI fix flow: a list of
|
||||
# canonical content blocks (see providers/base.py docstring).
|
||||
blocks = []
|
||||
for block in content:
|
||||
if block.get("type") == "image":
|
||||
mime = block.get("mime", "image/png")
|
||||
blocks.append({"type": "image_url", "image_url": {
|
||||
"url": f"data:{mime};base64,{block.get('data', '')}",
|
||||
}})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": block.get("text", "")})
|
||||
out.append({"role": role, "content": blocks})
|
||||
else:
|
||||
out.append({"role": role, "content": content})
|
||||
return out
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Optional[List[ToolSpec]] = None,
|
||||
on_text: Optional[TextCallback] = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
on_reasoning: Optional[TextCallback] = None,
|
||||
) -> Dict[str, Any]:
|
||||
work = list(messages) # local copy we can trim on context overflow
|
||||
payload: Dict[str, Any] = {"model": self.model, "stream": True}
|
||||
if tools:
|
||||
payload["tools"] = [t.to_openai() for t in tools]
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
text_parts: List[str] = []
|
||||
# Accumulate tool-call fragments keyed by streamed index.
|
||||
tool_acc: Dict[int, Dict[str, Any]] = {}
|
||||
usage_seen: Dict[str, Any] = {} # final "usage" block, if the server sends one
|
||||
|
||||
# Some gateways inline reasoning as <think>…</think> in the content stream
|
||||
# (rather than a separate reasoning_content field). Route that to
|
||||
# on_reasoning (→ "Thinking" indicator) and keep the answer bubble clean.
|
||||
def _emit_answer(t: str) -> None:
|
||||
text_parts.append(t)
|
||||
if on_text:
|
||||
on_text(t)
|
||||
|
||||
splitter = ThinkStreamSplitter(on_text=_emit_answer, on_reasoning=on_reasoning)
|
||||
|
||||
for attempt in range(1, _MAX_RETRIES + 2):
|
||||
payload["messages"] = self._to_api_messages(work)
|
||||
try:
|
||||
resp = self._request(
|
||||
"POST", self._url(), headers=self._headers(), json=payload,
|
||||
stream=True, timeout=_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ProviderError(f"Could not reach the gateway: {exc}") from exc
|
||||
# requests/urllib3 falls back to Latin-1 for text/* responses whose
|
||||
# Content-Type omits an explicit charset (common for SSE streams) —
|
||||
# every non-ASCII UTF-8 byte pair then gets misread as two Latin-1
|
||||
# characters ("ô" → "ô"), corrupting every non-English reply. The
|
||||
# body is always UTF-8 JSON/SSE in practice, so force it explicitly
|
||||
# rather than trust the guess.
|
||||
resp.encoding = "utf-8"
|
||||
|
||||
if resp.status_code >= 400:
|
||||
code = resp.status_code
|
||||
wait = self._retry_after(resp)
|
||||
err = self._error_text(resp)
|
||||
resp.close()
|
||||
# Rate limited (TPM/RPM) — wait the suggested time and retry.
|
||||
if code == 429 and attempt <= _MAX_RETRIES:
|
||||
if self._wait_or_cancel(wait, cancel, on_text, attempt):
|
||||
return _assemble_assistant(text_parts, tool_acc) # cancelled
|
||||
continue
|
||||
# Prompt too long — auto-compress and retry. First try dropping the
|
||||
# oldest turn; if there's nothing left to drop (e.g. the very first
|
||||
# message of a new conversation is itself oversized, typically from
|
||||
# a large attachment), shrink that message's own content instead of
|
||||
# giving up immediately.
|
||||
if code == 400 and attempt <= _MAX_RETRIES and self._is_context_overflow(err):
|
||||
work, changed = self._drop_oldest_turn(work)
|
||||
note = "\n✂ Lịch sử quá dài — tự nén bớt rồi thử lại…\n"
|
||||
if not changed:
|
||||
work, changed = self._shrink_last_message(work)
|
||||
note = "\n✂ Tin nhắn/đính kèm quá dài cho model này — tự cắt bớt nội dung rồi thử lại…\n"
|
||||
if changed:
|
||||
if on_text:
|
||||
on_text(note)
|
||||
continue
|
||||
if self._is_context_overflow(err):
|
||||
raise ProviderError(self._friendly_context_error(err))
|
||||
raise ProviderError(err)
|
||||
break # 200 OK → stream the response below
|
||||
|
||||
# Stream the body. A gateway/proxy can drop the connection mid-stream
|
||||
# ("Response ended prematurely" / connection reset): if nothing was
|
||||
# received yet, silently re-send the request a couple of times; if a
|
||||
# partial answer already streamed, keep it and just note the cut —
|
||||
# never surface the raw transport error over usable content.
|
||||
stream_retries = 0
|
||||
# If cancel is a threading.Event (new worker._stop_event), we can wait
|
||||
# on it with a timeout in parallel with the streaming read — this makes
|
||||
# Stop interrupt immediately even during LLM "thinking" silence.
|
||||
cancel_event: Optional[threading.Event] = None
|
||||
if isinstance(cancel, threading.Event):
|
||||
cancel_event = cancel
|
||||
elif hasattr(cancel, "is_set") and callable(getattr(cancel, "wait")):
|
||||
# Duck-type: anything with is_set() and wait() counts as Event-like
|
||||
cancel_event = cancel
|
||||
|
||||
def _wait_cancel(ev: threading.Event, resp: requests.Response) -> None:
|
||||
"""Block until cancel is set, then close the response to unblock iter_lines."""
|
||||
ev.wait()
|
||||
try:
|
||||
resp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cancel_thread: Optional[threading.Thread] = None
|
||||
if cancel_event is not None:
|
||||
cancel_thread = threading.Thread(
|
||||
target=_wait_cancel, args=(cancel_event, resp), daemon=True)
|
||||
cancel_thread.start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
with CancelWatchdog(resp, cancel):
|
||||
for raw in resp.iter_lines(decode_unicode=True):
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if not raw or not raw.startswith("data:"):
|
||||
continue
|
||||
data = raw[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
if chunk.get("usage"):
|
||||
usage_seen = chunk["usage"]
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta", {})
|
||||
# Reasoning models (Qwen3, DeepSeek-R1, …) stream their private
|
||||
# thinking in a separate field — surface it as "thinking" activity
|
||||
# only, never as part of the answer.
|
||||
rc = delta.get("reasoning_content") or delta.get("reasoning")
|
||||
if rc and on_reasoning:
|
||||
on_reasoning(rc)
|
||||
piece = delta.get("content")
|
||||
if piece:
|
||||
splitter.feed(piece) # splits inline <think>…</think> out of the answer
|
||||
for tc in delta.get("tool_calls", []) or []:
|
||||
idx = tc.get("index", 0)
|
||||
slot = tool_acc.setdefault(idx, {"id": "", "name": "", "args": ""})
|
||||
if tc.get("id"):
|
||||
slot["id"] = tc["id"]
|
||||
fn = tc.get("function", {})
|
||||
if fn.get("name"):
|
||||
slot["name"] = fn["name"]
|
||||
if fn.get("arguments"):
|
||||
slot["args"] += fn["arguments"]
|
||||
resp.close()
|
||||
break # stream finished normally (or cancelled)
|
||||
except requests.RequestException as exc:
|
||||
resp.close()
|
||||
# If cancel was requested, close cleanly without retry
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if self._is_cancelled(cancel):
|
||||
break
|
||||
if text_parts or tool_acc:
|
||||
# Partial answer already on screen — keep it, note the cut.
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n")
|
||||
break
|
||||
stream_retries += 1
|
||||
if stream_retries > 2:
|
||||
raise ProviderError(
|
||||
f"Kết nối tới gateway bị ngắt giữa chừng (đã thử lại {stream_retries - 1} lần): {exc}"
|
||||
) from exc
|
||||
if on_text:
|
||||
on_text("\n⚠ Kết nối bị ngắt — đang thử lại…\n")
|
||||
try:
|
||||
resp = self._request(
|
||||
"POST", self._url(), headers=self._headers(), json=payload,
|
||||
stream=True, timeout=_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc2:
|
||||
raise ProviderError(f"Could not reach the gateway: {exc2}") from exc2
|
||||
resp.encoding = "utf-8" # same Latin-1-fallback fix as the initial request
|
||||
if resp.status_code >= 400:
|
||||
err = self._error_text(resp)
|
||||
resp.close()
|
||||
raise ProviderError(err)
|
||||
|
||||
splitter.flush() # emit any held-back tail (partial tag / trailing text)
|
||||
self._record_usage(work, text_parts, tool_acc, usage_seen)
|
||||
return _assemble_assistant(text_parts, tool_acc)
|
||||
|
||||
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
|
||||
"""One Dashboard usage event per turn: real counts when the server's
|
||||
final chunk carried a "usage" block, a ~4 chars/token estimate
|
||||
otherwise. Never breaks the turn."""
|
||||
try:
|
||||
from ..core import usage_tracker as ut
|
||||
|
||||
if usage_seen:
|
||||
ut.record(self.name, self.model,
|
||||
usage_seen.get("prompt_tokens", 0),
|
||||
usage_seen.get("completion_tokens", 0),
|
||||
(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0))
|
||||
else:
|
||||
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
|
||||
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
|
||||
ut.record(self.name, self.model, ut.estimate_tokens(sent),
|
||||
ut.estimate_tokens(got), 0, estimated=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def list_models(self):
|
||||
self.last_error = ""
|
||||
base = str(self.conf.get("base_url", "")).rstrip("/")
|
||||
if not base:
|
||||
self.last_error = "Base URL is not configured (Settings → OpenAI-compatible)."
|
||||
return []
|
||||
try:
|
||||
resp = self._request("GET", f"{base}/models", headers=self._headers(),
|
||||
timeout=(10, 30))
|
||||
if resp.status_code >= 400:
|
||||
self.last_error = self._error_text(resp)
|
||||
return []
|
||||
data = resp.json().get("data", [])
|
||||
ids = [m.get("id") for m in data if isinstance(m, dict) and m.get("id")]
|
||||
if not ids:
|
||||
self.last_error = "Gateway responded but returned no models."
|
||||
return ids
|
||||
except requests.RequestException as exc:
|
||||
self.last_error = f"Could not reach the gateway: {exc}"
|
||||
return []
|
||||
except ValueError as exc:
|
||||
self.last_error = f"Gateway returned an invalid (non-JSON) response: {exc}"
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _error_text(resp: requests.Response) -> str:
|
||||
try:
|
||||
body = resp.json()
|
||||
# Prefer the OpenAI-style {"error": {"message": ...}} shape; some
|
||||
# gateways instead return a FLAT body like {"message": "Not found",
|
||||
# "description": "...", "code": 404} — "description" is usually the
|
||||
# human-readable one there, so try it before falling back to the
|
||||
# generic top-level "message" (often just "Not found") or a raw dump.
|
||||
err_obj = body.get("error")
|
||||
msg = (
|
||||
(err_obj.get("message") if isinstance(err_obj, dict) else None)
|
||||
or body.get("description")
|
||||
or body.get("message")
|
||||
or json.dumps(body)
|
||||
)
|
||||
except ValueError:
|
||||
msg = resp.text[:300]
|
||||
text = f"Gateway error {resp.status_code}: {msg}"
|
||||
if resp.status_code == 404 and "model" in msg.lower():
|
||||
# A model-not-found/unavailable response — this is recoverable by
|
||||
# just picking a different model, not a real outage. Say so
|
||||
# explicitly so the user doesn't read it as the app being broken.
|
||||
text += MODEL_NOT_FOUND_HINT
|
||||
return text
|
||||
|
||||
|
||||
def _assemble_assistant(text_parts: List[str], tool_acc: Dict[int, Dict[str, Any]]) -> Dict[str, Any]:
|
||||
tool_calls: List[Dict[str, Any]] = []
|
||||
for idx in sorted(tool_acc):
|
||||
slot = tool_acc[idx]
|
||||
if not slot["name"]:
|
||||
continue
|
||||
try:
|
||||
args = json.loads(slot["args"]) if slot["args"].strip() else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": slot["args"]}
|
||||
tool_calls.append({
|
||||
"id": slot["id"] or f"call_{idx}",
|
||||
"name": slot["name"],
|
||||
"arguments": args,
|
||||
})
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": Provider.strip_think("".join(text_parts)),
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
Reference in New Issue
Block a user