321 lines
15 KiB
Python
321 lines
15 KiB
Python
"""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}"
|