ata-coder 2.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Anthropic Messages API async client — provider-agnostic.
|
|
3
|
+
|
|
4
|
+
Works with ANY Anthropic-compatible endpoint:
|
|
5
|
+
- Native Anthropic: https://api.anthropic.com
|
|
6
|
+
- DeepSeek Anthropic: https://api.deepseek.com/anthropic
|
|
7
|
+
- Any proxy/gateway: http://localhost:8080
|
|
8
|
+
|
|
9
|
+
Configuration:
|
|
10
|
+
ATA_CODER_BASE_URL → base URL (auto-appends /anthropic if needed)
|
|
11
|
+
ATA_CODER_API_KEY → API key
|
|
12
|
+
ATA_CODER_DEFAULT_MODEL → model name
|
|
13
|
+
ANTHROPIC_MODEL_MAP → optional JSON mapping, e.g. '{"claude-opus":"deepseek-v4-pro"}'
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import random
|
|
21
|
+
from typing import Any, AsyncIterator, Callable
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from .config import LLMConfig
|
|
26
|
+
from .types import BaseLLMClient, Message, ToolDef
|
|
27
|
+
from .utils import enhance_api_error
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AnthropicClient(BaseLLMClient):
|
|
33
|
+
"""Async HTTP client for Anthropic-compatible Messages API. Provider-agnostic."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, config: LLMConfig | None = None):
|
|
36
|
+
self.config = config or LLMConfig()
|
|
37
|
+
self._tools: list[ToolDef] = []
|
|
38
|
+
self._usage_callback: Callable[[int, int], None] | None = None
|
|
39
|
+
self._total_prompt_tokens = 0
|
|
40
|
+
self._total_completion_tokens = 0
|
|
41
|
+
|
|
42
|
+
# ── URL — provider-agnostic ────────────────────────────────────
|
|
43
|
+
base = self.config.base_url.rstrip("/")
|
|
44
|
+
# If the base URL already points to a messages endpoint, use it directly
|
|
45
|
+
if "/messages" in base:
|
|
46
|
+
self._api_url = base
|
|
47
|
+
else:
|
|
48
|
+
# Standard: base_url/v1/messages
|
|
49
|
+
# Auto-add /v1 if missing
|
|
50
|
+
if not any(seg in base for seg in ("/v1", "/v2")):
|
|
51
|
+
base += "/v1"
|
|
52
|
+
self._api_url = f"{base}/messages"
|
|
53
|
+
|
|
54
|
+
# ── Model — with optional mapping ──────────────────────────────
|
|
55
|
+
self._model = self.config.model
|
|
56
|
+
map_json = ""
|
|
57
|
+
# Read from settings/env only at init time (not on every call)
|
|
58
|
+
map_json = os.getenv("ANTHROPIC_MODEL_MAP", "")
|
|
59
|
+
if map_json:
|
|
60
|
+
try:
|
|
61
|
+
model_map = json.loads(map_json)
|
|
62
|
+
if self._model in model_map:
|
|
63
|
+
self._model = model_map[self._model]
|
|
64
|
+
else:
|
|
65
|
+
logger.warning("Model %r not found in ANTHROPIC_MODEL_MAP, using as-is", self._model)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
logger.warning("ANTHROPIC_MODEL_MAP is invalid JSON, ignoring")
|
|
68
|
+
|
|
69
|
+
# ── Headers — Anthropic standard ───────────────────────────────
|
|
70
|
+
self._headers = {
|
|
71
|
+
"x-api-key": self.config.api_key,
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
}
|
|
74
|
+
# Native Anthropic requires this header; proxies may ignore it
|
|
75
|
+
if os.getenv("ANTHROPIC_VERSION"):
|
|
76
|
+
self._headers["anthropic-version"] = os.getenv("ANTHROPIC_VERSION")
|
|
77
|
+
|
|
78
|
+
self._client = httpx.AsyncClient(
|
|
79
|
+
timeout=httpx.Timeout(300.0, connect=30.0),
|
|
80
|
+
headers=self._headers,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Retry config
|
|
84
|
+
self._max_retries = 3
|
|
85
|
+
self._retry_base_delay = 1.0 # seconds
|
|
86
|
+
|
|
87
|
+
def on_usage(self, callback: Callable[[int, int], None]) -> None:
|
|
88
|
+
self._usage_callback = callback
|
|
89
|
+
|
|
90
|
+
def register_tools(self, tools: list[ToolDef]) -> None:
|
|
91
|
+
"""Convert OpenAI-format tools to Anthropic format."""
|
|
92
|
+
result = []
|
|
93
|
+
for t in tools:
|
|
94
|
+
fn = t.get("function", t)
|
|
95
|
+
result.append({
|
|
96
|
+
"name": fn.get("name", ""),
|
|
97
|
+
"description": fn.get("description", ""),
|
|
98
|
+
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
|
|
99
|
+
})
|
|
100
|
+
self._tools = result
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def total_prompt_tokens(self) -> int: return self._total_prompt_tokens
|
|
104
|
+
@property
|
|
105
|
+
def total_completion_tokens(self) -> int: return self._total_completion_tokens
|
|
106
|
+
@property
|
|
107
|
+
def total_tokens(self) -> int: return self._total_prompt_tokens + self._total_completion_tokens
|
|
108
|
+
|
|
109
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
110
|
+
# Chat (non-streaming)
|
|
111
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
112
|
+
|
|
113
|
+
async def chat(self, messages: list[Message], system_prompt: str = "",
|
|
114
|
+
tools: list[ToolDef] | None = None) -> Message:
|
|
115
|
+
tool_defs = tools if tools is not None else self._tools
|
|
116
|
+
anthropic_msgs, system = self._convert_messages(messages, system_prompt)
|
|
117
|
+
|
|
118
|
+
body: dict[str, Any] = {
|
|
119
|
+
"model": self._model,
|
|
120
|
+
"messages": anthropic_msgs,
|
|
121
|
+
"max_tokens": self.config.max_tokens,
|
|
122
|
+
}
|
|
123
|
+
if system:
|
|
124
|
+
body["system"] = system
|
|
125
|
+
if tool_defs:
|
|
126
|
+
body["tools"] = tool_defs
|
|
127
|
+
|
|
128
|
+
self._apply_thinking(body)
|
|
129
|
+
|
|
130
|
+
# Sanitize surrogates before JSON encoding (prevent UTF-8 encode crash)
|
|
131
|
+
from .utils import sanitize_surrogates
|
|
132
|
+
body = sanitize_surrogates(body)
|
|
133
|
+
|
|
134
|
+
logger.debug("Anthropic %s: %d msgs, %d tools", self._model,
|
|
135
|
+
len(anthropic_msgs), len(tool_defs) if tool_defs else 0)
|
|
136
|
+
|
|
137
|
+
return await self._request_with_retry(body)
|
|
138
|
+
|
|
139
|
+
async def _request_with_retry(self, body: dict[str, Any]) -> Message:
|
|
140
|
+
"""Send request with exponential backoff on rate limits / server errors."""
|
|
141
|
+
last_error: str | None = None
|
|
142
|
+
for attempt in range(self._max_retries + 1):
|
|
143
|
+
try:
|
|
144
|
+
resp = await self._client.post(self._api_url, json=body)
|
|
145
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as e:
|
|
146
|
+
last_error = str(e)
|
|
147
|
+
if attempt < self._max_retries:
|
|
148
|
+
delay = self._retry_base_delay * (2 ** attempt) * (0.5 + random.random())
|
|
149
|
+
logger.warning("Anthropic connect error, retrying in %.1fs: %s", delay, e)
|
|
150
|
+
await asyncio.sleep(delay)
|
|
151
|
+
continue
|
|
152
|
+
raise RuntimeError(f"Cannot reach Anthropic API: {e}") from e
|
|
153
|
+
|
|
154
|
+
if resp.status_code == 429:
|
|
155
|
+
last_error = "HTTP 429 (rate limited)"
|
|
156
|
+
if attempt < self._max_retries:
|
|
157
|
+
retry_after = resp.headers.get("retry-after", "")
|
|
158
|
+
try:
|
|
159
|
+
delay = float(retry_after) if retry_after else self._retry_base_delay * (2 ** attempt)
|
|
160
|
+
except ValueError:
|
|
161
|
+
delay = self._retry_base_delay * (2 ** attempt) * (0.5 + random.random())
|
|
162
|
+
delay = min(delay, 60.0)
|
|
163
|
+
logger.warning("Anthropic rate limited (429), retrying in %.1fs (attempt %d/%d)",
|
|
164
|
+
delay, attempt + 1, self._max_retries)
|
|
165
|
+
await asyncio.sleep(delay)
|
|
166
|
+
continue
|
|
167
|
+
raise RuntimeError(f"Rate limit exceeded after {self._max_retries} retries")
|
|
168
|
+
|
|
169
|
+
if resp.status_code >= 500:
|
|
170
|
+
last_error = f"HTTP {resp.status_code} (server error)"
|
|
171
|
+
if attempt < self._max_retries:
|
|
172
|
+
delay = self._retry_base_delay * (2 ** attempt) * (0.5 + random.random())
|
|
173
|
+
logger.warning("Anthropic server error (%d), retrying in %.1fs", resp.status_code, delay)
|
|
174
|
+
await asyncio.sleep(delay)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
except httpx.HTTPStatusError as e:
|
|
180
|
+
try:
|
|
181
|
+
err_data = resp.json()
|
|
182
|
+
err_msg = err_data.get("error", {}).get("message", str(e))
|
|
183
|
+
except Exception:
|
|
184
|
+
err_msg = str(e)
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
enhance_api_error(resp.status_code, f"Anthropic API error ({resp.status_code}): {err_msg}", self.config.base_url)
|
|
187
|
+
) from e
|
|
188
|
+
|
|
189
|
+
return self._convert_response(resp.json())
|
|
190
|
+
|
|
191
|
+
raise RuntimeError(f"Request failed after {self._max_retries} retries: {last_error}")
|
|
192
|
+
|
|
193
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
194
|
+
# Chat (streaming)
|
|
195
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
196
|
+
|
|
197
|
+
async def chat_stream(self, messages: list[Message], system_prompt: str = "",
|
|
198
|
+
tools: list[ToolDef] | None = None) -> AsyncIterator[tuple[str, Any]]:
|
|
199
|
+
tool_defs = tools if tools is not None else self._tools
|
|
200
|
+
anthropic_msgs, system = self._convert_messages(messages, system_prompt)
|
|
201
|
+
|
|
202
|
+
body: dict[str, Any] = {
|
|
203
|
+
"model": self._model,
|
|
204
|
+
"messages": anthropic_msgs,
|
|
205
|
+
"max_tokens": self.config.max_tokens,
|
|
206
|
+
"stream": True,
|
|
207
|
+
}
|
|
208
|
+
if system:
|
|
209
|
+
body["system"] = system
|
|
210
|
+
if tool_defs:
|
|
211
|
+
body["tools"] = tool_defs
|
|
212
|
+
|
|
213
|
+
self._apply_thinking(body)
|
|
214
|
+
|
|
215
|
+
# Sanitize surrogates before JSON encoding (prevent UTF-8 encode crash)
|
|
216
|
+
from .utils import sanitize_surrogates
|
|
217
|
+
body = sanitize_surrogates(body)
|
|
218
|
+
|
|
219
|
+
# Retry loop for streaming (up to 2 retries for 429/5xx)
|
|
220
|
+
last_error = None
|
|
221
|
+
for attempt in range(self._max_retries):
|
|
222
|
+
try:
|
|
223
|
+
resp = await self._client.send(
|
|
224
|
+
self._client.build_request("POST", self._api_url, json=body),
|
|
225
|
+
stream=True,
|
|
226
|
+
)
|
|
227
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as e:
|
|
228
|
+
if attempt < self._max_retries - 1:
|
|
229
|
+
delay = self._retry_base_delay * (2 ** attempt) * (0.5 + random.random())
|
|
230
|
+
logger.warning("Anthropic stream connect error, retrying in %.1fs: %s", delay, e)
|
|
231
|
+
await asyncio.sleep(delay)
|
|
232
|
+
continue
|
|
233
|
+
raise RuntimeError(f"Stream connection failed: {e}") from e
|
|
234
|
+
|
|
235
|
+
if resp.status_code >= 400:
|
|
236
|
+
if resp.status_code == 429 and attempt < self._max_retries - 1:
|
|
237
|
+
delay = self._retry_base_delay * (2 ** attempt) * (0.5 + random.random())
|
|
238
|
+
logger.warning("Anthropic stream rate limited, retrying in %.1fs", delay)
|
|
239
|
+
await asyncio.sleep(delay)
|
|
240
|
+
continue
|
|
241
|
+
if resp.status_code >= 500 and attempt < self._max_retries - 1:
|
|
242
|
+
delay = self._retry_base_delay * (2 ** attempt) * (0.5 + random.random())
|
|
243
|
+
logger.warning("Anthropic stream server error (%d), retrying in %.1fs", resp.status_code, delay)
|
|
244
|
+
await asyncio.sleep(delay)
|
|
245
|
+
continue
|
|
246
|
+
try:
|
|
247
|
+
error_body_raw = (await resp.aread()).decode("utf-8", errors="replace")[:500]
|
|
248
|
+
except Exception:
|
|
249
|
+
error_body_raw = "(could not read body)"
|
|
250
|
+
# Extract API error message from JSON body for richer diagnostics
|
|
251
|
+
try:
|
|
252
|
+
err_data = json.loads(error_body_raw)
|
|
253
|
+
err_msg = err_data.get("error", {}).get("message", error_body_raw)
|
|
254
|
+
except (json.JSONDecodeError, AttributeError):
|
|
255
|
+
err_msg = error_body_raw
|
|
256
|
+
logger.error("Anthropic stream request failed (%d): %s", resp.status_code, err_msg[:200])
|
|
257
|
+
raise RuntimeError(
|
|
258
|
+
enhance_api_error(
|
|
259
|
+
resp.status_code,
|
|
260
|
+
f"Anthropic API error ({resp.status_code}): {err_msg}",
|
|
261
|
+
self.config.base_url,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
tool_buf: dict[int, dict] = {}
|
|
266
|
+
async for line in resp.aiter_lines():
|
|
267
|
+
if not line or not line.startswith("data: "):
|
|
268
|
+
continue
|
|
269
|
+
data_str = line[6:]
|
|
270
|
+
if data_str.strip() == "[DONE]":
|
|
271
|
+
break
|
|
272
|
+
try:
|
|
273
|
+
event = json.loads(data_str)
|
|
274
|
+
except json.JSONDecodeError:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
evt_type = event.get("type", "")
|
|
278
|
+
delta = event.get("delta", {})
|
|
279
|
+
idx = event.get("index", 0)
|
|
280
|
+
|
|
281
|
+
if evt_type == "content_block_delta":
|
|
282
|
+
dt = delta.get("type", "")
|
|
283
|
+
if dt == "text_delta":
|
|
284
|
+
yield ("text", delta.get("text", ""))
|
|
285
|
+
elif dt == "thinking_delta":
|
|
286
|
+
yield ("reasoning", delta.get("thinking", ""))
|
|
287
|
+
elif dt == "input_json_delta":
|
|
288
|
+
if idx not in tool_buf:
|
|
289
|
+
tool_buf[idx] = {"id": "", "name": "", "arguments": ""}
|
|
290
|
+
tool_buf[idx]["arguments"] += delta.get("partial_json", "")
|
|
291
|
+
|
|
292
|
+
elif evt_type == "content_block_start":
|
|
293
|
+
block = event.get("content_block", {})
|
|
294
|
+
if block.get("type") == "tool_use":
|
|
295
|
+
tool_buf[idx] = {"id": block.get("id", ""), "name": block.get("name", ""), "arguments": ""}
|
|
296
|
+
|
|
297
|
+
elif evt_type == "message_stop":
|
|
298
|
+
yield ("finish", "end_turn")
|
|
299
|
+
|
|
300
|
+
# Yield tool calls
|
|
301
|
+
for idx in sorted(tool_buf.keys()):
|
|
302
|
+
buf = tool_buf[idx]
|
|
303
|
+
args = buf["arguments"]
|
|
304
|
+
if args:
|
|
305
|
+
try:
|
|
306
|
+
json.loads(args)
|
|
307
|
+
except json.JSONDecodeError:
|
|
308
|
+
args = self._balance_json(args)
|
|
309
|
+
yield ("tool_call", {
|
|
310
|
+
"id": buf["id"], "type": "function",
|
|
311
|
+
"function": {"name": buf["name"], "arguments": args},
|
|
312
|
+
})
|
|
313
|
+
break # success — exit retry loop
|
|
314
|
+
|
|
315
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
316
|
+
# Internal helpers
|
|
317
|
+
# ═════════════════════════════════════════════════════════════════════
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _balance_json(text: str) -> str:
|
|
321
|
+
"""Complete a truncated JSON string by appending missing closing brackets.
|
|
322
|
+
|
|
323
|
+
Handles nested objects, arrays, and string literals — not just a single
|
|
324
|
+
trailing ``}`` like the old ``args += "}"`` hack that failed on nested
|
|
325
|
+
structures and partial arrays.
|
|
326
|
+
"""
|
|
327
|
+
pairs = {'{': '}', '[': ']'}
|
|
328
|
+
stack: list[str] = []
|
|
329
|
+
in_string = False
|
|
330
|
+
escape = False
|
|
331
|
+
for ch in text:
|
|
332
|
+
if escape:
|
|
333
|
+
escape = False
|
|
334
|
+
continue
|
|
335
|
+
if ch == '\\':
|
|
336
|
+
escape = True
|
|
337
|
+
continue
|
|
338
|
+
if ch == '"':
|
|
339
|
+
in_string = not in_string
|
|
340
|
+
continue
|
|
341
|
+
if in_string:
|
|
342
|
+
continue
|
|
343
|
+
if ch in pairs:
|
|
344
|
+
stack.append(pairs[ch])
|
|
345
|
+
elif ch in (']', '}'):
|
|
346
|
+
if stack and stack[-1] == ch:
|
|
347
|
+
stack.pop()
|
|
348
|
+
return text + ''.join(reversed(stack))
|
|
349
|
+
|
|
350
|
+
def _apply_thinking(self, body: dict) -> None:
|
|
351
|
+
"""Apply thinking/reasoning_effort — provider-agnostic.
|
|
352
|
+
|
|
353
|
+
NOTE: DeepSeek supports low/medium/high/max. The Anthropic format
|
|
354
|
+
only recognises low/high. Values are passed through as-is; it is
|
|
355
|
+
the caller's responsibility to choose a supported strength.
|
|
356
|
+
"""
|
|
357
|
+
strength = getattr(self.config, 'thinking_strength', '') or ''
|
|
358
|
+
if not strength or strength.lower() == 'off':
|
|
359
|
+
return
|
|
360
|
+
# Anthropic format: thinking type + output_config
|
|
361
|
+
body["thinking"] = {"type": "enabled"}
|
|
362
|
+
body["output_config"] = {"effort": strength.lower()}
|
|
363
|
+
|
|
364
|
+
def _convert_messages(self, openai_msgs: list[Message], system_prompt: str = "") -> tuple[list[dict], str]:
|
|
365
|
+
"""OpenAI-format messages → Anthropic-format."""
|
|
366
|
+
result = []
|
|
367
|
+
system = system_prompt
|
|
368
|
+
|
|
369
|
+
for msg in openai_msgs:
|
|
370
|
+
role = msg.get("role", "")
|
|
371
|
+
content = msg.get("content", "")
|
|
372
|
+
|
|
373
|
+
if role == "system":
|
|
374
|
+
system = (system + "\n\n" + content).strip() if content else system
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
if role == "user":
|
|
378
|
+
result.append({"role": "user", "content": content or ""})
|
|
379
|
+
|
|
380
|
+
elif role == "assistant":
|
|
381
|
+
blocks = []
|
|
382
|
+
if msg.get("reasoning_content"):
|
|
383
|
+
blocks.append({"type": "thinking", "thinking": msg["reasoning_content"]})
|
|
384
|
+
if content:
|
|
385
|
+
blocks.append({"type": "text", "text": content})
|
|
386
|
+
for tc in msg.get("tool_calls", []):
|
|
387
|
+
fn = tc.get("function", {})
|
|
388
|
+
inp = fn.get("arguments", "{}")
|
|
389
|
+
if isinstance(inp, str):
|
|
390
|
+
try:
|
|
391
|
+
inp = json.loads(inp)
|
|
392
|
+
except json.JSONDecodeError:
|
|
393
|
+
inp = {}
|
|
394
|
+
blocks.append({"type": "tool_use", "id": tc.get("id", ""),
|
|
395
|
+
"name": fn.get("name", ""), "input": inp})
|
|
396
|
+
result.append({"role": "assistant", "content": blocks if blocks else content or ""})
|
|
397
|
+
|
|
398
|
+
elif role == "tool":
|
|
399
|
+
result.append({"role": "user", "content": [{
|
|
400
|
+
"type": "tool_result",
|
|
401
|
+
"tool_use_id": msg.get("tool_call_id", ""),
|
|
402
|
+
"content": content or "",
|
|
403
|
+
}]})
|
|
404
|
+
|
|
405
|
+
return result, system
|
|
406
|
+
|
|
407
|
+
def _convert_response(self, data: dict) -> Message:
|
|
408
|
+
"""Anthropic response → OpenAI-format message."""
|
|
409
|
+
result: Message = {"role": "assistant", "content": ""}
|
|
410
|
+
texts, tools, reasonings = [], [], []
|
|
411
|
+
|
|
412
|
+
for block in data.get("content", []):
|
|
413
|
+
t = block.get("type", "")
|
|
414
|
+
if t == "text":
|
|
415
|
+
texts.append(block.get("text", ""))
|
|
416
|
+
elif t == "tool_use":
|
|
417
|
+
tools.append({"id": block.get("id", ""), "type": "function",
|
|
418
|
+
"function": {"name": block.get("name", ""),
|
|
419
|
+
"arguments": json.dumps(block.get("input", {}))}})
|
|
420
|
+
elif t == "thinking":
|
|
421
|
+
reasonings.append(block.get("thinking", ""))
|
|
422
|
+
|
|
423
|
+
result["content"] = "\n".join(texts)
|
|
424
|
+
if tools:
|
|
425
|
+
result["tool_calls"] = tools
|
|
426
|
+
if reasonings:
|
|
427
|
+
result["reasoning_content"] = "\n".join(reasonings)
|
|
428
|
+
|
|
429
|
+
usage = data.get("usage", {})
|
|
430
|
+
inp = usage.get("input_tokens", 0)
|
|
431
|
+
out = usage.get("output_tokens", 0)
|
|
432
|
+
if not inp:
|
|
433
|
+
out_text = "\n".join(texts)
|
|
434
|
+
inp = max(1, len(json.dumps(data)) // 3)
|
|
435
|
+
out = max(1, len(out_text) // 3)
|
|
436
|
+
self._total_prompt_tokens += inp
|
|
437
|
+
self._total_completion_tokens += out or 1
|
|
438
|
+
if self._usage_callback:
|
|
439
|
+
self._usage_callback(inp, out or 1)
|
|
440
|
+
|
|
441
|
+
return result
|
|
442
|
+
|
|
443
|
+
def count_tokens_approx(self, messages: list[Message]) -> int:
|
|
444
|
+
"""CJK-aware token count estimation (mirrors LLMClient fallback)."""
|
|
445
|
+
import re
|
|
446
|
+
total = 0
|
|
447
|
+
for msg in messages:
|
|
448
|
+
content = msg.get("content", "") or ""
|
|
449
|
+
if isinstance(content, list):
|
|
450
|
+
# Anthropic content blocks
|
|
451
|
+
content = json.dumps(content)
|
|
452
|
+
cjk = len(re.findall(r'[一-鿿 -〿-]', content))
|
|
453
|
+
other = len(content) - cjk
|
|
454
|
+
total += (cjk * 2 // 3) + (other // 4)
|
|
455
|
+
for tc in msg.get("tool_calls", []):
|
|
456
|
+
total += len(json.dumps(tc)) // 4
|
|
457
|
+
return max(1, total)
|
|
458
|
+
|
|
459
|
+
def set_model(self, model: str) -> None:
|
|
460
|
+
"""Change the model at runtime without recreating the client."""
|
|
461
|
+
self.config.model = model
|
|
462
|
+
self._model = model
|
|
463
|
+
|
|
464
|
+
async def close(self):
|
|
465
|
+
await self._client.aclose()
|