alysis-code 0.13.0__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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,2288 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import re
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from ..error_text import sanitize_error_text_for_output
13
+ from ..provider_telemetry import ProviderCallTelemetryRecorder
14
+ from ..request_estimation import estimate_provider_payload_tokens
15
+ from ..web_search_adapters import ANTHROPIC_MESSAGES_ADAPTER, AUTO_WEB_SEARCH_ADAPTER
16
+ from .cache_capabilities import CACHE_CONTROL_FIELD
17
+ from .cache_control_blocks import (
18
+ count_cache_control_blocks,
19
+ count_explicit_cache_control_blocks,
20
+ explicit_cache_control_payloads,
21
+ strip_cache_control_blocks,
22
+ )
23
+ from .cache_policy import merge_cache_policy_metadata
24
+ from .metadata import (
25
+ ANTHROPIC_MESSAGES_PROVIDER_METADATA_KEY,
26
+ PROVIDER_METADATA_KEY,
27
+ ProviderRouteIdentity,
28
+ build_provider_route_identity,
29
+ canonicalize_extra_headers,
30
+ credential_scope_fingerprint,
31
+ gate_messages_for_provider_route,
32
+ merge_canonical_headers,
33
+ stamp_response_for_route,
34
+ )
35
+ from .provider_limits import (
36
+ DEFAULT_PROVIDER_CONCURRENCY_CAPS,
37
+ ProviderRetrySettings,
38
+ best_effort_provider_key,
39
+ mark_provider_call_non_retryable,
40
+ run_provider_limited_call,
41
+ )
42
+ from .request_plan import LLMRequestPlan, RequestCachePlan
43
+ from .request_shape import build_request_shape_report
44
+ from .streaming import SSEFrame, iter_sse_frames, parse_sse_json_frame
45
+ from .temperature_compat import documented_temperature_omit_reason
46
+ from .types import (
47
+ InputTokenCount,
48
+ LLMError,
49
+ LLMResponse,
50
+ LLMUsage,
51
+ ReasoningOutput,
52
+ ReasoningOutputKind,
53
+ ToolCall,
54
+ UsageConfidence,
55
+ UsageContract,
56
+ )
57
+
58
+ _DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
59
+ ANTHROPIC_MESSAGES_ROUTE_REVISION = _DEFAULT_ANTHROPIC_VERSION
60
+ _DEFAULT_ACCEPT_ENCODING = "identity"
61
+ _ANTHROPIC_METADATA_KEY = ANTHROPIC_MESSAGES_PROVIDER_METADATA_KEY
62
+ _ALYSIS_WEB_SEARCH_FUNCTION_NAME = "web_search"
63
+ _ANTHROPIC_WEB_SEARCH_TOOL_TYPE = "web_search_20260209"
64
+ _ANTHROPIC_WEB_SEARCH_TOOL_TYPES = frozenset(
65
+ {
66
+ "web_search_20250305",
67
+ "web_search_20260209",
68
+ }
69
+ )
70
+ _WEB_SEARCH_MODES_ALLOWING_ANTHROPIC_BUILTIN = frozenset({"auto", "native"})
71
+ _ANTHROPIC_CACHE_CONTROL_TTLS = frozenset({"5m", "1h"})
72
+ _ANTHROPIC_MAX_CACHE_CONTROL_BREAKPOINTS = 4
73
+ _ANTHROPIC_MIN_MANUAL_THINKING_BUDGET = 1024
74
+ _ANTHROPIC_MANUAL_THINKING_BUDGETS = {
75
+ "minimal": 1024,
76
+ "low": 1024,
77
+ "medium": 4096,
78
+ "high": 8192,
79
+ "xhigh": 16384,
80
+ "max": 32768,
81
+ }
82
+ _ANTHROPIC_API_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
83
+ _CLAUDE_MODEL_VERSION_RE = re.compile(
84
+ r"(?:^|[/.:_-])claude[-_.](?P<family>opus|sonnet|haiku|fable|mythos)"
85
+ r"[-_.](?P<major>\d+)(?:[-_.](?P<minor>\d+))?"
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class _ClaudeModelVersion:
91
+ family: str
92
+ major: int
93
+ minor: int | None
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class _AnthropicThinkingPlan:
98
+ config: dict[str, Any] | None
99
+ output_effort: str | None
100
+ active: bool
101
+
102
+
103
+ def _claude_model_version(model: str) -> _ClaudeModelVersion | None:
104
+ normalized = str(model or "").strip().casefold()
105
+ match = _CLAUDE_MODEL_VERSION_RE.search(normalized)
106
+ if match is None:
107
+ return None
108
+ minor_text = match.group("minor")
109
+ minor = int(minor_text) if minor_text is not None else None
110
+ # Dated snapshots such as claude-opus-4-20250514 are Claude 4.0, not
111
+ # version 4.20-million. Keep the same distinction as temperature_compat.
112
+ if minor is not None and minor >= 100:
113
+ minor = None
114
+ return _ClaudeModelVersion(
115
+ family=match.group("family"),
116
+ major=int(match.group("major")),
117
+ minor=minor,
118
+ )
119
+
120
+
121
+ def _uses_adaptive_thinking(model: str) -> bool:
122
+ normalized = str(model or "").strip().casefold()
123
+ if "claude-mythos-preview" in normalized:
124
+ return True
125
+ version = _claude_model_version(model)
126
+ if version is None:
127
+ return False
128
+ if version.family in {"fable", "mythos"}:
129
+ return version.major >= 5
130
+ if version.family not in {"opus", "sonnet"}:
131
+ return False
132
+ return version.major >= 5 or (
133
+ version.major == 4 and version.minor is not None and version.minor >= 6
134
+ )
135
+
136
+
137
+ def _supports_disabled_thinking(model: str) -> bool:
138
+ normalized = str(model or "").strip().casefold()
139
+ if "claude-mythos-preview" in normalized:
140
+ return False
141
+ version = _claude_model_version(model)
142
+ if version is None:
143
+ return True
144
+ return not (version.family in {"fable", "mythos"} and version.major >= 5)
145
+
146
+
147
+ def _thinking_enabled_by_default(model: str) -> bool:
148
+ """Models whose documented default already includes adaptive thinking.
149
+
150
+ Opus joined the 5-generation families here: on Opus 5 an omitted ``thinking``
151
+ field runs adaptive, where on Opus 4.8/4.7 the same request did not think at
152
+ all. Reporting that honestly matters for output budgeting — ``max_tokens``
153
+ caps thinking and answer text together.
154
+ """
155
+
156
+ normalized = str(model or "").strip().casefold()
157
+ if "claude-mythos-preview" in normalized:
158
+ return True
159
+ version = _claude_model_version(model)
160
+ if version is None or version.major < 5:
161
+ return False
162
+ return version.family in {"fable", "mythos", "opus", "sonnet"}
163
+
164
+
165
+ def _supports_output_effort(model: str) -> bool:
166
+ normalized = str(model or "").strip().casefold()
167
+ if "claude-mythos-preview" in normalized:
168
+ return True
169
+ version = _claude_model_version(model)
170
+ if version is None:
171
+ return False
172
+ if version.family in {"fable", "mythos"}:
173
+ return version.major >= 5
174
+ if version.family == "opus":
175
+ return version.major >= 5 or (
176
+ version.major == 4 and version.minor is not None and version.minor >= 5
177
+ )
178
+ if version.family == "sonnet":
179
+ return version.major >= 5 or (
180
+ version.major == 4 and version.minor is not None and version.minor >= 6
181
+ )
182
+ return False
183
+
184
+
185
+ def _supports_xhigh_effort(model: str) -> bool:
186
+ version = _claude_model_version(model)
187
+ if version is None:
188
+ return False
189
+ if version.family in {"fable", "mythos"}:
190
+ return version.major >= 5
191
+ if version.family == "opus":
192
+ return version.major >= 5 or (
193
+ version.major == 4 and version.minor is not None and version.minor >= 7
194
+ )
195
+ return version.family == "sonnet" and version.major >= 5
196
+
197
+
198
+ def _supports_max_effort(model: str) -> bool:
199
+ normalized = str(model or "").strip().casefold()
200
+ return "claude-mythos-preview" in normalized or _uses_adaptive_thinking(model)
201
+
202
+
203
+ def _manual_thinking_budget(*, effort: str | None, max_tokens: int) -> int:
204
+ if max_tokens <= _ANTHROPIC_MIN_MANUAL_THINKING_BUDGET:
205
+ raise LLMError("Anthropic manual thinking requires max_tokens greater than 1024")
206
+ requested = _ANTHROPIC_MANUAL_THINKING_BUDGETS.get(effort or "high", 8192)
207
+ # Preserve useful answer headroom where possible while satisfying the API's
208
+ # strict budget_tokens < max_tokens constraint for smaller output limits.
209
+ reserve = 1024 if max_tokens >= 2048 else 1
210
+ return max(
211
+ _ANTHROPIC_MIN_MANUAL_THINKING_BUDGET,
212
+ min(requested, max_tokens - reserve),
213
+ )
214
+
215
+
216
+ def _anthropic_thinking_plan(
217
+ *,
218
+ model: str,
219
+ enable_thinking: bool | None,
220
+ reasoning_effort: str | None,
221
+ max_tokens: int,
222
+ request_summary: bool,
223
+ ) -> _AnthropicThinkingPlan:
224
+ effort = str(reasoning_effort or "").strip().casefold() or None
225
+ if effort == "ultra":
226
+ raise LLMError(
227
+ "Anthropic Messages does not support reasoning_effort='ultra'; use xhigh or max"
228
+ )
229
+ if effort not in {None, "none", *_ANTHROPIC_MANUAL_THINKING_BUDGETS}:
230
+ raise LLMError(f"Anthropic Messages reasoning_effort is not supported: {effort}")
231
+
232
+ if enable_thinking is False or effort == "none":
233
+ if not _supports_disabled_thinking(model):
234
+ raise LLMError(f"Anthropic model {model!r} does not support disabling thinking")
235
+ # output_effort stays None on purpose: Opus 5 accepts disabled thinking
236
+ # only at effort 'high' or below, and rejects it outright at xhigh/max.
237
+ # Emitting no effort leaves the server default (high), which is the
238
+ # documented way to keep a thinking-off route legal. Do not pass the
239
+ # caller's effort through here.
240
+ return _AnthropicThinkingPlan(
241
+ config={"type": "disabled"},
242
+ output_effort=None,
243
+ active=False,
244
+ )
245
+
246
+ explicitly_active = enable_thinking is True or effort is not None
247
+ if not explicitly_active:
248
+ # Auto/default is intentionally provider-owned. Trace visibility must
249
+ # never switch model reasoning on by itself. For models that already
250
+ # think by default, requesting ``display=summarized`` changes visibility
251
+ # only; the provider still owns effort and whether a simple turn thinks.
252
+ default_active = _thinking_enabled_by_default(model)
253
+ return _AnthropicThinkingPlan(
254
+ config=(
255
+ {"type": "adaptive", "display": "summarized"}
256
+ if default_active and request_summary
257
+ else None
258
+ ),
259
+ output_effort=None,
260
+ active=default_active,
261
+ )
262
+
263
+ version = _claude_model_version(model)
264
+ if version is None and "claude-mythos-preview" not in str(model).casefold():
265
+ raise LLMError(
266
+ f"Anthropic Messages cannot safely select a thinking mode for model {model!r}"
267
+ )
268
+ if version is not None and version.major < 4:
269
+ raise LLMError(f"Anthropic model {model!r} does not support extended thinking")
270
+
271
+ display = "summarized" if request_summary else "omitted"
272
+ if _uses_adaptive_thinking(model):
273
+ config: dict[str, Any] = {"type": "adaptive", "display": display}
274
+ else:
275
+ config = {
276
+ "type": "enabled",
277
+ "budget_tokens": _manual_thinking_budget(
278
+ effort=effort,
279
+ max_tokens=max_tokens,
280
+ ),
281
+ "display": display,
282
+ }
283
+
284
+ output_effort: str | None = None
285
+ if effort is not None and _supports_output_effort(model):
286
+ if effort not in _ANTHROPIC_API_EFFORTS:
287
+ raise LLMError(f"Anthropic Messages reasoning_effort is not supported: {effort}")
288
+ if effort == "xhigh" and not _supports_xhigh_effort(model):
289
+ raise LLMError(f"Anthropic model {model!r} does not support xhigh effort")
290
+ if effort == "max" and not _supports_max_effort(model):
291
+ raise LLMError(f"Anthropic model {model!r} does not support max effort")
292
+ output_effort = effort
293
+ return _AnthropicThinkingPlan(
294
+ config=config,
295
+ output_effort=output_effort,
296
+ active=True,
297
+ )
298
+
299
+
300
+ def _headers_with_default_accept_encoding(headers: dict[str, str]) -> dict[str, str]:
301
+ request_headers = dict(headers)
302
+ if not any(key.lower() == "accept-encoding" for key in request_headers):
303
+ request_headers["accept-encoding"] = _DEFAULT_ACCEPT_ENCODING
304
+ return request_headers
305
+
306
+
307
+ def _non_negative_int(value: Any) -> int | None:
308
+ try:
309
+ parsed = int(value)
310
+ except (TypeError, ValueError):
311
+ return None
312
+ return parsed if parsed >= 0 else None
313
+
314
+
315
+ def _normalize_prompt_cache_control_ttl(value: str | None) -> str:
316
+ normalized = str(value or "").strip().lower() or "5m"
317
+ return normalized if normalized in _ANTHROPIC_CACHE_CONTROL_TTLS else "5m"
318
+
319
+
320
+ def _response_with_cache_policy_metadata(
321
+ response: LLMResponse,
322
+ cache_policy: dict[str, Any] | None,
323
+ request_plan_metadata: dict[str, Any] | None = None,
324
+ ) -> LLMResponse:
325
+ if not cache_policy and not request_plan_metadata:
326
+ return response
327
+ provider_metadata = copy.deepcopy(response.provider_metadata) or {}
328
+ anthropic_metadata = provider_metadata.setdefault(_ANTHROPIC_METADATA_KEY, {})
329
+ if isinstance(anthropic_metadata, dict):
330
+ if cache_policy:
331
+ anthropic_metadata["cache_policy"] = copy.deepcopy(cache_policy)
332
+ if request_plan_metadata:
333
+ anthropic_metadata["request_plan"] = copy.deepcopy(request_plan_metadata)
334
+ return LLMResponse(
335
+ content=response.content,
336
+ tool_calls=response.tool_calls,
337
+ raw=response.raw,
338
+ response_model=response.response_model,
339
+ usage=response.usage,
340
+ provider_metadata=provider_metadata,
341
+ reasoning=response.reasoning,
342
+ )
343
+
344
+
345
+ def _content_to_text(raw: Any) -> str:
346
+ if raw is None:
347
+ return ""
348
+ if isinstance(raw, str):
349
+ return raw
350
+ if isinstance(raw, list):
351
+ parts: list[str] = []
352
+ for item in raw:
353
+ if isinstance(item, str):
354
+ parts.append(item)
355
+ elif isinstance(item, dict):
356
+ text = item.get("text") or item.get("content")
357
+ if isinstance(text, str):
358
+ parts.append(text)
359
+ return "".join(parts)
360
+ if isinstance(raw, dict):
361
+ text = raw.get("text") or raw.get("content")
362
+ return text if isinstance(text, str) else json.dumps(raw, ensure_ascii=False)
363
+ return str(raw)
364
+
365
+
366
+ def _json_arguments(args: Any) -> dict[str, Any]:
367
+ if isinstance(args, dict):
368
+ return dict(args)
369
+ if args is None:
370
+ return {}
371
+ if isinstance(args, str):
372
+ try:
373
+ parsed = json.loads(args)
374
+ except json.JSONDecodeError:
375
+ return {"_raw_arguments": args}
376
+ if isinstance(parsed, dict):
377
+ return parsed
378
+ return {"_raw_arguments": args}
379
+ return {"_raw_arguments": json.dumps(args, ensure_ascii=False)}
380
+
381
+
382
+ def _anthropic_blocks_from_content(raw: Any, *, role: str) -> list[dict[str, Any]]:
383
+ if raw is None:
384
+ return []
385
+ if isinstance(raw, str):
386
+ return [{"type": "text", "text": raw}] if raw else []
387
+ if not isinstance(raw, list):
388
+ text = _content_to_text(raw)
389
+ return [{"type": "text", "text": text}] if text else []
390
+
391
+ blocks: list[dict[str, Any]] = []
392
+ for item in raw:
393
+ if isinstance(item, str):
394
+ if item:
395
+ blocks.append({"type": "text", "text": item})
396
+ continue
397
+ if not isinstance(item, dict):
398
+ continue
399
+ block_type = str(item.get("type") or "").strip()
400
+ text = item.get("text") or item.get("content")
401
+ if block_type in {"text", "input_text", "output_text"} and isinstance(text, str):
402
+ block = {"type": "text", "text": text}
403
+ cache_control = item.get(CACHE_CONTROL_FIELD)
404
+ if isinstance(cache_control, dict):
405
+ block[CACHE_CONTROL_FIELD] = copy.deepcopy(cache_control)
406
+ blocks.append(block)
407
+ continue
408
+ if role == "user" and block_type == "image_url":
409
+ image_url = item.get("image_url")
410
+ url = ""
411
+ if isinstance(image_url, dict):
412
+ url = str(image_url.get("url") or "").strip()
413
+ elif isinstance(image_url, str):
414
+ url = image_url.strip()
415
+ if url:
416
+ blocks.append({"type": "image", "source": {"type": "url", "url": url}})
417
+ continue
418
+ if block_type in {
419
+ "text",
420
+ "image",
421
+ "tool_result",
422
+ "tool_use",
423
+ "server_tool_use",
424
+ "web_search_tool_result",
425
+ }:
426
+ blocks.append(copy.deepcopy(item))
427
+ return blocks
428
+
429
+
430
+ def _function_name_from_tool(tool: dict[str, Any]) -> str:
431
+ function = tool.get("function")
432
+ if isinstance(function, dict):
433
+ return str(function.get("name") or "").strip()
434
+ if str(tool.get("type") or "") == "function":
435
+ return str(tool.get("name") or "").strip()
436
+ return ""
437
+
438
+
439
+ def _is_alysis_web_search_function(tool: dict[str, Any]) -> bool:
440
+ return _function_name_from_tool(tool) == _ALYSIS_WEB_SEARCH_FUNCTION_NAME
441
+
442
+
443
+ def _is_anthropic_hosted_web_search_tool(tool: dict[str, Any]) -> bool:
444
+ return str(tool.get("type") or "").strip() in _ANTHROPIC_WEB_SEARCH_TOOL_TYPES
445
+
446
+
447
+ def _anthropic_builtin_web_search_allowed(*, mode: str, adapter: str) -> bool:
448
+ normalized_mode = str(mode or "").strip().lower()
449
+ normalized_adapter = str(adapter or "").strip().lower() or AUTO_WEB_SEARCH_ADAPTER
450
+ if normalized_mode not in _WEB_SEARCH_MODES_ALLOWING_ANTHROPIC_BUILTIN:
451
+ return False
452
+ return normalized_adapter in {AUTO_WEB_SEARCH_ADAPTER, ANTHROPIC_MESSAGES_ADAPTER}
453
+
454
+
455
+ @dataclass(frozen=True)
456
+ class _AnthropicToolMapping:
457
+ tools: list[dict[str, Any]]
458
+ added_builtin_web_search: bool
459
+ removed_alysis_web_search: bool
460
+
461
+
462
+ def _anthropic_tool_from_chat_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
463
+ if not isinstance(tool, dict):
464
+ return None
465
+ tool_type = str(tool.get("type") or "").strip()
466
+ if tool_type == "function":
467
+ function = tool.get("function")
468
+ source = function if isinstance(function, dict) else tool
469
+ name = str(source.get("name") or "").strip()
470
+ if not name:
471
+ return None
472
+ mapped: dict[str, Any] = {
473
+ "name": name,
474
+ "input_schema": copy.deepcopy(source.get("parameters") or {"type": "object"}),
475
+ }
476
+ description = str(source.get("description") or "").strip()
477
+ if description:
478
+ mapped["description"] = description
479
+ return mapped
480
+ if _is_anthropic_hosted_web_search_tool(tool):
481
+ return copy.deepcopy(tool)
482
+ raise LLMError(f"Anthropic Messages does not support tool type {tool_type!r}")
483
+
484
+
485
+ def _anthropic_tools(
486
+ tools: list[dict[str, Any]] | None,
487
+ *,
488
+ mode: str,
489
+ adapter: str,
490
+ ) -> _AnthropicToolMapping:
491
+ normalized_mode = str(mode or "off").strip().lower()
492
+ normalized_adapter = (
493
+ str(adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower() or AUTO_WEB_SEARCH_ADAPTER
494
+ )
495
+ raw_tools = [tool for tool in tools or [] if isinstance(tool, dict)]
496
+ alysis_web_search_present = any(_is_alysis_web_search_function(tool) for tool in raw_tools)
497
+ use_builtin_web_search = alysis_web_search_present and _anthropic_builtin_web_search_allowed(
498
+ mode=normalized_mode,
499
+ adapter=normalized_adapter,
500
+ )
501
+ if normalized_mode == "native" and alysis_web_search_present and not use_builtin_web_search:
502
+ raise LLMError(
503
+ "web_search_mode=native with protocol=anthropic_messages requires "
504
+ "web_search_adapter='auto' or 'anthropic_messages' for Anthropic hosted web_search; "
505
+ f"got {normalized_adapter!r}"
506
+ )
507
+
508
+ mapped_tools: list[dict[str, Any]] = []
509
+ removed_alysis_web_search = False
510
+ for tool in raw_tools:
511
+ if _is_alysis_web_search_function(tool):
512
+ if normalized_mode in {"off", "native"} or use_builtin_web_search:
513
+ removed_alysis_web_search = True
514
+ continue
515
+ if _is_anthropic_hosted_web_search_tool(tool) and normalized_mode in {"off", "external"}:
516
+ continue
517
+ mapped = _anthropic_tool_from_chat_tool(tool)
518
+ if mapped is not None:
519
+ mapped_tools.append(mapped)
520
+
521
+ if use_builtin_web_search and not any(
522
+ _is_anthropic_hosted_web_search_tool(tool) for tool in mapped_tools
523
+ ):
524
+ mapped_tools.append(
525
+ {
526
+ "type": _ANTHROPIC_WEB_SEARCH_TOOL_TYPE,
527
+ "name": _ALYSIS_WEB_SEARCH_FUNCTION_NAME,
528
+ "max_uses": 5,
529
+ }
530
+ )
531
+ return _AnthropicToolMapping(
532
+ tools=mapped_tools,
533
+ added_builtin_web_search=use_builtin_web_search,
534
+ removed_alysis_web_search=removed_alysis_web_search,
535
+ )
536
+
537
+
538
+ def _anthropic_tool_choice(
539
+ tool_choice: Any,
540
+ *,
541
+ removed_alysis_web_search: bool,
542
+ added_builtin_web_search: bool,
543
+ ) -> dict[str, Any] | None:
544
+ if tool_choice is None:
545
+ return None
546
+ if isinstance(tool_choice, str):
547
+ normalized = tool_choice.strip()
548
+ if normalized == "auto":
549
+ return {"type": "auto"}
550
+ if normalized == "none":
551
+ return {"type": "none"}
552
+ if normalized == "required":
553
+ return {"type": "any"}
554
+ raise LLMError(f"Anthropic Messages does not support tool_choice={tool_choice!r}")
555
+ if not isinstance(tool_choice, dict):
556
+ raise LLMError("Anthropic Messages tool_choice must be a string or object")
557
+
558
+ choice_type = str(tool_choice.get("type") or "").strip()
559
+ if choice_type == "function":
560
+ if "name" in tool_choice:
561
+ name = str(tool_choice.get("name") or "").strip()
562
+ else:
563
+ function = tool_choice.get("function")
564
+ name = str(function.get("name") or "").strip() if isinstance(function, dict) else ""
565
+ if not name:
566
+ raise LLMError("Anthropic Messages forced function tool_choice is missing name")
567
+ if (
568
+ name == _ALYSIS_WEB_SEARCH_FUNCTION_NAME
569
+ and removed_alysis_web_search
570
+ and not added_builtin_web_search
571
+ ):
572
+ raise LLMError(
573
+ "Anthropic Messages removed the Alysis Code web_search function for the selected "
574
+ "web_search_mode; do not force tool_choice to function web_search"
575
+ )
576
+ return {"type": "tool", "name": name}
577
+ if choice_type in {"auto", "any", "none"}:
578
+ return {"type": choice_type}
579
+ if choice_type == "tool":
580
+ name = str(tool_choice.get("name") or "").strip()
581
+ if not name:
582
+ raise LLMError("Anthropic Messages forced tool_choice is missing name")
583
+ return {"type": "tool", "name": name}
584
+ raise LLMError(f"Anthropic Messages does not support tool_choice type {choice_type!r}")
585
+
586
+
587
+ def _metadata_content_blocks(message: dict[str, Any]) -> list[dict[str, Any]]:
588
+ metadata = message.get(PROVIDER_METADATA_KEY)
589
+ if not isinstance(metadata, dict):
590
+ return []
591
+ anthropic_metadata = metadata.get(_ANTHROPIC_METADATA_KEY)
592
+ if not isinstance(anthropic_metadata, dict):
593
+ return []
594
+ blocks = anthropic_metadata.get("content_blocks")
595
+ if not isinstance(blocks, list):
596
+ return []
597
+ copied: list[dict[str, Any]] = []
598
+ for block in blocks:
599
+ if isinstance(block, dict):
600
+ copied.append(copy.deepcopy(block))
601
+ return copied
602
+
603
+
604
+ def _tool_use_blocks_from_tool_calls(message: dict[str, Any]) -> list[dict[str, Any]]:
605
+ blocks: list[dict[str, Any]] = []
606
+ raw_tool_calls = message.get("tool_calls")
607
+ if not isinstance(raw_tool_calls, list):
608
+ return blocks
609
+ for raw_tool_call in raw_tool_calls:
610
+ if not isinstance(raw_tool_call, dict):
611
+ continue
612
+ call_id = str(raw_tool_call.get("id") or raw_tool_call.get("call_id") or "").strip()
613
+ function = raw_tool_call.get("function")
614
+ if isinstance(function, dict):
615
+ name = str(function.get("name") or "").strip()
616
+ arguments = _json_arguments(function.get("arguments"))
617
+ else:
618
+ name = str(raw_tool_call.get("name") or "").strip()
619
+ arguments = _json_arguments(raw_tool_call.get("arguments"))
620
+ if not name:
621
+ continue
622
+ if not call_id:
623
+ call_id = f"toolu_{name}"
624
+ blocks.append(
625
+ {
626
+ "type": "tool_use",
627
+ "id": call_id,
628
+ "name": name,
629
+ "input": arguments,
630
+ }
631
+ )
632
+ return blocks
633
+
634
+
635
+ def _is_tool_result_user_message(message: dict[str, Any]) -> bool:
636
+ if str(message.get("role") or "") != "user":
637
+ return False
638
+ content = message.get("content")
639
+ return (
640
+ isinstance(content, list)
641
+ and bool(content)
642
+ and all(isinstance(block, dict) and block.get("type") == "tool_result" for block in content)
643
+ )
644
+
645
+
646
+ def _anthropic_messages_from_messages(
647
+ messages: list[dict[str, Any]],
648
+ ) -> tuple[str | list[dict[str, Any]] | None, list[dict[str, Any]]]:
649
+ system_parts: list[str] = []
650
+ system_blocks: list[dict[str, Any]] = []
651
+ anthropic_messages: list[dict[str, Any]] = []
652
+
653
+ def _flush_system_parts() -> None:
654
+ if not system_parts:
655
+ return
656
+ text = "\n\n".join(system_parts).strip()
657
+ system_parts.clear()
658
+ if text:
659
+ system_blocks.append({"type": "text", "text": text})
660
+
661
+ for message in messages:
662
+ if not isinstance(message, dict):
663
+ continue
664
+ role = str(message.get("role") or "").strip()
665
+ if role in {"system", "developer"}:
666
+ content = message.get("content")
667
+ if isinstance(content, list) and count_cache_control_blocks(content) > 0:
668
+ _flush_system_parts()
669
+ system_blocks.extend(_anthropic_blocks_from_content(content, role="system"))
670
+ continue
671
+ text = _content_to_text(content).strip()
672
+ if text:
673
+ if system_blocks:
674
+ system_blocks.append({"type": "text", "text": text})
675
+ else:
676
+ system_parts.append(text)
677
+ continue
678
+ if role == "user":
679
+ blocks = _anthropic_blocks_from_content(message.get("content"), role="user")
680
+ if blocks:
681
+ anthropic_messages.append({"role": "user", "content": blocks})
682
+ continue
683
+ if role == "assistant":
684
+ metadata_blocks = _metadata_content_blocks(message)
685
+ if metadata_blocks:
686
+ anthropic_messages.append({"role": "assistant", "content": metadata_blocks})
687
+ continue
688
+ blocks = _anthropic_blocks_from_content(message.get("content"), role="assistant")
689
+ blocks.extend(_tool_use_blocks_from_tool_calls(message))
690
+ if blocks:
691
+ anthropic_messages.append({"role": "assistant", "content": blocks})
692
+ continue
693
+ if role == "tool":
694
+ tool_use_id = str(message.get("tool_call_id") or message.get("call_id") or "").strip()
695
+ if not tool_use_id:
696
+ raise LLMError("Anthropic Messages tool result is missing tool_call_id")
697
+ tool_result_block = {
698
+ "type": "tool_result",
699
+ "tool_use_id": tool_use_id,
700
+ "content": _content_to_text(message.get("content")),
701
+ }
702
+ if anthropic_messages and _is_tool_result_user_message(anthropic_messages[-1]):
703
+ content = anthropic_messages[-1].setdefault("content", [])
704
+ if isinstance(content, list):
705
+ content.append(tool_result_block)
706
+ else:
707
+ anthropic_messages.append({"role": "user", "content": [tool_result_block]})
708
+ else:
709
+ anthropic_messages.append(
710
+ {
711
+ "role": "user",
712
+ "content": [tool_result_block],
713
+ }
714
+ )
715
+ continue
716
+ raise LLMError(f"Anthropic Messages cannot send message role {role!r}")
717
+ if system_blocks:
718
+ _flush_system_parts()
719
+ system: str | list[dict[str, Any]] | None = system_blocks
720
+ else:
721
+ system = "\n\n".join(system_parts).strip() or None
722
+ return system, anthropic_messages
723
+
724
+
725
+ def _anthropic_system_with_cache_control(
726
+ system: str | list[dict[str, Any]] | None,
727
+ *,
728
+ cache_control: Mapping[str, Any] | None,
729
+ ) -> tuple[str | list[dict[str, Any]] | None, bool]:
730
+ if not system or not isinstance(cache_control, Mapping):
731
+ return system, False
732
+ if count_cache_control_blocks(system) > 0:
733
+ return system, True
734
+ cache_control_payload = copy.deepcopy(dict(cache_control))
735
+ if isinstance(system, str):
736
+ text = system.strip()
737
+ if not text:
738
+ return system, False
739
+ return (
740
+ [
741
+ {
742
+ "type": "text",
743
+ "text": system,
744
+ CACHE_CONTROL_FIELD: cache_control_payload,
745
+ }
746
+ ],
747
+ True,
748
+ )
749
+ copied: list[dict[str, Any]] = [copy.deepcopy(block) for block in system]
750
+ for index in range(len(copied) - 1, -1, -1):
751
+ block = copied[index]
752
+ if not isinstance(block, dict):
753
+ continue
754
+ block_type = str(block.get("type") or "text").strip().lower()
755
+ text = block.get("text")
756
+ if block_type != "text" or not isinstance(text, str) or not text.strip():
757
+ continue
758
+ block[CACHE_CONTROL_FIELD] = cache_control_payload
759
+ return copied, True
760
+ return system, False
761
+
762
+
763
+ def _cache_control_ttl(cache_control: Mapping[str, Any] | None) -> str:
764
+ if not isinstance(cache_control, Mapping):
765
+ return "5m"
766
+ return _normalize_prompt_cache_control_ttl(str(cache_control.get("ttl") or "5m"))
767
+
768
+
769
+ def _append_policy_list_value(
770
+ cache_policy: dict[str, Any] | None,
771
+ key: str,
772
+ value: str,
773
+ ) -> None:
774
+ if cache_policy is None:
775
+ return
776
+ normalized = str(value or "").strip()
777
+ if not normalized:
778
+ return
779
+ existing = cache_policy.get(key)
780
+ values = [str(item).strip() for item in existing] if isinstance(existing, list) else []
781
+ if normalized not in values:
782
+ values.append(normalized)
783
+ cache_policy[key] = values
784
+
785
+
786
+ def _apply_anthropic_cache_control_plan(
787
+ *,
788
+ payload: dict[str, Any],
789
+ cache_control: Mapping[str, Any] | None,
790
+ cache_policy: dict[str, Any] | None,
791
+ ) -> None:
792
+ if not isinstance(cache_control, Mapping):
793
+ return
794
+
795
+ explicit_payloads = explicit_cache_control_payloads(payload)
796
+ explicit_block_count = count_explicit_cache_control_blocks(payload)
797
+ requested_ttl = _cache_control_ttl(cache_control)
798
+ ttl_conflict = (
799
+ bool(explicit_payloads) and _cache_control_ttl(explicit_payloads[-1]) != requested_ttl
800
+ )
801
+ top_level_allowed = (
802
+ not ttl_conflict and explicit_block_count < _ANTHROPIC_MAX_CACHE_CONTROL_BREAKPOINTS
803
+ )
804
+ explicit_slot_limit = _ANTHROPIC_MAX_CACHE_CONTROL_BREAKPOINTS - (1 if top_level_allowed else 0)
805
+
806
+ system = payload.get("system")
807
+ if system and not ttl_conflict:
808
+ system_has_cache_control = count_cache_control_blocks(system) > 0
809
+ if not system_has_cache_control and explicit_block_count < explicit_slot_limit:
810
+ updated_system, added_system_cache_control = _anthropic_system_with_cache_control(
811
+ system,
812
+ cache_control=cache_control,
813
+ )
814
+ if added_system_cache_control:
815
+ payload["system"] = updated_system
816
+ explicit_block_count = count_explicit_cache_control_blocks(payload)
817
+ elif system_has_cache_control:
818
+ explicit_block_count = count_explicit_cache_control_blocks(payload)
819
+
820
+ if top_level_allowed:
821
+ payload[CACHE_CONTROL_FIELD] = copy.deepcopy(dict(cache_control))
822
+ else:
823
+ _append_policy_list_value(
824
+ cache_policy,
825
+ "warnings",
826
+ (
827
+ "anthropic_top_level_cache_control_skipped_ttl_conflict"
828
+ if ttl_conflict
829
+ else "anthropic_top_level_cache_control_skipped_breakpoint_limit"
830
+ ),
831
+ )
832
+
833
+ explicit_block_count = count_explicit_cache_control_blocks(payload)
834
+ if cache_policy is not None:
835
+ cache_policy["used"] = bool(top_level_allowed or explicit_block_count > 0)
836
+ cache_policy["top_level_cache_control_used"] = bool(top_level_allowed)
837
+ cache_policy["explicit_block_used"] = explicit_block_count > 0
838
+ cache_policy["explicit_block_count"] = explicit_block_count
839
+ if not top_level_allowed and explicit_block_count <= 0:
840
+ cache_policy["fallback"] = "cache_control_not_applied"
841
+ _append_policy_list_value(cache_policy, "disabled_fields", CACHE_CONTROL_FIELD)
842
+
843
+
844
+ def _payload_has_cache_control(payload: Mapping[str, Any]) -> bool:
845
+ return count_cache_control_blocks(payload) > 0
846
+
847
+
848
+ def _cache_control_rejection_reason(response: httpx.Response) -> str | None:
849
+ if response.status_code != 400:
850
+ return None
851
+ try:
852
+ body = response.text
853
+ except Exception:
854
+ body = ""
855
+ lowered = body.lower()
856
+ if any(
857
+ marker in lowered
858
+ for marker in (
859
+ "cache_control",
860
+ "cache control",
861
+ "prompt cache",
862
+ "cache breakpoint",
863
+ "ephemeral",
864
+ "ttl",
865
+ )
866
+ ):
867
+ return "anthropic_cache_control_rejected"
868
+ return None
869
+
870
+
871
+ def _temperature_rejection_reason(response: httpx.Response) -> str | None:
872
+ if response.status_code not in {400, 422}:
873
+ return None
874
+ try:
875
+ body = response.text
876
+ except Exception:
877
+ body = ""
878
+ if "temperature" in body.casefold():
879
+ return "provider_rejected_temperature"
880
+ return None
881
+
882
+
883
+ def _thinking_display_rejection_reason(response: httpx.Response) -> str | None:
884
+ """Return a fallback reason only for explicit summary-display incompatibility."""
885
+
886
+ if response.status_code not in {400, 422}:
887
+ return None
888
+ try:
889
+ body = response.text
890
+ except Exception:
891
+ body = ""
892
+ lowered = body.casefold()
893
+ if not any(marker in lowered for marker in ("display", "summarized")):
894
+ return None
895
+ if not any(
896
+ marker in lowered
897
+ for marker in (
898
+ "unsupported",
899
+ "not supported",
900
+ "does not support",
901
+ "unknown",
902
+ "unrecognized",
903
+ "invalid",
904
+ "not allowed",
905
+ "not permitted",
906
+ "unexpected",
907
+ "extra input",
908
+ "extra field",
909
+ )
910
+ ):
911
+ return None
912
+ return "provider_rejected_thinking_display"
913
+
914
+
915
+ def _payload_requests_summarized_thinking(payload: Mapping[str, Any]) -> bool:
916
+ thinking = payload.get("thinking")
917
+ return isinstance(thinking, Mapping) and thinking.get("display") == "summarized"
918
+
919
+
920
+ def _without_thinking_display(payload: Mapping[str, Any]) -> dict[str, Any]:
921
+ downgraded = copy.deepcopy(dict(payload))
922
+ thinking = downgraded.get("thinking")
923
+ if isinstance(thinking, dict):
924
+ thinking.pop("display", None)
925
+ return downgraded
926
+
927
+
928
+ def _downgrade_anthropic_cache_control_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
929
+ downgraded = copy.deepcopy(dict(payload))
930
+ strip_cache_control_blocks(downgraded)
931
+ return downgraded
932
+
933
+
934
+ def _mark_anthropic_cache_control_downgrade(
935
+ cache_policy: dict[str, Any] | None,
936
+ *,
937
+ reason: str,
938
+ ) -> None:
939
+ if cache_policy is None:
940
+ return
941
+ cache_policy["status"] = "fallback"
942
+ cache_policy["used"] = False
943
+ cache_policy["fallback"] = reason
944
+ cache_policy["top_level_cache_control_used"] = False
945
+ cache_policy["explicit_block_used"] = False
946
+ cache_policy["explicit_block_count"] = 0
947
+ _append_policy_list_value(cache_policy, "disabled_fields", CACHE_CONTROL_FIELD)
948
+ _append_policy_list_value(cache_policy, "warnings", reason)
949
+
950
+
951
+ def _parse_usage(raw: Any) -> LLMUsage | None:
952
+ if not isinstance(raw, dict):
953
+ return None
954
+
955
+ def _as_non_negative_int(value: Any) -> int | None:
956
+ try:
957
+ parsed = int(value) if value is not None else None
958
+ except (TypeError, ValueError):
959
+ return None
960
+ if parsed is None or parsed >= 0:
961
+ return parsed
962
+ return None
963
+
964
+ input_tokens = _as_non_negative_int(raw.get("input_tokens"))
965
+ output_tokens = _as_non_negative_int(raw.get("output_tokens"))
966
+ cache_read_input_tokens = _as_non_negative_int(raw.get("cache_read_input_tokens"))
967
+ cache_creation = raw.get("cache_creation")
968
+ cache_creation_5m_input_tokens: int | None = None
969
+ cache_creation_1h_input_tokens: int | None = None
970
+ if isinstance(cache_creation, dict):
971
+ cache_creation_5m_input_tokens = _as_non_negative_int(
972
+ cache_creation.get("ephemeral_5m_input_tokens")
973
+ )
974
+ cache_creation_1h_input_tokens = _as_non_negative_int(
975
+ cache_creation.get("ephemeral_1h_input_tokens")
976
+ )
977
+ cache_creation_input_tokens = _as_non_negative_int(raw.get("cache_creation_input_tokens"))
978
+ if cache_creation_input_tokens is None:
979
+ creation_parts = [
980
+ value
981
+ for value in (cache_creation_5m_input_tokens, cache_creation_1h_input_tokens)
982
+ if value is not None
983
+ ]
984
+ if creation_parts:
985
+ cache_creation_input_tokens = sum(creation_parts)
986
+
987
+ has_cache_accounting = any(
988
+ value is not None for value in (cache_read_input_tokens, cache_creation_input_tokens)
989
+ )
990
+ prompt_tokens = input_tokens
991
+ if has_cache_accounting:
992
+ prompt_tokens = sum(
993
+ value or 0
994
+ for value in (input_tokens, cache_read_input_tokens, cache_creation_input_tokens)
995
+ )
996
+ total_tokens = None
997
+ if prompt_tokens is not None and output_tokens is not None:
998
+ total_tokens = prompt_tokens + output_tokens
999
+ return LLMUsage(
1000
+ prompt_tokens=prompt_tokens,
1001
+ completion_tokens=output_tokens,
1002
+ total_tokens=total_tokens,
1003
+ cached_prompt_tokens=cache_read_input_tokens,
1004
+ input_tokens_uncached=input_tokens,
1005
+ cache_read_input_tokens=cache_read_input_tokens,
1006
+ cache_creation_input_tokens=cache_creation_input_tokens,
1007
+ cache_creation_5m_input_tokens=cache_creation_5m_input_tokens,
1008
+ cache_creation_1h_input_tokens=cache_creation_1h_input_tokens,
1009
+ raw_provider_usage=copy.deepcopy(raw),
1010
+ )
1011
+
1012
+
1013
+ def _extract_error_message(data: Any) -> str | None:
1014
+ if not isinstance(data, dict):
1015
+ return None
1016
+ error_obj = data.get("error")
1017
+ if isinstance(error_obj, dict):
1018
+ message = str(error_obj.get("message") or "").strip()
1019
+ if message:
1020
+ error_type = str(error_obj.get("type") or "").strip()
1021
+ return f"{error_type}: {message}" if error_type else message
1022
+ return None
1023
+
1024
+
1025
+ def _text_from_content_blocks(content: list[Any]) -> str:
1026
+ parts: list[str] = []
1027
+ for block in content:
1028
+ if not isinstance(block, dict):
1029
+ continue
1030
+ if str(block.get("type") or "") == "text":
1031
+ text = block.get("text")
1032
+ if isinstance(text, str):
1033
+ parts.append(text)
1034
+ return "".join(parts)
1035
+
1036
+
1037
+ def _thinking_summaries_from_content_blocks(content: list[Any]) -> list[str]:
1038
+ summaries: list[str] = []
1039
+ for block in content:
1040
+ if not isinstance(block, dict) or str(block.get("type") or "") != "thinking":
1041
+ continue
1042
+ thinking = block.get("thinking")
1043
+ if isinstance(thinking, str) and thinking:
1044
+ # This field is only requested with display="summarized". Never
1045
+ # surface signatures, redacted_thinking blocks, or opaque state.
1046
+ summaries.append(thinking)
1047
+ return summaries
1048
+
1049
+
1050
+ def _parse_tool_calls(content: list[Any]) -> list[ToolCall]:
1051
+ tool_calls: list[ToolCall] = []
1052
+ for index, block in enumerate(content):
1053
+ if not isinstance(block, dict):
1054
+ continue
1055
+ if str(block.get("type") or "") != "tool_use":
1056
+ continue
1057
+ tool_use_id = str(block.get("id") or f"toolu_{index}").strip()
1058
+ name = str(block.get("name") or "").strip()
1059
+ if not name:
1060
+ continue
1061
+ raw_input = block.get("input")
1062
+ arguments = (
1063
+ dict(raw_input) if isinstance(raw_input, dict) else {"_raw_arguments": raw_input}
1064
+ )
1065
+ tool_calls.append(
1066
+ ToolCall(
1067
+ id=tool_use_id,
1068
+ name=name,
1069
+ arguments=arguments,
1070
+ provider_metadata={
1071
+ _ANTHROPIC_METADATA_KEY: {
1072
+ "content_index": index,
1073
+ }
1074
+ },
1075
+ )
1076
+ )
1077
+ return tool_calls
1078
+
1079
+
1080
+ def _collect_web_search_metadata(content: list[Any]) -> dict[str, Any]:
1081
+ server_tool_uses: list[dict[str, Any]] = []
1082
+ web_search_results: list[dict[str, Any]] = []
1083
+ citations: list[dict[str, Any]] = []
1084
+ sources: list[dict[str, Any]] = []
1085
+ queries: list[str] = []
1086
+
1087
+ def _append_source(raw: dict[str, Any]) -> None:
1088
+ url = str(raw.get("url") or "").strip()
1089
+ if not url:
1090
+ return
1091
+ source: dict[str, Any] = {
1092
+ "url": url,
1093
+ "title": str(raw.get("title") or "").strip(),
1094
+ }
1095
+ for key in ("page_age", "encrypted_content", "encrypted_index", "cited_text"):
1096
+ value = raw.get(key)
1097
+ if value is not None:
1098
+ source[key] = value
1099
+ sources.append(source)
1100
+
1101
+ for block in content:
1102
+ if not isinstance(block, dict):
1103
+ continue
1104
+ block_type = str(block.get("type") or "")
1105
+ if block_type == "server_tool_use" and str(block.get("name") or "") == "web_search":
1106
+ copied = copy.deepcopy(block)
1107
+ server_tool_uses.append(copied)
1108
+ raw_input = block.get("input")
1109
+ if isinstance(raw_input, dict):
1110
+ query = str(raw_input.get("query") or "").strip()
1111
+ if query:
1112
+ queries.append(query)
1113
+ continue
1114
+ if block_type == "web_search_tool_result":
1115
+ web_search_results.append(copy.deepcopy(block))
1116
+ result_content = block.get("content")
1117
+ if isinstance(result_content, list):
1118
+ for result in result_content:
1119
+ if (
1120
+ isinstance(result, dict)
1121
+ and str(result.get("type") or "") == "web_search_result"
1122
+ ):
1123
+ web_search_results.append(copy.deepcopy(result))
1124
+ _append_source(result)
1125
+ continue
1126
+ if block_type != "text":
1127
+ continue
1128
+ raw_citations = block.get("citations")
1129
+ if not isinstance(raw_citations, list):
1130
+ continue
1131
+ for citation in raw_citations:
1132
+ if not isinstance(citation, dict):
1133
+ continue
1134
+ if str(citation.get("type") or "") != "web_search_result_location":
1135
+ continue
1136
+ url = str(citation.get("url") or "").strip()
1137
+ if not url:
1138
+ continue
1139
+ citation_payload = {
1140
+ "url": url,
1141
+ "title": str(citation.get("title") or "").strip(),
1142
+ "encrypted_index": citation.get("encrypted_index"),
1143
+ "cited_text": citation.get("cited_text"),
1144
+ }
1145
+ citations.append(citation_payload)
1146
+ _append_source(citation)
1147
+
1148
+ if queries:
1149
+ queries = list(dict.fromkeys(queries))
1150
+ deduped_sources: list[dict[str, Any]] = []
1151
+ seen_urls: set[str] = set()
1152
+ for source in sources:
1153
+ url = str(source.get("url") or "").strip()
1154
+ if not url or url in seen_urls:
1155
+ continue
1156
+ seen_urls.add(url)
1157
+ deduped_sources.append(source)
1158
+ metadata: dict[str, Any] = {}
1159
+ if server_tool_uses:
1160
+ metadata["server_tool_uses"] = server_tool_uses
1161
+ if web_search_results:
1162
+ metadata["web_search_results"] = web_search_results
1163
+ if citations:
1164
+ metadata["citations"] = citations
1165
+ if deduped_sources:
1166
+ metadata["sources"] = deduped_sources
1167
+ if queries:
1168
+ metadata["queries"] = queries
1169
+ return metadata
1170
+
1171
+
1172
+ def _anthropic_provider_metadata(data: dict[str, Any]) -> dict[str, Any] | None:
1173
+ content = data.get("content")
1174
+ metadata: dict[str, Any] = {}
1175
+ message_id = str(data.get("id") or "").strip()
1176
+ if message_id:
1177
+ metadata["message_id"] = message_id
1178
+ stop_reason = str(data.get("stop_reason") or "").strip()
1179
+ if stop_reason:
1180
+ metadata["stop_reason"] = stop_reason
1181
+ stop_sequence = data.get("stop_sequence")
1182
+ if stop_sequence is not None:
1183
+ metadata["stop_sequence"] = stop_sequence
1184
+ if isinstance(content, list):
1185
+ metadata["content_blocks"] = copy.deepcopy(content)
1186
+ metadata.update(_collect_web_search_metadata(content))
1187
+ usage = data.get("usage")
1188
+ if isinstance(usage, dict):
1189
+ metadata["usage"] = copy.deepcopy(usage)
1190
+ stream_metadata = data.get("stream_metadata")
1191
+ if isinstance(stream_metadata, dict):
1192
+ metadata["stream_metadata"] = copy.deepcopy(stream_metadata)
1193
+ return {_ANTHROPIC_METADATA_KEY: metadata} if metadata else None
1194
+
1195
+
1196
+ def _response_from_json(data: dict[str, Any]) -> httpx.Response:
1197
+ return httpx.Response(200, json=data)
1198
+
1199
+
1200
+ def _event_index(data: dict[str, Any], *, event_type: str) -> int:
1201
+ index = data.get("index")
1202
+ if isinstance(index, int) and index >= 0:
1203
+ return index
1204
+ raise LLMError(f"Anthropic Messages stream {event_type} event is missing a valid index")
1205
+
1206
+
1207
+ class _AnthropicStreamAccumulator:
1208
+ def __init__(
1209
+ self,
1210
+ *,
1211
+ on_text_delta: Callable[[str], None] | None,
1212
+ on_reasoning_delta: Callable[[str], None] | None,
1213
+ reasoning_is_summary: bool,
1214
+ ) -> None:
1215
+ self.on_text_delta = on_text_delta
1216
+ self.on_reasoning_delta = on_reasoning_delta
1217
+ self.reasoning_is_summary = reasoning_is_summary
1218
+ self.message: dict[str, Any] = {
1219
+ "type": "message",
1220
+ "role": "assistant",
1221
+ "content": [],
1222
+ }
1223
+ self.content_blocks: dict[int, dict[str, Any]] = {}
1224
+ self.input_json_chunks: dict[int, list[str]] = {}
1225
+ self.usage: dict[str, Any] = {}
1226
+ self.unknown_events: list[dict[str, Any]] = []
1227
+ self.event_count = 0
1228
+ self.seen_message_start = False
1229
+ self.seen_message_stop = False
1230
+
1231
+ def handle(self, frame: SSEFrame, data: dict[str, Any]) -> None:
1232
+ event_type = str(data.get("type") or frame.event or "").strip()
1233
+ if not event_type:
1234
+ self._append_unknown(frame=frame, data=data)
1235
+ return
1236
+ self.event_count += 1
1237
+
1238
+ if event_type == "message_start":
1239
+ self._handle_message_start(data)
1240
+ return
1241
+ if event_type == "content_block_start":
1242
+ self._handle_content_block_start(data)
1243
+ return
1244
+ if event_type == "content_block_delta":
1245
+ self._handle_content_block_delta(data)
1246
+ return
1247
+ if event_type == "content_block_stop":
1248
+ self._handle_content_block_stop(data)
1249
+ return
1250
+ if event_type == "message_delta":
1251
+ self._handle_message_delta(data)
1252
+ return
1253
+ if event_type == "message_stop":
1254
+ self.seen_message_stop = True
1255
+ return
1256
+ if event_type == "ping":
1257
+ return
1258
+ if event_type == "error":
1259
+ message = _extract_error_message(data)
1260
+ raise LLMError(f"Anthropic Messages stream error: {message or data!r}")
1261
+
1262
+ self._append_unknown(frame=frame, data=data)
1263
+
1264
+ def _handle_message_start(self, data: dict[str, Any]) -> None:
1265
+ raw_message = data.get("message")
1266
+ if not isinstance(raw_message, dict):
1267
+ raise LLMError("Anthropic Messages stream message_start missing message object")
1268
+ message = copy.deepcopy(raw_message)
1269
+ content = message.get("content")
1270
+ if not isinstance(content, list):
1271
+ message["content"] = []
1272
+ else:
1273
+ message["content"] = []
1274
+ self.message.update(message)
1275
+ usage = message.get("usage")
1276
+ if isinstance(usage, dict):
1277
+ self.usage.update(copy.deepcopy(usage))
1278
+ self.seen_message_start = True
1279
+
1280
+ def _handle_content_block_start(self, data: dict[str, Any]) -> None:
1281
+ index = _event_index(data, event_type="content_block_start")
1282
+ raw_block = data.get("content_block")
1283
+ block = copy.deepcopy(raw_block) if isinstance(raw_block, dict) else {}
1284
+ if not str(block.get("type") or "").strip():
1285
+ block["type"] = "unknown"
1286
+ block.setdefault("_stream_index", index)
1287
+ self.content_blocks[index] = block
1288
+ if str(block.get("type") or "") in {"tool_use", "server_tool_use"}:
1289
+ self.input_json_chunks.setdefault(index, [])
1290
+
1291
+ def _handle_content_block_delta(self, data: dict[str, Any]) -> None:
1292
+ index = _event_index(data, event_type="content_block_delta")
1293
+ block = self.content_blocks.setdefault(index, {"type": "unknown", "_stream_index": index})
1294
+ raw_delta = data.get("delta")
1295
+ if not isinstance(raw_delta, dict):
1296
+ self._append_block_delta(block, raw_delta)
1297
+ return
1298
+ delta_type = str(raw_delta.get("type") or "").strip()
1299
+
1300
+ if delta_type == "text_delta":
1301
+ text = raw_delta.get("text")
1302
+ if isinstance(text, str) and text:
1303
+ existing = block.get("text")
1304
+ block["text"] = (existing if isinstance(existing, str) else "") + text
1305
+ if self.on_text_delta is not None:
1306
+ self.on_text_delta(text)
1307
+ return
1308
+
1309
+ if delta_type == "input_json_delta":
1310
+ partial_json = raw_delta.get("partial_json")
1311
+ if isinstance(partial_json, str):
1312
+ self.input_json_chunks.setdefault(index, []).append(partial_json)
1313
+ return
1314
+
1315
+ if delta_type == "thinking_delta":
1316
+ thinking = raw_delta.get("thinking")
1317
+ if isinstance(thinking, str) and thinking:
1318
+ existing = block.get("thinking")
1319
+ block["thinking"] = (existing if isinstance(existing, str) else "") + thinking
1320
+ if self.reasoning_is_summary and self.on_reasoning_delta is not None:
1321
+ self.on_reasoning_delta(thinking)
1322
+ return
1323
+
1324
+ if delta_type == "signature_delta":
1325
+ signature = raw_delta.get("signature")
1326
+ if isinstance(signature, str) and signature:
1327
+ existing = block.get("signature")
1328
+ block["signature"] = (existing if isinstance(existing, str) else "") + signature
1329
+ return
1330
+
1331
+ if delta_type in {"citations_delta", "citation_delta"}:
1332
+ self._append_citation_delta(block, raw_delta)
1333
+ return
1334
+
1335
+ self._append_block_delta(block, raw_delta)
1336
+
1337
+ def _handle_content_block_stop(self, data: dict[str, Any]) -> None:
1338
+ index = _event_index(data, event_type="content_block_stop")
1339
+ block = self.content_blocks.get(index)
1340
+ if block is None:
1341
+ raise LLMError(
1342
+ "Anthropic Messages stream content_block_stop before content_block_start"
1343
+ )
1344
+ chunks = self.input_json_chunks.get(index)
1345
+ if chunks is None:
1346
+ return
1347
+ joined = "".join(chunks).strip()
1348
+ if not joined:
1349
+ if "input" not in block:
1350
+ block["input"] = {}
1351
+ return
1352
+ try:
1353
+ parsed = json.loads(joined)
1354
+ except json.JSONDecodeError as exc:
1355
+ raise LLMError(
1356
+ "Anthropic Messages stream emitted malformed tool input JSON "
1357
+ f"for content block {index}: {exc.msg}"
1358
+ ) from exc
1359
+ block["input"] = parsed if isinstance(parsed, dict) else {"_raw_arguments": parsed}
1360
+
1361
+ def _handle_message_delta(self, data: dict[str, Any]) -> None:
1362
+ delta = data.get("delta")
1363
+ if isinstance(delta, dict):
1364
+ for key in ("stop_reason", "stop_sequence"):
1365
+ if key in delta:
1366
+ self.message[key] = copy.deepcopy(delta[key])
1367
+ usage = data.get("usage")
1368
+ if isinstance(usage, dict):
1369
+ self.usage.update(copy.deepcopy(usage))
1370
+
1371
+ def finish(self) -> dict[str, Any]:
1372
+ if not self.seen_message_start:
1373
+ raise LLMError("Anthropic Messages stream returned no message_start event")
1374
+ if not self.seen_message_stop:
1375
+ raise LLMError("Anthropic Messages stream ended before message_stop")
1376
+ for index in tuple(self.input_json_chunks):
1377
+ self._handle_content_block_stop({"index": index})
1378
+ content = [
1379
+ self._public_content_block(block)
1380
+ for _index, block in sorted(self.content_blocks.items(), key=lambda item: item[0])
1381
+ ]
1382
+ self.message["content"] = content
1383
+ if self.usage:
1384
+ self.message["usage"] = copy.deepcopy(self.usage)
1385
+ stream_metadata: dict[str, Any] = {"events": self.event_count}
1386
+ if self.unknown_events:
1387
+ stream_metadata["unknown_events"] = copy.deepcopy(self.unknown_events)
1388
+ self.message["stream_metadata"] = stream_metadata
1389
+ return copy.deepcopy(self.message)
1390
+
1391
+ @staticmethod
1392
+ def _append_citation_delta(block: dict[str, Any], delta: dict[str, Any]) -> None:
1393
+ citations = block.setdefault("citations", [])
1394
+ if not isinstance(citations, list):
1395
+ citations = []
1396
+ block["citations"] = citations
1397
+ citation = delta.get("citation")
1398
+ if isinstance(citation, dict):
1399
+ citations.append(copy.deepcopy(citation))
1400
+ return
1401
+ raw_citations = delta.get("citations")
1402
+ if isinstance(raw_citations, list):
1403
+ citations.extend(
1404
+ copy.deepcopy(item) for item in raw_citations if isinstance(item, dict)
1405
+ )
1406
+
1407
+ @staticmethod
1408
+ def _append_block_delta(block: dict[str, Any], delta: Any) -> None:
1409
+ deltas = block.setdefault("_stream_deltas", [])
1410
+ if isinstance(deltas, list):
1411
+ deltas.append(copy.deepcopy(delta))
1412
+
1413
+ def _append_unknown(self, *, frame: SSEFrame, data: dict[str, Any]) -> None:
1414
+ self.unknown_events.append(
1415
+ {
1416
+ "event": frame.event,
1417
+ "data": copy.deepcopy(data),
1418
+ }
1419
+ )
1420
+
1421
+ @staticmethod
1422
+ def _public_content_block(block: dict[str, Any]) -> dict[str, Any]:
1423
+ copied = copy.deepcopy(block)
1424
+ copied.pop("_stream_index", None)
1425
+ return copied
1426
+
1427
+
1428
+ class AnthropicMessagesClient:
1429
+ usage_contract = UsageContract(
1430
+ response_usage_confidence=UsageConfidence.AUTHORITATIVE,
1431
+ input_token_count_strategy="anthropic_messages",
1432
+ )
1433
+ usage_counts_authoritative = usage_contract.response_usage_authoritative
1434
+ supports_tool_calling = True
1435
+ supports_forced_tool_choice = True
1436
+
1437
+ def __init__(
1438
+ self,
1439
+ *,
1440
+ base_url: str,
1441
+ api_key: str,
1442
+ model: str,
1443
+ timeout_s: float = 20.0,
1444
+ temperature: float = 1.0,
1445
+ prompt_cache_key: str | None = None,
1446
+ prompt_cache_retention: str | None = None,
1447
+ enable_thinking: bool | None = None,
1448
+ reasoning_effort: str | None = None,
1449
+ transport: httpx.BaseTransport | None = None,
1450
+ extra_headers: dict[str, str] | None = None,
1451
+ provider_key: str | None = None,
1452
+ web_search_mode: str = "off",
1453
+ web_search_adapter: str = AUTO_WEB_SEARCH_ADAPTER,
1454
+ prompt_cache_control_enabled: bool = False,
1455
+ prompt_cache_control_ttl: str = "5m",
1456
+ prompt_cache_policy_metadata: Mapping[str, Any] | None = None,
1457
+ provider_concurrency_caps: dict[str, int] | None = None,
1458
+ provider_retry_settings: ProviderRetrySettings | None = None,
1459
+ provider_sleep_fn: Callable[[float], None] | None = None,
1460
+ provider_random_fn: Callable[[], float] | None = None,
1461
+ default_max_tokens: int = 4096,
1462
+ usage_contract: UsageContract | None = None,
1463
+ route_identity: ProviderRouteIdentity | None = None,
1464
+ ) -> None:
1465
+ self.base_url = base_url.rstrip("/")
1466
+ self.api_key = api_key
1467
+ self.model = model
1468
+ self.timeout_s = timeout_s
1469
+ self.temperature = temperature
1470
+ self.prompt_cache_key = str(prompt_cache_key or "").strip() or None
1471
+ self.prompt_cache_retention = str(prompt_cache_retention or "").strip() or None
1472
+ self.enable_thinking = enable_thinking
1473
+ self.reasoning_effort = str(reasoning_effort or "").strip().lower() or None
1474
+ self._transport = transport
1475
+ self.extra_headers = canonicalize_extra_headers(extra_headers)
1476
+ self.provider_key = str(provider_key or "").strip() or None
1477
+ self.route_identity = route_identity or build_provider_route_identity(
1478
+ protocol="anthropic_messages",
1479
+ base_url=self.base_url,
1480
+ provider_key=self.provider_key,
1481
+ model=self.model,
1482
+ credential_scope=credential_scope_fingerprint(self.api_key),
1483
+ routing_headers=self.extra_headers,
1484
+ protocol_revision=ANTHROPIC_MESSAGES_ROUTE_REVISION,
1485
+ )
1486
+ self.web_search_mode = str(web_search_mode or "off").strip().lower()
1487
+ self.web_search_adapter = (
1488
+ str(web_search_adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower()
1489
+ or AUTO_WEB_SEARCH_ADAPTER
1490
+ )
1491
+ self.prompt_cache_control_enabled = bool(prompt_cache_control_enabled)
1492
+ self.prompt_cache_control_ttl = _normalize_prompt_cache_control_ttl(
1493
+ prompt_cache_control_ttl
1494
+ )
1495
+ self.prompt_cache_policy_metadata = (
1496
+ copy.deepcopy(dict(prompt_cache_policy_metadata))
1497
+ if isinstance(prompt_cache_policy_metadata, Mapping)
1498
+ else None
1499
+ )
1500
+ self.provider_concurrency_caps = dict(
1501
+ DEFAULT_PROVIDER_CONCURRENCY_CAPS
1502
+ if provider_concurrency_caps is None
1503
+ else provider_concurrency_caps
1504
+ )
1505
+ self.provider_retry_settings = provider_retry_settings or ProviderRetrySettings()
1506
+ self._provider_sleep_fn = provider_sleep_fn
1507
+ self._provider_random_fn = provider_random_fn
1508
+ self.default_max_tokens = int(default_max_tokens)
1509
+ self.usage_contract = usage_contract or type(self).usage_contract
1510
+ self.usage_counts_authoritative = self.usage_contract.response_usage_authoritative
1511
+ self._input_token_count_available: bool | None = None
1512
+ self._temperature_omit_after_rejection = False
1513
+ self._thinking_display_supported: bool | None = None
1514
+
1515
+ def _headers(self) -> dict[str, str]:
1516
+ headers = merge_canonical_headers(
1517
+ {
1518
+ "x-api-key": self.api_key,
1519
+ "anthropic-version": _DEFAULT_ANTHROPIC_VERSION,
1520
+ "Content-Type": "application/json",
1521
+ "User-Agent": "alysis-code/0.1.0",
1522
+ },
1523
+ self.extra_headers,
1524
+ )
1525
+ return _headers_with_default_accept_encoding(headers)
1526
+
1527
+ @staticmethod
1528
+ def _llm_error_from_response(response: httpx.Response) -> LLMError:
1529
+ try:
1530
+ data = response.json()
1531
+ except Exception:
1532
+ body = response.text
1533
+ if len(body) > 1000:
1534
+ body = body[:1000] + "...(truncated)"
1535
+ return LLMError(
1536
+ sanitize_error_text_for_output(f"LLM error {response.status_code}: {body}")
1537
+ )
1538
+ error_message = _extract_error_message(data)
1539
+ if error_message:
1540
+ return LLMError(
1541
+ sanitize_error_text_for_output(f"LLM error {response.status_code}: {error_message}")
1542
+ )
1543
+ return LLMError(
1544
+ sanitize_error_text_for_output(f"LLM error {response.status_code}: {data!r}")
1545
+ )
1546
+
1547
+ def count_input_tokens(
1548
+ self,
1549
+ *,
1550
+ messages: list[dict[str, Any]],
1551
+ tools: list[dict[str, Any]] | None = None,
1552
+ tool_choice: Any | None = None,
1553
+ ) -> InputTokenCount | None:
1554
+ if self._input_token_count_available is False:
1555
+ return None
1556
+ messages = gate_messages_for_provider_route(messages, self.route_identity)
1557
+ system, anthropic_messages = _anthropic_messages_from_messages(messages)
1558
+ tool_mapping = _anthropic_tools(
1559
+ tools,
1560
+ mode=self.web_search_mode,
1561
+ adapter=self.web_search_adapter,
1562
+ )
1563
+ payload: dict[str, Any] = {
1564
+ "model": self.model,
1565
+ "messages": anthropic_messages,
1566
+ }
1567
+ thinking_plan = _anthropic_thinking_plan(
1568
+ model=self.model,
1569
+ enable_thinking=self.enable_thinking,
1570
+ reasoning_effort=self.reasoning_effort,
1571
+ max_tokens=self.default_max_tokens,
1572
+ request_summary=False,
1573
+ )
1574
+ if thinking_plan.config is not None:
1575
+ payload["thinking"] = copy.deepcopy(thinking_plan.config)
1576
+ if self._thinking_display_supported is False:
1577
+ payload = _without_thinking_display(payload)
1578
+ if system:
1579
+ payload["system"] = system
1580
+ if tool_mapping.tools:
1581
+ payload["tools"] = tool_mapping.tools
1582
+ mapped_tool_choice = _anthropic_tool_choice(
1583
+ tool_choice,
1584
+ removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
1585
+ added_builtin_web_search=tool_mapping.added_builtin_web_search,
1586
+ )
1587
+ if mapped_tool_choice is not None:
1588
+ if not (thinking_plan.active and mapped_tool_choice.get("type") in {"any", "tool"}):
1589
+ payload["tool_choice"] = mapped_tool_choice
1590
+ count_cache_plan = RequestCachePlan(
1591
+ strategy=("anthropic_cache_control" if self.prompt_cache_control_enabled else "none"),
1592
+ mode="automatic" if self.prompt_cache_control_enabled else "manual",
1593
+ anthropic_cache_control_enabled=self.prompt_cache_control_enabled,
1594
+ anthropic_cache_control_ttl=self.prompt_cache_control_ttl,
1595
+ )
1596
+ _apply_anthropic_cache_control_plan(
1597
+ payload=payload,
1598
+ cache_control=count_cache_plan.anthropic_cache_control_payload(),
1599
+ cache_policy=merge_cache_policy_metadata(
1600
+ self.prompt_cache_policy_metadata,
1601
+ count_cache_plan.anthropic_cache_policy_metadata(),
1602
+ ),
1603
+ )
1604
+ url = f"{self.base_url}/messages/count_tokens"
1605
+
1606
+ def _send_request() -> InputTokenCount | None:
1607
+ try:
1608
+ with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
1609
+ response = client.post(url, headers=self._headers(), json=payload)
1610
+ except httpx.HTTPError as exc:
1611
+ raise LLMError(
1612
+ "Anthropic input token count request failed: "
1613
+ f"{sanitize_error_text_for_output(exc)}"
1614
+ ) from exc
1615
+ if response.status_code in {404, 405, 501}:
1616
+ self._input_token_count_available = False
1617
+ return None
1618
+ if response.status_code >= 400:
1619
+ raise self._llm_error_from_response(response)
1620
+ try:
1621
+ data = response.json()
1622
+ except Exception as exc: # noqa: BLE001
1623
+ raise LLMError("Anthropic input token count returned non-JSON response") from exc
1624
+ count = _non_negative_int(data.get("input_tokens") if isinstance(data, dict) else None)
1625
+ if count is None:
1626
+ raise LLMError("Anthropic input token count response omitted input_tokens")
1627
+ self._input_token_count_available = True
1628
+ return InputTokenCount(
1629
+ input_tokens=count,
1630
+ raw_provider_usage=copy.deepcopy(data),
1631
+ )
1632
+
1633
+ return run_provider_limited_call(
1634
+ call=_send_request,
1635
+ provider_key=self.provider_key,
1636
+ provider_concurrency_caps=self.provider_concurrency_caps,
1637
+ retry_settings=self.provider_retry_settings,
1638
+ operation="anthropic_messages_count_input_tokens",
1639
+ sleep_fn=self._provider_sleep_fn,
1640
+ random_fn=self._provider_random_fn,
1641
+ retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
1642
+ )
1643
+
1644
+ def chat(
1645
+ self,
1646
+ *,
1647
+ messages: list[dict[str, Any]],
1648
+ tools: list[dict[str, Any]] | None = None,
1649
+ tool_choice: Any | None = None,
1650
+ response_format: dict[str, Any] | None = None,
1651
+ stream: bool = False,
1652
+ on_text_delta: Callable[[str], None] | None = None,
1653
+ on_reasoning_delta: Callable[[str], None] | None = None,
1654
+ temperature: float | None = None,
1655
+ max_tokens: int | None = None,
1656
+ request_plan: LLMRequestPlan | None = None,
1657
+ ) -> LLMResponse:
1658
+ default_cache = RequestCachePlan(
1659
+ strategy="anthropic_cache_control" if self.prompt_cache_control_enabled else "none",
1660
+ mode="automatic" if self.prompt_cache_control_enabled else "manual",
1661
+ anthropic_cache_control_enabled=self.prompt_cache_control_enabled,
1662
+ anthropic_cache_control_ttl=self.prompt_cache_control_ttl,
1663
+ )
1664
+ plan = request_plan or LLMRequestPlan.from_chat_args(
1665
+ messages=messages,
1666
+ tools=tools,
1667
+ tool_choice=tool_choice,
1668
+ response_format=response_format,
1669
+ stream=stream,
1670
+ temperature=temperature,
1671
+ max_tokens=max_tokens,
1672
+ cache=default_cache,
1673
+ )
1674
+ if (
1675
+ request_plan is not None
1676
+ and plan.cache.mode != "off"
1677
+ and plan.cache.strategy == "none"
1678
+ and not plan.cache.anthropic_cache_control_enabled
1679
+ and self.prompt_cache_control_enabled
1680
+ ):
1681
+ plan = plan.with_cache(default_cache)
1682
+ messages = gate_messages_for_provider_route(plan.message_list(), self.route_identity)
1683
+ tools = plan.tool_list()
1684
+ tool_choice = plan.tool_choice
1685
+ response_format = plan.response_format
1686
+ stream = plan.stream
1687
+ temperature = plan.temperature
1688
+ max_tokens = plan.max_tokens
1689
+ if response_format is not None:
1690
+ raise LLMError("Anthropic Messages does not support response_format")
1691
+
1692
+ system, anthropic_messages = _anthropic_messages_from_messages(messages)
1693
+ tool_mapping = _anthropic_tools(
1694
+ tools,
1695
+ mode=self.web_search_mode,
1696
+ adapter=self.web_search_adapter,
1697
+ )
1698
+ effective_max_tokens = (
1699
+ int(max_tokens) if max_tokens is not None else self.default_max_tokens
1700
+ )
1701
+ thinking_plan = _anthropic_thinking_plan(
1702
+ model=self.model,
1703
+ enable_thinking=self.enable_thinking,
1704
+ reasoning_effort=self.reasoning_effort,
1705
+ max_tokens=effective_max_tokens,
1706
+ request_summary=(
1707
+ on_reasoning_delta is not None and self._thinking_display_supported is not False
1708
+ ),
1709
+ )
1710
+ temperature_omit_reason = documented_temperature_omit_reason(self.model)
1711
+ if thinking_plan.active:
1712
+ temperature_omit_reason = "anthropic_extended_thinking_temperature_unsupported"
1713
+ if self._temperature_omit_after_rejection and temperature_omit_reason is None:
1714
+ temperature_omit_reason = "provider_rejected_parameter"
1715
+ payload: dict[str, Any] = {
1716
+ "model": self.model,
1717
+ "max_tokens": effective_max_tokens,
1718
+ "messages": anthropic_messages,
1719
+ }
1720
+ if thinking_plan.config is not None:
1721
+ payload["thinking"] = copy.deepcopy(thinking_plan.config)
1722
+ if self._thinking_display_supported is False:
1723
+ payload = _without_thinking_display(payload)
1724
+ if thinking_plan.output_effort is not None:
1725
+ payload["output_config"] = {"effort": thinking_plan.output_effort}
1726
+ if temperature_omit_reason is None:
1727
+ payload["temperature"] = self.temperature if temperature is None else float(temperature)
1728
+ cache_control = plan.cache.anthropic_cache_control_payload()
1729
+ cache_policy = merge_cache_policy_metadata(
1730
+ self.prompt_cache_policy_metadata,
1731
+ plan.cache.anthropic_cache_policy_metadata(),
1732
+ )
1733
+ if stream:
1734
+ payload["stream"] = True
1735
+ if system:
1736
+ payload["system"] = system
1737
+ forced_tool_choice_omitted = False
1738
+ if tool_mapping.tools:
1739
+ payload["tools"] = tool_mapping.tools
1740
+ mapped_tool_choice = _anthropic_tool_choice(
1741
+ tool_choice,
1742
+ removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
1743
+ added_builtin_web_search=tool_mapping.added_builtin_web_search,
1744
+ )
1745
+ if mapped_tool_choice is not None:
1746
+ if thinking_plan.active and mapped_tool_choice.get("type") in {"any", "tool"}:
1747
+ # Anthropic rejects forced tool use while extended thinking
1748
+ # is active. Leaving the choice unset preserves automatic
1749
+ # tool selection without failing the entire turn.
1750
+ forced_tool_choice_omitted = True
1751
+ else:
1752
+ payload["tool_choice"] = mapped_tool_choice
1753
+ elif tool_choice is not None:
1754
+ raise LLMError("Anthropic Messages tool_choice requires at least one available tool")
1755
+
1756
+ _apply_anthropic_cache_control_plan(
1757
+ payload=payload,
1758
+ cache_control=cache_control,
1759
+ cache_policy=cache_policy,
1760
+ )
1761
+
1762
+ def _prompt_estimation_payload(current_payload: Mapping[str, Any]) -> dict[str, Any]:
1763
+ estimation_payload = {
1764
+ "messages": current_payload.get("messages", []),
1765
+ }
1766
+ for key in ("system", "tools", "cache_control"):
1767
+ if key in current_payload:
1768
+ estimation_payload[key] = current_payload[key]
1769
+ return estimation_payload
1770
+
1771
+ def _request_shape_metadata(
1772
+ current_payload: Mapping[str, Any],
1773
+ *,
1774
+ input_mode: str = "full",
1775
+ ) -> dict[str, Any]:
1776
+ return build_request_shape_report(
1777
+ messages=messages,
1778
+ tools=tools,
1779
+ cache_policy=cache_policy,
1780
+ provider_payload=_prompt_estimation_payload(current_payload),
1781
+ input_mode=input_mode,
1782
+ )
1783
+
1784
+ def _token_reconciliation_metadata(
1785
+ current_payload: Mapping[str, Any],
1786
+ *,
1787
+ input_mode: str = "full",
1788
+ ) -> dict[str, Any]:
1789
+ input_estimate_tokens = estimate_provider_payload_tokens(
1790
+ _prompt_estimation_payload(current_payload)
1791
+ )
1792
+ return {
1793
+ "input_estimate_tokens": input_estimate_tokens,
1794
+ "sent_input_estimate_tokens": input_estimate_tokens,
1795
+ "estimator": "cl100k_base",
1796
+ "estimate_basis": "provider_prompt_payload",
1797
+ "input_mode": input_mode,
1798
+ }
1799
+
1800
+ request_shape = _request_shape_metadata(payload)
1801
+ token_reconciliation = _token_reconciliation_metadata(payload)
1802
+ request_plan_extra: dict[str, Any] = {}
1803
+ if temperature_omit_reason is not None:
1804
+ request_plan_extra.update(
1805
+ {
1806
+ "temperature_omitted": True,
1807
+ "temperature_omit_reason": temperature_omit_reason,
1808
+ }
1809
+ )
1810
+ payload_thinking = payload.get("thinking")
1811
+ if isinstance(payload_thinking, Mapping):
1812
+ request_plan_extra.update(
1813
+ {
1814
+ "thinking_mode": payload_thinking.get("type"),
1815
+ "thinking_display": payload_thinking.get("display"),
1816
+ "reasoning_summary_requested": _payload_requests_summarized_thinking(payload),
1817
+ }
1818
+ )
1819
+ if forced_tool_choice_omitted:
1820
+ request_plan_extra.update(
1821
+ {
1822
+ "tool_choice_omitted": True,
1823
+ "tool_choice_omit_reason": "anthropic_extended_thinking_forced_tool_unsupported",
1824
+ }
1825
+ )
1826
+ request_plan_metadata = plan.request_plan_metadata(
1827
+ input_mode="full",
1828
+ continuation_strategy="full_replay",
1829
+ provider_payload=_prompt_estimation_payload(payload),
1830
+ sent_provider_payload=_prompt_estimation_payload(payload),
1831
+ cache_policy_metadata=cache_policy,
1832
+ extra=request_plan_extra or None,
1833
+ )
1834
+
1835
+ provider_key = self.provider_key or best_effort_provider_key(
1836
+ base_url=self.base_url,
1837
+ model=self.model,
1838
+ )
1839
+ telemetry = ProviderCallTelemetryRecorder(
1840
+ provider_key=provider_key,
1841
+ protocol="anthropic_messages",
1842
+ model=self.model,
1843
+ base_url=self.base_url,
1844
+ stream=stream,
1845
+ tools=tools,
1846
+ web_search_mode=self.web_search_mode,
1847
+ web_search_adapter=self.web_search_adapter,
1848
+ native_web_search=tool_mapping.added_builtin_web_search,
1849
+ cache_policy=cache_policy,
1850
+ request_plan=request_plan_metadata,
1851
+ request_shape=request_shape,
1852
+ token_reconciliation=token_reconciliation,
1853
+ operation="anthropic_messages_chat",
1854
+ )
1855
+ telemetry_on_text_delta = telemetry.wrap_text_delta(on_text_delta)
1856
+ telemetry_on_reasoning_delta = telemetry.wrap_reasoning_delta(on_reasoning_delta)
1857
+ public_output_emitted = False
1858
+
1859
+ def _tracked_text_delta(delta: str) -> None:
1860
+ nonlocal public_output_emitted
1861
+ if delta:
1862
+ public_output_emitted = True
1863
+ if telemetry_on_text_delta is not None:
1864
+ telemetry_on_text_delta(delta)
1865
+
1866
+ def _tracked_reasoning_delta(delta: str) -> None:
1867
+ nonlocal public_output_emitted
1868
+ if delta:
1869
+ public_output_emitted = True
1870
+ if telemetry_on_reasoning_delta is not None:
1871
+ telemetry_on_reasoning_delta(delta)
1872
+
1873
+ def _activate_reasoning_summary_fallback(
1874
+ current_payload: Mapping[str, Any],
1875
+ *,
1876
+ reason: str,
1877
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
1878
+ downgraded_payload = _without_thinking_display(current_payload)
1879
+ fallback_plan = plan.request_plan_metadata(
1880
+ input_mode="reasoning_summary_fallback",
1881
+ continuation_strategy="full_replay",
1882
+ provider_payload=_prompt_estimation_payload(downgraded_payload),
1883
+ sent_provider_payload=_prompt_estimation_payload(downgraded_payload),
1884
+ cache_policy_metadata=cache_policy,
1885
+ extra={
1886
+ "fallback_used": True,
1887
+ "thinking_display_omitted": True,
1888
+ "reasoning_summary_requested": False,
1889
+ "reasoning_summary_fallback_reason": reason,
1890
+ },
1891
+ )
1892
+ telemetry.set_request_plan(fallback_plan)
1893
+ telemetry.set_request_shape(
1894
+ _request_shape_metadata(
1895
+ downgraded_payload,
1896
+ input_mode="reasoning_summary_fallback",
1897
+ )
1898
+ )
1899
+ telemetry.set_token_reconciliation(
1900
+ _token_reconciliation_metadata(
1901
+ downgraded_payload,
1902
+ input_mode="reasoning_summary_fallback",
1903
+ )
1904
+ )
1905
+ return downgraded_payload, fallback_plan
1906
+
1907
+ def _send_request() -> LLMResponse:
1908
+ url = f"{self.base_url}/messages"
1909
+ try:
1910
+ with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
1911
+ request_payload = payload
1912
+ active_request_plan_metadata = request_plan_metadata
1913
+ if self._thinking_display_supported is False and (
1914
+ _payload_requests_summarized_thinking(request_payload)
1915
+ ):
1916
+ request_payload, active_request_plan_metadata = (
1917
+ _activate_reasoning_summary_fallback(
1918
+ request_payload,
1919
+ reason="cached_provider_rejection",
1920
+ )
1921
+ )
1922
+ cache_control_retry_used = False
1923
+ temperature_retry_used = False
1924
+ thinking_display_retry_used = False
1925
+ while True:
1926
+ if stream:
1927
+ with client.stream(
1928
+ "POST",
1929
+ url,
1930
+ headers=self._headers(),
1931
+ json=request_payload,
1932
+ ) as response:
1933
+ if response.status_code >= 400:
1934
+ response.read()
1935
+ thinking_display_rejection = (
1936
+ _thinking_display_rejection_reason(response)
1937
+ if not thinking_display_retry_used
1938
+ and _payload_requests_summarized_thinking(request_payload)
1939
+ else None
1940
+ )
1941
+ if thinking_display_rejection is not None:
1942
+ thinking_display_retry_used = True
1943
+ self._thinking_display_supported = False
1944
+ request_payload, active_request_plan_metadata = (
1945
+ _activate_reasoning_summary_fallback(
1946
+ request_payload,
1947
+ reason=thinking_display_rejection,
1948
+ )
1949
+ )
1950
+ continue
1951
+ temperature_rejection = (
1952
+ _temperature_rejection_reason(response)
1953
+ if not temperature_retry_used
1954
+ and "temperature" in request_payload
1955
+ else None
1956
+ )
1957
+ if temperature_rejection is not None:
1958
+ temperature_retry_used = True
1959
+ self._temperature_omit_after_rejection = True
1960
+ request_payload = copy.deepcopy(dict(request_payload))
1961
+ request_payload.pop("temperature", None)
1962
+ active_request_plan_metadata = plan.request_plan_metadata(
1963
+ input_mode="temperature_fallback",
1964
+ continuation_strategy="full_replay",
1965
+ provider_payload=_prompt_estimation_payload(
1966
+ request_payload
1967
+ ),
1968
+ sent_provider_payload=_prompt_estimation_payload(
1969
+ request_payload
1970
+ ),
1971
+ cache_policy_metadata=cache_policy,
1972
+ extra={
1973
+ "fallback_used": True,
1974
+ "temperature_omitted": True,
1975
+ "temperature_omit_reason": (temperature_rejection),
1976
+ },
1977
+ )
1978
+ telemetry.set_request_plan(active_request_plan_metadata)
1979
+ telemetry.set_request_shape(
1980
+ _request_shape_metadata(
1981
+ request_payload,
1982
+ input_mode="temperature_fallback",
1983
+ )
1984
+ )
1985
+ telemetry.set_token_reconciliation(
1986
+ _token_reconciliation_metadata(
1987
+ request_payload,
1988
+ input_mode="temperature_fallback",
1989
+ )
1990
+ )
1991
+ continue
1992
+ downgrade_reason = (
1993
+ _cache_control_rejection_reason(response)
1994
+ if not cache_control_retry_used
1995
+ and _payload_has_cache_control(request_payload)
1996
+ else None
1997
+ )
1998
+ if downgrade_reason is not None:
1999
+ cache_control_retry_used = True
2000
+ request_payload = (
2001
+ _downgrade_anthropic_cache_control_payload(
2002
+ request_payload
2003
+ )
2004
+ )
2005
+ _mark_anthropic_cache_control_downgrade(
2006
+ cache_policy,
2007
+ reason=downgrade_reason,
2008
+ )
2009
+ telemetry.set_cache_policy(cache_policy)
2010
+ active_request_plan_metadata = plan.request_plan_metadata(
2011
+ input_mode="cache_control_fallback",
2012
+ continuation_strategy="full_replay",
2013
+ provider_payload=_prompt_estimation_payload(
2014
+ request_payload
2015
+ ),
2016
+ sent_provider_payload=_prompt_estimation_payload(
2017
+ request_payload
2018
+ ),
2019
+ cache_policy_metadata=cache_policy,
2020
+ extra={"fallback_used": True},
2021
+ )
2022
+ telemetry.set_request_plan(active_request_plan_metadata)
2023
+ telemetry.set_request_shape(
2024
+ _request_shape_metadata(
2025
+ request_payload,
2026
+ input_mode="cache_control_fallback",
2027
+ )
2028
+ )
2029
+ telemetry.set_token_reconciliation(
2030
+ _token_reconciliation_metadata(
2031
+ request_payload,
2032
+ input_mode="cache_control_fallback",
2033
+ )
2034
+ )
2035
+ continue
2036
+ raise self._llm_error_from_response(response)
2037
+ return _response_with_cache_policy_metadata(
2038
+ self._parse_stream_response(
2039
+ response,
2040
+ on_text_delta=(
2041
+ _tracked_text_delta
2042
+ if telemetry_on_text_delta is not None
2043
+ else None
2044
+ ),
2045
+ on_reasoning_delta=(
2046
+ _tracked_reasoning_delta
2047
+ if telemetry_on_reasoning_delta is not None
2048
+ else None
2049
+ ),
2050
+ reasoning_is_summary=(
2051
+ _payload_requests_summarized_thinking(request_payload)
2052
+ ),
2053
+ ),
2054
+ cache_policy,
2055
+ active_request_plan_metadata,
2056
+ )
2057
+ response = client.post(
2058
+ url,
2059
+ headers=self._headers(),
2060
+ json=request_payload,
2061
+ )
2062
+ if response.status_code < 400:
2063
+ break
2064
+ thinking_display_rejection = (
2065
+ _thinking_display_rejection_reason(response)
2066
+ if not thinking_display_retry_used
2067
+ and _payload_requests_summarized_thinking(request_payload)
2068
+ else None
2069
+ )
2070
+ if thinking_display_rejection is not None:
2071
+ thinking_display_retry_used = True
2072
+ self._thinking_display_supported = False
2073
+ request_payload, active_request_plan_metadata = (
2074
+ _activate_reasoning_summary_fallback(
2075
+ request_payload,
2076
+ reason=thinking_display_rejection,
2077
+ )
2078
+ )
2079
+ continue
2080
+ temperature_rejection = (
2081
+ _temperature_rejection_reason(response)
2082
+ if not temperature_retry_used and "temperature" in request_payload
2083
+ else None
2084
+ )
2085
+ if temperature_rejection is not None:
2086
+ temperature_retry_used = True
2087
+ self._temperature_omit_after_rejection = True
2088
+ request_payload = copy.deepcopy(dict(request_payload))
2089
+ request_payload.pop("temperature", None)
2090
+ active_request_plan_metadata = plan.request_plan_metadata(
2091
+ input_mode="temperature_fallback",
2092
+ continuation_strategy="full_replay",
2093
+ provider_payload=_prompt_estimation_payload(request_payload),
2094
+ sent_provider_payload=_prompt_estimation_payload(request_payload),
2095
+ cache_policy_metadata=cache_policy,
2096
+ extra={
2097
+ "fallback_used": True,
2098
+ "temperature_omitted": True,
2099
+ "temperature_omit_reason": temperature_rejection,
2100
+ },
2101
+ )
2102
+ telemetry.set_request_plan(active_request_plan_metadata)
2103
+ telemetry.set_request_shape(
2104
+ _request_shape_metadata(
2105
+ request_payload,
2106
+ input_mode="temperature_fallback",
2107
+ )
2108
+ )
2109
+ telemetry.set_token_reconciliation(
2110
+ _token_reconciliation_metadata(
2111
+ request_payload,
2112
+ input_mode="temperature_fallback",
2113
+ )
2114
+ )
2115
+ continue
2116
+ downgrade_reason = (
2117
+ _cache_control_rejection_reason(response)
2118
+ if not cache_control_retry_used
2119
+ and _payload_has_cache_control(request_payload)
2120
+ else None
2121
+ )
2122
+ if downgrade_reason is not None:
2123
+ cache_control_retry_used = True
2124
+ request_payload = _downgrade_anthropic_cache_control_payload(
2125
+ request_payload
2126
+ )
2127
+ _mark_anthropic_cache_control_downgrade(
2128
+ cache_policy,
2129
+ reason=downgrade_reason,
2130
+ )
2131
+ telemetry.set_cache_policy(cache_policy)
2132
+ active_request_plan_metadata = plan.request_plan_metadata(
2133
+ input_mode="cache_control_fallback",
2134
+ continuation_strategy="full_replay",
2135
+ provider_payload=_prompt_estimation_payload(request_payload),
2136
+ sent_provider_payload=_prompt_estimation_payload(request_payload),
2137
+ cache_policy_metadata=cache_policy,
2138
+ extra={"fallback_used": True},
2139
+ )
2140
+ telemetry.set_request_plan(active_request_plan_metadata)
2141
+ telemetry.set_request_shape(
2142
+ _request_shape_metadata(
2143
+ request_payload,
2144
+ input_mode="cache_control_fallback",
2145
+ )
2146
+ )
2147
+ telemetry.set_token_reconciliation(
2148
+ _token_reconciliation_metadata(
2149
+ request_payload,
2150
+ input_mode="cache_control_fallback",
2151
+ )
2152
+ )
2153
+ continue
2154
+ break
2155
+ except httpx.DecodingError as e:
2156
+ err = LLMError(
2157
+ f"Anthropic Messages decompression failed: {sanitize_error_text_for_output(e)}"
2158
+ )
2159
+ if stream and public_output_emitted:
2160
+ mark_provider_call_non_retryable(err)
2161
+ raise err from e
2162
+ except Exception as e: # noqa: BLE001
2163
+ if isinstance(e, LLMError):
2164
+ if stream and public_output_emitted:
2165
+ mark_provider_call_non_retryable(e)
2166
+ raise
2167
+ err = LLMError(
2168
+ f"Anthropic Messages request failed: {sanitize_error_text_for_output(e)}"
2169
+ )
2170
+ if stream and public_output_emitted:
2171
+ mark_provider_call_non_retryable(err)
2172
+ raise err from e
2173
+ if response.status_code >= 400:
2174
+ raise self._llm_error_from_response(response)
2175
+ return _response_with_cache_policy_metadata(
2176
+ self._parse_chat_response(
2177
+ response,
2178
+ on_reasoning_delta=telemetry_on_reasoning_delta,
2179
+ reasoning_is_summary=_payload_requests_summarized_thinking(request_payload),
2180
+ ),
2181
+ cache_policy,
2182
+ active_request_plan_metadata,
2183
+ )
2184
+
2185
+ return stamp_response_for_route(
2186
+ telemetry.run(
2187
+ lambda: run_provider_limited_call(
2188
+ call=_send_request,
2189
+ provider_key=provider_key,
2190
+ provider_concurrency_caps=self.provider_concurrency_caps,
2191
+ retry_settings=self.provider_retry_settings,
2192
+ operation="anthropic_messages_chat",
2193
+ sleep_fn=self._provider_sleep_fn,
2194
+ random_fn=self._provider_random_fn,
2195
+ on_retry=telemetry.on_retry,
2196
+ on_retry_event=getattr(
2197
+ self,
2198
+ "_provider_retry_event_observer",
2199
+ None,
2200
+ ),
2201
+ retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
2202
+ )
2203
+ ),
2204
+ self.route_identity,
2205
+ )
2206
+
2207
+ @staticmethod
2208
+ def _parse_stream_response(
2209
+ response: httpx.Response,
2210
+ *,
2211
+ on_text_delta: Callable[[str], None] | None,
2212
+ on_reasoning_delta: Callable[[str], None] | None,
2213
+ reasoning_is_summary: bool,
2214
+ ) -> LLMResponse:
2215
+ accumulator = _AnthropicStreamAccumulator(
2216
+ on_text_delta=on_text_delta,
2217
+ on_reasoning_delta=on_reasoning_delta,
2218
+ reasoning_is_summary=reasoning_is_summary,
2219
+ )
2220
+ for frame in iter_sse_frames(response.iter_lines()):
2221
+ raw_event = parse_sse_json_frame(frame, stream_name="Anthropic Messages stream")
2222
+ if not isinstance(raw_event, dict):
2223
+ raise LLMError("Anthropic Messages stream emitted non-object JSON event")
2224
+ accumulator.handle(frame, raw_event)
2225
+ data = accumulator.finish()
2226
+ # Streamed thinking was already emitted chunk-by-chunk. Parsing the
2227
+ # accumulated message must not emit the same summary a second time.
2228
+ return AnthropicMessagesClient._parse_chat_response(
2229
+ response=_response_from_json(data),
2230
+ reasoning_is_summary=reasoning_is_summary,
2231
+ )
2232
+
2233
+ @staticmethod
2234
+ def _parse_chat_response(
2235
+ response: httpx.Response,
2236
+ *,
2237
+ reasoning_is_summary: bool,
2238
+ on_reasoning_delta: Callable[[str], None] | None = None,
2239
+ ) -> LLMResponse:
2240
+ try:
2241
+ data = response.json()
2242
+ except Exception as e: # noqa: BLE001
2243
+ raise LLMError("Anthropic Messages returned non-JSON response") from e
2244
+ if not isinstance(data, dict):
2245
+ raise LLMError("Unexpected Anthropic Messages payload: expected JSON object")
2246
+ content = data.get("content")
2247
+ if not isinstance(content, list):
2248
+ raise LLMError("Unexpected Anthropic Messages payload: missing content list")
2249
+
2250
+ stop_reason = str(data.get("stop_reason") or "").strip()
2251
+ if stop_reason == "refusal":
2252
+ raise LLMError("Anthropic Messages refusal")
2253
+
2254
+ for block in content:
2255
+ if isinstance(block, dict) and str(block.get("type") or "") == "refusal":
2256
+ text = str(block.get("text") or block.get("refusal") or "").strip()
2257
+ suffix = f": {text}" if text else ""
2258
+ raise LLMError(f"Anthropic Messages refusal{suffix}")
2259
+
2260
+ text = _text_from_content_blocks(content)
2261
+ tool_calls = _parse_tool_calls(content)
2262
+ if not text and not tool_calls:
2263
+ suffix = f" (stop_reason={stop_reason})" if stop_reason else ""
2264
+ raise LLMError(f"Anthropic Messages returned no assistant text or tool calls{suffix}")
2265
+
2266
+ summaries = _thinking_summaries_from_content_blocks(content) if reasoning_is_summary else []
2267
+ if on_reasoning_delta is not None:
2268
+ for summary in summaries:
2269
+ on_reasoning_delta(summary)
2270
+
2271
+ response_model = data.get("model") if isinstance(data.get("model"), str) else None
2272
+ reasoning = tuple(
2273
+ ReasoningOutput(
2274
+ text=summary,
2275
+ kind=ReasoningOutputKind.SUMMARY,
2276
+ provider="anthropic",
2277
+ )
2278
+ for summary in summaries
2279
+ )
2280
+ return LLMResponse(
2281
+ content=text,
2282
+ tool_calls=tool_calls,
2283
+ raw=data,
2284
+ response_model=response_model,
2285
+ usage=_parse_usage(data.get("usage")),
2286
+ provider_metadata=_anthropic_provider_metadata(data),
2287
+ reasoning=reasoning,
2288
+ )