chore(repo): initialize Cowork Local Gitea repository
CI / test (push) Canceled after 0s

This commit is contained in:
thanhnv
2026-08-09 20:12:05 +07:00
commit 414eaddca3
192 changed files with 48160 additions and 0 deletions
+10
View File
@@ -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"]
+320
View File
@@ -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}"
+402
View File
@@ -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)
+25
View File
@@ -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)
+358
View File
@@ -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,
}