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.
Files changed (118) hide show
  1. ata_coder/__init__.py +1 -0
  2. ata_coder/agent.py +874 -0
  3. ata_coder/agent_compact.py +190 -0
  4. ata_coder/agent_controller.py +218 -0
  5. ata_coder/agent_extension.py +69 -0
  6. ata_coder/agent_routing.py +105 -0
  7. ata_coder/agent_subsystems.py +72 -0
  8. ata_coder/agent_tools.py +318 -0
  9. ata_coder/agent_undo.py +63 -0
  10. ata_coder/anthropic_client.py +465 -0
  11. ata_coder/change_tracker.py +368 -0
  12. ata_coder/clawd_integration.py +574 -0
  13. ata_coder/commands/__init__.py +128 -0
  14. ata_coder/commands/_core.py +184 -0
  15. ata_coder/commands/_safety.py +95 -0
  16. ata_coder/commands/_settings.py +241 -0
  17. ata_coder/commands/_workflow.py +451 -0
  18. ata_coder/commands.py +974 -0
  19. ata_coder/config.py +257 -0
  20. ata_coder/core/__init__.py +35 -0
  21. ata_coder/core/events.py +73 -0
  22. ata_coder/core/queue.py +85 -0
  23. ata_coder/core/state.py +17 -0
  24. ata_coder/event_queue.py +5 -0
  25. ata_coder/extension.py +654 -0
  26. ata_coder/extensions/__init__.py +1 -0
  27. ata_coder/extensions/hello_skill.py +47 -0
  28. ata_coder/fool_proof.py +295 -0
  29. ata_coder/git_workflow.py +371 -0
  30. ata_coder/gui.py +511 -0
  31. ata_coder/llm_client.py +543 -0
  32. ata_coder/main.py +814 -0
  33. ata_coder/mcp_client.py +1095 -0
  34. ata_coder/memory.py +539 -0
  35. ata_coder/model_registry.py +134 -0
  36. ata_coder/model_router.py +105 -0
  37. ata_coder/permissions.py +274 -0
  38. ata_coder/privilege.py +464 -0
  39. ata_coder/project.py +273 -0
  40. ata_coder/prompt_template.py +423 -0
  41. ata_coder/prompts/auto-mode.md +7 -0
  42. ata_coder/prompts/coding-rules.md +40 -0
  43. ata_coder/prompts/execution-guardrails.md +14 -0
  44. ata_coder/prompts/memory-system.md +24 -0
  45. ata_coder/prompts/output-style.md +23 -0
  46. ata_coder/prompts/safety.md +17 -0
  47. ata_coder/prompts/slash-commands.md +24 -0
  48. ata_coder/prompts/sub-agents.md +38 -0
  49. ata_coder/prompts/system-reminders.md +17 -0
  50. ata_coder/prompts/system.md +105 -0
  51. ata_coder/prompts/tool-policy.md +46 -0
  52. ata_coder/repl_theme.py +99 -0
  53. ata_coder/repl_tracker.py +89 -0
  54. ata_coder/repl_ui.py +1214 -0
  55. ata_coder/safety_guard.py +434 -0
  56. ata_coder/self_correct.py +346 -0
  57. ata_coder/server.py +882 -0
  58. ata_coder/server_session.py +159 -0
  59. ata_coder/server_shell.py +129 -0
  60. ata_coder/session.py +431 -0
  61. ata_coder/settings.py +439 -0
  62. ata_coder/setup_wizard.py +136 -0
  63. ata_coder/skill_extension.py +92 -0
  64. ata_coder/skills/architect/SKILL.md +42 -0
  65. ata_coder/skills/code-reviewer/SKILL.md +37 -0
  66. ata_coder/skills/codecraft/SKILL.md +452 -0
  67. ata_coder/skills/debugger/SKILL.md +45 -0
  68. ata_coder/skills/doc-writer/SKILL.md +36 -0
  69. ata_coder/skills/general-coder/SKILL.md +76 -0
  70. ata_coder/skills/math-calculator/README.md +40 -0
  71. ata_coder/skills/math-calculator/SKILL.md +59 -0
  72. ata_coder/skills/math-calculator/handler.py +103 -0
  73. ata_coder/skills/math-calculator/prompts/system.md +8 -0
  74. ata_coder/skills/math-calculator/requirements.txt +2 -0
  75. ata_coder/skills/math-calculator/resources/constants.json +8 -0
  76. ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
  77. ata_coder/skills/security-auditor/SKILL.md +40 -0
  78. ata_coder/skills/test-writer/SKILL.md +36 -0
  79. ata_coder/skills/weather-skill/README.md +45 -0
  80. ata_coder/skills/weather-skill/handler.py +76 -0
  81. ata_coder/skills/weather-skill/manifest.json +48 -0
  82. ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
  83. ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
  84. ata_coder/skills/weather-skill/requirements.txt +1 -0
  85. ata_coder/skills/weather-skill/resources/city_list.json +17 -0
  86. ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
  87. ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
  88. ata_coder/skills/weather-skill/weather_utils.py +50 -0
  89. ata_coder/skills.py +1014 -0
  90. ata_coder/sub_agent.py +273 -0
  91. ata_coder/sub_agent_manager.py +203 -0
  92. ata_coder/system_prompt_builder.py +146 -0
  93. ata_coder/task_planner.py +391 -0
  94. ata_coder/terminal.py +318 -0
  95. ata_coder/test_runner.py +219 -0
  96. ata_coder/thread_supervisor.py +195 -0
  97. ata_coder/tool_defs.py +335 -0
  98. ata_coder/tools/__init__.py +11 -0
  99. ata_coder/tools/definitions.py +335 -0
  100. ata_coder/tools/executor.py +1036 -0
  101. ata_coder/tools/result.py +26 -0
  102. ata_coder/tools/subagent.py +332 -0
  103. ata_coder/tools/web.py +361 -0
  104. ata_coder/tools.py +1576 -0
  105. ata_coder/types.py +92 -0
  106. ata_coder/utils.py +113 -0
  107. ata_coder/web/css/style.css +180 -0
  108. ata_coder/web/index.html +84 -0
  109. ata_coder/web/js/app.js +489 -0
  110. ata_coder/web/package-lock.json +25 -0
  111. ata_coder/web/package.json +10 -0
  112. ata_coder/web/tsconfig.json +13 -0
  113. ata_coder-2.4.2.dist-info/METADATA +799 -0
  114. ata_coder-2.4.2.dist-info/RECORD +118 -0
  115. ata_coder-2.4.2.dist-info/WHEEL +5 -0
  116. ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
  117. ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
  118. 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()