## 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:
+47
-7
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user