Files
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00: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)