Files
cowork-local/providers/base.py
T
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
Hồi quy đã vá
-------------
F-12  Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
      `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
      `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
      tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
      là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
      gắn 1 tệp, khớp bản trước refactor.

F-01  Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
      `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
      `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
      tiên hỏng thì đổi provider chính là lúc phải thử lại.

F-07  Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
      lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
      người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
      theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
      mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
      `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.

F-11  Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
      mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
      và không bao giờ chạy.

Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:

    graph_renderer.py         -> graph_scene_builder.py + graph_export.py
    co4e_workflow_service.py  -> co4e_run_history.py
    json_config_repository.py -> config_sections.py
    agents_admin_tab.py       -> shared/agent_kind_visuals.py

File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.

Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.

Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.

859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:41:45 +09:00

468 lines
20 KiB
Python

"""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:
"""``True`` khi thông báo lỗi là loại "không có model này" — chỗ gọi dựa vào
đây để gợi ý đổi model thay vì báo lỗi chung chung.
"""
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):
"""Canh cờ huỷ trong lúc một lượt gọi HTTP đang chờ."""
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":
"""Bắt đầu canh. Không có hàm huỷ thì không dựng luồng nào — đây là đường đi
thường gặp nhất, không đáng tốn một luồng.
"""
if self._cancel is not None:
self._thread = threading.Thread(target=self._watch, daemon=True)
self._thread.start()
return self
def _watch(self) -> None:
"""Luồng canh: thấy cờ huỷ là đóng thẳng response đang chờ.
Đóng socket là cách duy nhất cắt được một lượt stream đang treo — nếu chỉ
đặt cờ, ``iter_lines()`` vẫn chờ tới khi máy chủ gửi tiếp hoặc hết giờ.
Nhận cả ``Callable`` lẫn ``threading.Event`` để chỗ gọi khỏi phải đổi kiểu.
"""
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:
"""Dừng canh và chờ luồng thoát, tối đa 1 giây.
Có chờ, vì luồng canh còn giữ tham chiếu tới response; bỏ mặc nó thì đóng
kết nối xong luồng vẫn đang đọc.
"""
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):
"""Tách dòng chữ model trả về thành phần suy nghĩ và phần trả lời.
Có bộ đệm riêng vì thẻ ``<think>`` có thể bị cắt làm đôi giữa hai gói dữ
liệu — xét từng gói rời rạc sẽ bỏ sót thẻ.
"""
self._on_text = on_text
self._on_reasoning = on_reasoning
self._buf = ""
self._in_think = False
def feed(self, piece: str) -> None:
"""Đưa một mẩu vừa nhận từ luồng stream vào bộ tách."""
if not piece:
return
self._buf += piece
self._drain()
def flush(self) -> None:
"""Kết thúc luồng: đẩy nốt phần còn giữ lại trong bộ đệm.
Bắt buộc gọi khi stream đóng, nếu không phần đuôi đang giữ chờ ghép thẻ
``<think>`` sẽ mất hẳn.
"""
if self._buf:
self._emit(self._buf)
self._buf = ""
# -- internals -----------------------------------------------------
def _emit(self, text: str) -> None:
"""Gửi văn bản ra đúng callback tuỳ đang ở trong hay ngoài khối ``<think>``."""
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:
"""Rút bộ đệm, cắt tại mỗi thẻ mở/đóng và lật trạng thái.
Không tìm thấy thẻ thì vẫn giữ lại phần đuôi có thể là nửa thẻ viết dở
(``<thi``) — phát ra sớm là chữ rác lọt vào bong bóng trả lời.
"""
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]:
"""Khai báo tool theo định dạng OpenAI function-calling."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
def to_anthropic(self) -> Dict[str, Any]:
"""Khai báo tool theo định dạng Anthropic — khác OpenAI ở tên khoá schema."""
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]):
"""``last_error`` được đặt khi ``list_models()`` hỏng, thay vì nuốt lỗi: màn Cài
đặt hiện nó ra để "không nạp được model" có một lý do cụ thể.
"""
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 = ""
# Where this provider's token usage goes (R03-T06). None means "the
# process-wide default sink", resolved lazily in _emit_usage so that a
# test can swap the destination without rebuilding every provider.
# Set it per instance to bill one run somewhere else (a workflow, a
# scheduled task) without touching global state.
self.usage_sink = None
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."
# -- telemetry -----------------------------------------------------
def _emit_usage(self, event) -> None:
"""Hand one ``UsageEvent`` to this provider's usage sink.
Never raises: recording how many tokens a turn cost must not be able to
fail the turn itself. Falls back to the process-wide default sink so
existing call sites keep reporting to the Dashboard exactly as before
(see infrastructure/telemetry/usage_sink.py)."""
try:
sink = self.usage_sink
if sink is None:
from ..infrastructure.telemetry import usage_sink as telemetry
sink = telemetry.default_sink
sink.record(event)
except Exception: # noqa: BLE001 — telemetry is never worth a failed turn
pass
# -- 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:
"""Đổi lỗi tràn context thành câu tiếng Việt nói rõ phải làm gì tiếp."""
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:
"""Chuỗi ``provider:model`` để ghi log và hiện lên thanh trạng thái."""
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)