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

## 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>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+47 -7
View File
@@ -15,14 +15,24 @@ _MAX_RETRIES = 6 # auto-retry on rate-limit (429) / overloaded
class AnthropicProvider(Provider):
"""Adapter cho API Messages của Anthropic.
Khác OpenAI ở ba chỗ: prompt hệ thống nằm ở tham số ``system`` riêng chứ
không phải một tin nhắn, xác thực bằng header ``x-api-key``, và khối
nội dung là danh sách block chứ không phải chuỗi.
"""
name = "anthropic"
supports_vision = True
def _url(self) -> str:
"""Endpoint ``/v1/messages``; mặc định là api.anthropic.com nếu không đặt ``base_url``."""
base = str(self.conf.get("base_url") or "https://api.anthropic.com").rstrip("/")
return f"{base}/v1/messages"
def _headers(self) -> Dict[str, str]:
"""Header cho một lượt gọi. Thiếu khoá thì báo lỗi ngay — Anthropic không
có chế độ chạy cục bộ không cần khoá như Ollama.
"""
key = self.conf.get("api_key")
if not key:
raise ProviderError("Anthropic API key is not configured.")
@@ -35,6 +45,12 @@ class AnthropicProvider(Provider):
_FALLBACK_MODELS = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]
def list_models(self):
"""Danh sách model; hỏi được API thì dùng, không thì rơi về danh sách dựng sẵn.
Không bao giờ trả về rỗng: người dùng luôn phải chọn được một model, kể
cả khi mạng nội bộ chặn ``/v1/models``. Lý do thất bại ghi vào
``last_error`` để giao diện hiện ra.
"""
self.last_error = ""
base = str(self.conf.get("base_url") or "https://api.anthropic.com").rstrip("/")
try:
@@ -58,6 +74,11 @@ class AnthropicProvider(Provider):
@staticmethod
def _split(messages: List[Dict[str, Any]]):
"""Tách lịch sử thành (prompt hệ thống, danh sách tin nhắn) theo khuôn Anthropic.
Anthropic nhận prompt hệ thống ở một tham số riêng, nên mọi tin nhắn
role ``system`` phải được gom lại và bỏ khỏi danh sách.
"""
system_parts: List[str] = []
api: List[Dict[str, Any]] = []
for m in messages:
@@ -117,6 +138,12 @@ class AnthropicProvider(Provider):
cancel: Optional[CancelFn] = None,
on_reasoning: Optional[TextCallback] = None,
) -> Dict[str, Any]:
"""Chạy một lượt chat có stream, có gọi tool, tự thử lại khi bị giới hạn tốc độ
hoặc máy chủ quá tải.
Giữ bản sao ``work`` của lịch sử để cắt bớt và gửi lại được khi tràn
context — không đụng vào danh sách của chỗ gọi.
"""
work = list(messages) # local copy we can trim on context overflow
payload: Dict[str, Any] = {
"model": self.model,
@@ -292,19 +319,31 @@ class AnthropicProvider(Provider):
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.
# Usage event — real counts from the stream's usage events, else a
# ~4 chars/token estimate. Published to the telemetry sink (R03-T06)
# rather than written straight to the Dashboard store, so the provider
# stays a pure transport adapter. Never breaks the turn.
try:
from ..core import usage_tracker as ut
from ..infrastructure.telemetry import usage_sink
if usage_seen:
ut.record(self.name, self.model, usage_seen.get("in", 0),
usage_seen.get("out", 0), usage_seen.get("cache", 0))
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_seen.get("in", 0),
output_tokens=usage_seen.get("out", 0),
cached_tokens=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)
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_sink.estimate_tokens(sent),
output_tokens=usage_sink.estimate_tokens(got),
estimated=True,
))
except Exception: # noqa: BLE001
pass
@@ -312,6 +351,7 @@ class AnthropicProvider(Provider):
@staticmethod
def _error_text(resp: requests.Response) -> str:
"""Rút câu lỗi dễ đọc từ phản hồi lỗi của Anthropic, kèm mã HTTP."""
try:
body = resp.json()
msg = body.get("error", {}).get("message") or json.dumps(body)
+66
View File
@@ -52,6 +52,9 @@ MODEL_NOT_FOUND_HINT = "\n→ Hãy chọn model khác trong ⚙ Settings rồi g
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 "")
@@ -76,6 +79,7 @@ class CancelWatchdog:
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
@@ -83,12 +87,21 @@ class CancelWatchdog:
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"):
@@ -104,6 +117,11 @@ class CancelWatchdog:
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)
@@ -130,24 +148,36 @@ class ThinkStreamSplitter:
_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
@@ -163,6 +193,11 @@ class ThinkStreamSplitter:
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)
@@ -191,6 +226,7 @@ class ToolSpec:
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": {
@@ -201,6 +237,7 @@ class ToolSpec:
}
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,
@@ -218,12 +255,21 @@ class Provider:
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,
@@ -274,6 +320,24 @@ class Provider:
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:
@@ -375,6 +439,7 @@ class Provider:
@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 "
@@ -382,6 +447,7 @@ class Provider:
)
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 ------
+24 -17
View File
@@ -1,25 +1,32 @@
"""Build a provider instance from the application config."""
"""Build a provider instance from the application config.
Kept as the historic entry point (``providers.build_provider``) that call sites
across the app already import, but it no longer owns a provider table of its
own: since R03-T02 the catalogue lives in
``infrastructure/providers/provider_registry.py`` so provider ids, wire
protocols, default models and capabilities are declared exactly once.
"""
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)
"""Construct the adapter registered for ``name``.
Delegates to the central registry and translates its lookup failure into
:class:`ProviderError`, because every existing call site (chat turns,
Settings' connection test, the routing prober) already handles that type —
changing the exception would ripple into unrelated error handling.
"""
from ..infrastructure.providers.provider_registry import (
ProviderNotFoundError,
default_registry,
)
try:
return default_registry().build(name, conf)
except ProviderNotFoundError as exc:
raise ProviderError(f"Unsupported provider: {name}") from exc
+65 -10
View File
@@ -22,16 +22,28 @@ _MAX_RETRIES = 6 # auto-retry on rate-limit (429) up to this many times
class OpenAICompatProvider(Provider):
"""Adapter cho mọi endpoint nói giao thức OpenAI: gateway nội bộ, Ollama,
GitHub Copilot.
Một lớp dùng chung cho nhiều nhà cung cấp vì phần khác nhau giữa chúng
chỉ là ``base_url`` và cách gắn khoá — đều nằm trong ``conf``.
"""
name = "openai_compat"
supports_vision = True
def _url(self) -> str:
"""Endpoint ``/chat/completions``. Chưa cấu hình ``base_url`` thì báo lỗi
ngay tại đây, thay vì để lỗi nổ ra ở tận tầng HTTP.
"""
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]:
"""Header cho một lượt gọi; không có khoá thì bỏ hẳn ``Authorization``
(Ollama chạy cục bộ không cần khoá).
"""
headers = {"Content-Type": "application/json"}
key = self.conf.get("api_key")
if key:
@@ -40,6 +52,11 @@ class OpenAICompatProvider(Provider):
@staticmethod
def _to_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Đổi lịch sử hội thoại nội bộ sang đúng khuôn OpenAI mong đợi.
Chỗ khác biệt thật sự là lượt gọi tool: bên trong app lưu một tin nhắn
assistant kèm ``tool_calls``, còn API đòi tham số phải là chuỗi JSON.
"""
out: List[Dict[str, Any]] = []
for m in messages:
role = m["role"]
@@ -89,6 +106,13 @@ class OpenAICompatProvider(Provider):
cancel: Optional[CancelFn] = None,
on_reasoning: Optional[TextCallback] = None,
) -> Dict[str, Any]:
"""Chạy một lượt chat có stream, có gọi tool, tự thử lại khi bị giới hạn tốc độ.
Giữ một bản sao ``work`` của lịch sử để khi tràn context còn cắt bớt và
gửi lại được — không đụng vào danh sách của chỗ gọi. Suy luận nội bộ mà
gateway nhét thẳng vào ``content`` dưới dạng ``<think>…</think>`` được
tách ra qua ``ThinkStreamSplitter`` để bong bóng trả lời sạch.
"""
work = list(messages) # local copy we can trim on context overflow
payload: Dict[str, Any] = {"model": self.model, "stream": True}
if tools:
@@ -104,6 +128,7 @@ class OpenAICompatProvider(Provider):
# (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:
"""Gom phần trả lời (đã tách khỏi khối suy luận) và đẩy dần ra ngoài."""
text_parts.append(t)
if on_text:
on_text(t)
@@ -266,26 +291,45 @@ class OpenAICompatProvider(Provider):
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."""
"""Publish one usage event per turn: real counts when the server's final
chunk carried a "usage" block, a ~4 chars/token estimate otherwise.
Since R03-T06 this only *describes* what the turn consumed and hands the
event to ``infrastructure/telemetry/usage_sink.py``; deciding where the
numbers land (Dashboard files, cost meters, tests) belongs to the
subscribers, not to a provider adapter. Never breaks the turn.
"""
try:
from ..core import usage_tracker as ut
from ..infrastructure.telemetry import usage_sink
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))
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_seen.get("prompt_tokens", 0),
output_tokens=usage_seen.get("completion_tokens", 0),
cached_tokens=(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0),
))
else:
# No usage block from the gateway — approximate from the exact
# bytes we sent and received so the Dashboard still shows a
# (clearly flagged) figure instead of a silent zero.
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)
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_sink.estimate_tokens(sent),
output_tokens=usage_sink.estimate_tokens(got),
estimated=True,
))
except Exception: # noqa: BLE001
pass
def list_models(self):
"""Danh sách model của gateway; lỗi thì trả về list rỗng và ghi lý do vào
``last_error`` để giao diện hiện được thay vì im lặng.
"""
self.last_error = ""
base = str(self.conf.get("base_url", "")).rstrip("/")
if not base:
@@ -311,6 +355,12 @@ class OpenAICompatProvider(Provider):
@staticmethod
def _error_text(resp: requests.Response) -> str:
"""Rút câu lỗi dễ đọc nhất từ phản hồi lỗi của gateway.
Mỗi gateway trả một khuôn khác nhau: chuẩn OpenAI là
``{"error": {"message": ...}}``, có nơi trả phẳng với ``description`` mới
là câu dành cho người đọc còn ``message`` chỉ là "Not found".
"""
try:
body = resp.json()
# Prefer the OpenAI-style {"error": {"message": ...}} shape; some
@@ -337,6 +387,11 @@ class OpenAICompatProvider(Provider):
def _assemble_assistant(text_parts: List[str], tool_acc: Dict[int, Dict[str, Any]]) -> Dict[str, Any]:
"""Ghép các mẩu stream thành một tin nhắn assistant hoàn chỉnh.
Tham số tool về theo từng mẩu nên phải nối lại rồi mới parse; JSON hỏng
thì giữ nguyên chuỗi thô trong ``_raw`` thay vì làm vỡ cả lượt chat.
"""
tool_calls: List[Dict[str, Any]] = []
for idx in sorted(tool_acc):
slot = tool_acc[idx]