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,2947 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import logging
6
+ import re
7
+ import threading
8
+ from collections.abc import Callable, Iterator, Mapping
9
+ from time import monotonic
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ..error_text import sanitize_error_text_for_output
15
+ from ..execution_deadline import DeadlineExhausted
16
+ from ..failure_category import provider_unavailable_retry_reason
17
+ from ..provider_telemetry import ProviderCallTelemetryRecorder
18
+ from ..provider_url import known_provider_key_from_base_url
19
+ from ..reasoning_contracts import (
20
+ ALWAYS_ON,
21
+ OFF_EXPLICIT,
22
+ OPTIONAL,
23
+ WIRE_CHAT_TEMPLATE_ENABLE_THINKING,
24
+ WIRE_REASONING_EFFORT,
25
+ WIRE_THINKING_LEVEL,
26
+ WIRE_THINKING_TYPE,
27
+ reasoning_contract_for,
28
+ )
29
+ from ..request_estimation import estimate_provider_payload_tokens
30
+ from ..run_provenance import active_sampling_settings, apply_sampling_to_payload
31
+ from .cache_capabilities import (
32
+ CACHE_CONTROL_FIELD,
33
+ OPENROUTER_SESSION_ID_FIELD,
34
+ OPENROUTER_SESSION_ID_HEADER_FIELD,
35
+ PROMPT_CACHE_KEY_FIELD,
36
+ PROMPT_CACHE_RETENTION_FIELD,
37
+ XAI_CONVERSATION_ID_HEADER_FIELD,
38
+ )
39
+ from .cache_control_blocks import (
40
+ apply_openai_compatible_cache_control_breakpoint,
41
+ count_cache_control_blocks,
42
+ strip_cache_control_blocks,
43
+ )
44
+ from .cache_policy import merge_cache_policy_metadata
45
+ from .metadata import (
46
+ DEEPSEEK_REASONING_CONTENT_KEY as _DEEPSEEK_REASONING_CONTENT_KEY,
47
+ )
48
+ from .metadata import MISTRAL_CONTENT_CHUNKS_KEY as _MISTRAL_CONTENT_CHUNKS_KEY
49
+ from .metadata import MISTRAL_PROVIDER_METADATA_KEY as _MISTRAL_PROVIDER_KEY
50
+ from .metadata import (
51
+ OPENROUTER_REASONING_DETAILS_KEY as _OPENROUTER_REASONING_DETAILS_KEY,
52
+ )
53
+ from .metadata import (
54
+ OPENROUTER_REASONING_KEY as _OPENROUTER_REASONING_KEY,
55
+ )
56
+ from .metadata import (
57
+ PROVIDER_METADATA_KEY,
58
+ QWEN_PROVIDER_METADATA_KEY,
59
+ ProviderRouteIdentity,
60
+ build_provider_route_identity,
61
+ canonicalize_extra_headers,
62
+ credential_scope_fingerprint,
63
+ endpoint_descriptor,
64
+ endpoint_label,
65
+ gate_messages_for_provider_route,
66
+ merge_canonical_headers,
67
+ stamp_response_for_route,
68
+ strip_provider_metadata_from_message,
69
+ )
70
+ from .metadata import (
71
+ TOOL_CALL_PROVIDER_METADATA_KEY as _TOOL_CALL_PROVIDER_METADATA_KEY,
72
+ )
73
+ from .metadata import (
74
+ assistant_message_from_response as assistant_message_from_response,
75
+ )
76
+ from .metadata import (
77
+ attach_provider_metadata_to_assistant_message as attach_provider_metadata_to_assistant_message,
78
+ )
79
+ from .metadata import (
80
+ merge_provider_metadata as _merge_provider_metadata,
81
+ )
82
+ from .protocols import (
83
+ OPENAI_COMPAT_PROTOCOL,
84
+ validate_reasoning_trace_adapter_for_protocol,
85
+ )
86
+ from .provider_limits import (
87
+ DEFAULT_PROVIDER_CONCURRENCY_CAPS,
88
+ ProviderRetrySettings,
89
+ best_effort_provider_key,
90
+ mark_provider_call_non_retryable,
91
+ run_provider_limited_call,
92
+ )
93
+ from .request_plan import LLMRequestPlan, RequestCachePlan
94
+ from .request_shape import build_request_shape_report
95
+ from .temperature_compat import documented_temperature_omit_reason
96
+ from .types import (
97
+ InputTokenCount,
98
+ LLMError,
99
+ LLMResponse,
100
+ LLMStreamNoProgressError,
101
+ LLMUsage,
102
+ ReasoningOutput,
103
+ ReasoningOutputKind,
104
+ ToolCall,
105
+ UsageConfidence,
106
+ UsageContract,
107
+ UsageSource,
108
+ )
109
+ from .usage_normalization import parse_compatible_usage
110
+
111
+ _TEXT_LIKE_CONTENT_PART_TYPES = {"text", "output_text"}
112
+ _DEEPSEEK_PROVIDER_KEY = "deepseek"
113
+ _OPENROUTER_PROVIDER_KEY = "openrouter"
114
+ _QWEN_PROVIDER_KEY = QWEN_PROVIDER_METADATA_KEY
115
+ _GEMINI_PROVIDER_KEY = "gemini"
116
+ _MOONSHOT_PROVIDER_KEY = "moonshot"
117
+ _NVIDIA_PROVIDER_KEY = "nvidia"
118
+ _TOGETHER_PROVIDER_KEY = "together"
119
+ _GEMINI_EXTRA_CONTENT_KEY = "extra_content"
120
+ _OPENAI_STYLE_REASONING_EFFORT_PROVIDERS = frozenset({"openai", "azure", "mistral"})
121
+ _REASONING_PROVIDER_BY_ADAPTER: dict[str, str] = {
122
+ "deepseek_reasoning": _DEEPSEEK_PROVIDER_KEY,
123
+ "openrouter_reasoning": _OPENROUTER_PROVIDER_KEY,
124
+ "dashscope_thinking": _QWEN_PROVIDER_KEY,
125
+ "mistral_thinking": _MISTRAL_PROVIDER_KEY,
126
+ "moonshot_reasoning": _MOONSHOT_PROVIDER_KEY,
127
+ "nvidia_reasoning": _NVIDIA_PROVIDER_KEY,
128
+ }
129
+ _GEMINI_REASONING_EFFORTS = frozenset({"minimal", "low", "medium", "high"})
130
+ _DEFAULT_ACCEPT_ENCODING = "identity"
131
+ _DEFAULT_CONNECT_TIMEOUT_S = 2.0
132
+ _LOGGER = logging.getLogger(__name__)
133
+ _TEMPERATURE_DEFAULT_VALUE = 1.0
134
+ _TEMPERATURE_COMPAT_MODE_DEFAULT = "default_temperature"
135
+ _TEMPERATURE_COMPAT_MODE_OMIT = "omit_temperature"
136
+ _TEMPERATURE_COMPAT_MODES = {
137
+ _TEMPERATURE_COMPAT_MODE_DEFAULT,
138
+ _TEMPERATURE_COMPAT_MODE_OMIT,
139
+ }
140
+ _TEMPERATURE_UNSUPPORTED_STATUS_CODES = {400, 422}
141
+ _TEMPERATURE_UNSUPPORTED_TOKENS = (
142
+ "allowed",
143
+ "greater than",
144
+ "invalid",
145
+ "unsupported",
146
+ "not support",
147
+ "not supported",
148
+ "not allowed",
149
+ "out of range",
150
+ "range",
151
+ "deprecated",
152
+ "only the default",
153
+ "cannot be set",
154
+ "must be omitted",
155
+ )
156
+ _CACHE_PARAM_UNSUPPORTED_STATUS_CODES = {400, 422}
157
+ _CACHE_PARAM_UNSUPPORTED_TOKENS = (
158
+ "invalid",
159
+ "unsupported",
160
+ "not support",
161
+ "not supported",
162
+ "not allowed",
163
+ "unknown",
164
+ "unrecognized",
165
+ "unexpected",
166
+ "extra",
167
+ "cannot be set",
168
+ "must be omitted",
169
+ "forbidden",
170
+ )
171
+
172
+
173
+ _CACHE_BODY_FIELDS = (
174
+ PROMPT_CACHE_KEY_FIELD,
175
+ PROMPT_CACHE_RETENTION_FIELD,
176
+ CACHE_CONTROL_FIELD,
177
+ OPENROUTER_SESSION_ID_FIELD,
178
+ )
179
+ _CACHE_HEADER_FIELDS = (
180
+ OPENROUTER_SESSION_ID_HEADER_FIELD,
181
+ XAI_CONVERSATION_ID_HEADER_FIELD,
182
+ )
183
+ _PROMPT_CACHE_FIELDS = (*_CACHE_BODY_FIELDS, *_CACHE_HEADER_FIELDS)
184
+ _TOOL_CHOICE_UNSUPPORTED_STATUS_CODES = {400, 422}
185
+ _TOOL_CHOICE_UNSUPPORTED_TOKENS = (
186
+ "invalid",
187
+ "not allowed",
188
+ "not support",
189
+ "not supported",
190
+ "unsupported",
191
+ "unknown",
192
+ "unrecognized",
193
+ "unexpected",
194
+ )
195
+ _TOOL_CALLING_REJECTION_PARAMS = frozenset({"tool", "tools", "function", "functions"})
196
+ _TOOL_CALLING_REJECTION_TERMS = (
197
+ "tool",
198
+ "tools",
199
+ "function",
200
+ "functions",
201
+ "function calling",
202
+ "function_call",
203
+ "model",
204
+ )
205
+ _PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS: float | None = None
206
+ _ERROR_BODY_DISPLAY_LIMIT = 1000
207
+
208
+
209
+ def _headers_with_default_accept_encoding(headers: dict[str, str]) -> dict[str, str]:
210
+ request_headers = dict(headers)
211
+ if not any(key.lower() == "accept-encoding" for key in request_headers):
212
+ request_headers["accept-encoding"] = _DEFAULT_ACCEPT_ENCODING
213
+ return request_headers
214
+
215
+
216
+ def _httpx_request_timeout(timeout_s: float) -> httpx.Timeout:
217
+ request_timeout = max(float(timeout_s), 0.001)
218
+ connect_timeout = min(request_timeout, _DEFAULT_CONNECT_TIMEOUT_S)
219
+ return httpx.Timeout(request_timeout, connect=connect_timeout)
220
+
221
+
222
+ def _iter_exception_chain(exc: BaseException) -> Iterator[BaseException]:
223
+ current: BaseException | None = exc
224
+ seen: set[int] = set()
225
+ while current is not None and id(current) not in seen:
226
+ seen.add(id(current))
227
+ yield current
228
+ cause = current.__cause__
229
+ context = current.__context__
230
+ current = cause if cause is not None else context
231
+
232
+
233
+ def _is_connect_failure(exc: BaseException) -> bool:
234
+ return any(
235
+ isinstance(item, httpx.ConnectError | httpx.ConnectTimeout)
236
+ for item in _iter_exception_chain(exc)
237
+ )
238
+
239
+
240
+ def _is_read_timeout(exc: BaseException) -> bool:
241
+ return any(isinstance(item, httpx.ReadTimeout) for item in _iter_exception_chain(exc))
242
+
243
+
244
+ def _common_prefix_length(left: str, right: str) -> int:
245
+ limit = min(len(left), len(right))
246
+ idx = 0
247
+ while idx < limit and left[idx] == right[idx]:
248
+ idx += 1
249
+ return idx
250
+
251
+
252
+ def _suffix_prefix_overlap_length(left: str, right: str) -> int:
253
+ limit = min(len(left), len(right))
254
+ for size in range(limit, 0, -1):
255
+ if left[-size:] == right[:size]:
256
+ return size
257
+ return 0
258
+
259
+
260
+ def _strip_cumulative_restart_suffix(*, previous: str, incoming: str) -> str | None:
261
+ candidate = incoming
262
+ while candidate:
263
+ trimmed = candidate.lstrip("\r\n ")
264
+ if trimmed.startswith(previous):
265
+ remainder = trimmed[len(previous) :]
266
+ trimmed_remainder = remainder.lstrip("\r\n ")
267
+ if trimmed_remainder.startswith(previous):
268
+ candidate = trimmed_remainder
269
+ continue
270
+ return remainder
271
+ if trimmed != candidate:
272
+ candidate = trimmed
273
+ continue
274
+ break
275
+ return None
276
+
277
+
278
+ def _looks_like_alternate_cumulative_restart(*, previous: str, incoming: str) -> bool:
279
+ if len(previous) < 120 or len(incoming) < 80:
280
+ return False
281
+ if incoming.startswith(previous) or previous.startswith(incoming):
282
+ return False
283
+ common_prefix = _common_prefix_length(previous, incoming)
284
+ if common_prefix < 24:
285
+ return False
286
+ return common_prefix < (min(len(previous), len(incoming)) // 2)
287
+
288
+
289
+ def _stream_delta_suffix(*, previous: str, incoming: str) -> str:
290
+ if not incoming:
291
+ return ""
292
+ if not previous:
293
+ return incoming
294
+ if incoming == previous:
295
+ return ""
296
+ if _looks_like_alternate_cumulative_restart(previous=previous, incoming=incoming):
297
+ return ""
298
+ cumulative_suffix = _strip_cumulative_restart_suffix(previous=previous, incoming=incoming)
299
+ if cumulative_suffix is not None:
300
+ return cumulative_suffix
301
+ common_prefix = _common_prefix_length(previous, incoming)
302
+ if common_prefix >= max(16, min(len(previous), len(incoming)) // 2):
303
+ return incoming[common_prefix:]
304
+ previous_restart = incoming.rfind(previous)
305
+ if previous_restart > 0:
306
+ prefix = incoming[:previous_restart]
307
+ if not prefix.strip():
308
+ return incoming[previous_restart + len(previous) :]
309
+ overlap = _suffix_prefix_overlap_length(previous, incoming)
310
+ if overlap > 0:
311
+ restarted_suffix = _strip_cumulative_restart_suffix(
312
+ previous=previous,
313
+ incoming=incoming[overlap:],
314
+ )
315
+ if restarted_suffix is not None:
316
+ return restarted_suffix
317
+ if overlap >= max(4, len(incoming) // 2):
318
+ return incoming[overlap:]
319
+ return incoming
320
+
321
+
322
+ def _sanitize_transport_text(text: str) -> str:
323
+ if not text:
324
+ return text
325
+ if not any(0xD800 <= ord(ch) <= 0xDFFF for ch in text):
326
+ return text
327
+ try:
328
+ # Recover surrogate-escaped terminal bytes when possible, and replace
329
+ # genuinely invalid sequences so JSON transport never crashes.
330
+ return text.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace")
331
+ except Exception:
332
+ return text.encode("utf-8", errors="replace").decode("utf-8")
333
+
334
+
335
+ def _sanitize_transport_value(value: Any) -> Any:
336
+ if isinstance(value, str):
337
+ return _sanitize_transport_text(value)
338
+ if isinstance(value, list):
339
+ return [_sanitize_transport_value(item) for item in value]
340
+ if isinstance(value, tuple):
341
+ return tuple(_sanitize_transport_value(item) for item in value)
342
+ if isinstance(value, dict):
343
+ return {
344
+ _sanitize_transport_text(key)
345
+ if isinstance(key, str)
346
+ else key: _sanitize_transport_value(item)
347
+ for key, item in value.items()
348
+ }
349
+ return value
350
+
351
+
352
+ def _normalize_provider_key(provider_key: str | None) -> str:
353
+ normalized = str(provider_key or "").strip().casefold()
354
+ return "".join(char if char.isalnum() else "_" for char in normalized).strip("_")
355
+
356
+
357
+ def _provider_key_from_base_url(base_url: str | None) -> str | None:
358
+ return known_provider_key_from_base_url(base_url)
359
+
360
+
361
+ def _transport_provider_key(
362
+ *,
363
+ base_url: str | None,
364
+ provider_key: str | None,
365
+ model: str | None,
366
+ ) -> str:
367
+ from_url = _provider_key_from_base_url(base_url)
368
+ if from_url:
369
+ return from_url
370
+ normalized_provider = _normalize_provider_key(provider_key)
371
+ if normalized_provider:
372
+ if normalized_provider in {"dashscope", "qwen", "aliyun", "aliyuncs"}:
373
+ return "qwen"
374
+ return normalized_provider
375
+ return _normalize_provider_key(best_effort_provider_key(base_url=base_url, model=model))
376
+
377
+
378
+ def _reasoning_transport_provider_key(
379
+ *,
380
+ transport_provider_key: str | None,
381
+ reasoning_trace_adapter: str | None,
382
+ ) -> str | None:
383
+ """Resolve only the provider dialect used for reasoning state on the wire.
384
+
385
+ Automatic selection preserves the existing provider inference. Explicit
386
+ adapters are authoritative for custom OpenAI-compatible endpoints, while
387
+ ``none`` and the passive adapter deliberately inject and replay nothing.
388
+ """
389
+
390
+ adapter = validate_reasoning_trace_adapter_for_protocol(
391
+ protocol=OPENAI_COMPAT_PROTOCOL,
392
+ adapter=reasoning_trace_adapter,
393
+ )
394
+ if adapter == "auto":
395
+ return _normalize_provider_key(transport_provider_key) or None
396
+ return _REASONING_PROVIDER_BY_ADAPTER.get(adapter)
397
+
398
+
399
+ def _is_deepseek_provider(provider_key: str | None) -> bool:
400
+ return _normalize_provider_key(provider_key) == _DEEPSEEK_PROVIDER_KEY
401
+
402
+
403
+ def _is_openrouter_provider(provider_key: str | None) -> bool:
404
+ return _normalize_provider_key(provider_key) == _OPENROUTER_PROVIDER_KEY
405
+
406
+
407
+ def _is_gemini_provider(provider_key: str | None) -> bool:
408
+ return _normalize_provider_key(provider_key) == _GEMINI_PROVIDER_KEY
409
+
410
+
411
+ def _is_dashscope_provider(provider_key: str | None) -> bool:
412
+ return _normalize_provider_key(provider_key) in {"qwen", "dashscope"}
413
+
414
+
415
+ def _is_mistral_provider(provider_key: str | None) -> bool:
416
+ return _normalize_provider_key(provider_key) == _MISTRAL_PROVIDER_KEY
417
+
418
+
419
+ def _is_moonshot_provider(provider_key: str | None) -> bool:
420
+ return _normalize_provider_key(provider_key) in {_MOONSHOT_PROVIDER_KEY, "kimi_code"}
421
+
422
+
423
+ def _is_nvidia_provider(provider_key: str | None) -> bool:
424
+ return _normalize_provider_key(provider_key) == _NVIDIA_PROVIDER_KEY
425
+
426
+
427
+ def _is_together_deepseek_pro(provider_key: str | None, model: str | None) -> bool:
428
+ return _normalize_provider_key(provider_key) == _TOGETHER_PROVIDER_KEY and str(
429
+ model or ""
430
+ ).strip().casefold().startswith("deepseek-ai/deepseek-v4-pro")
431
+
432
+
433
+ def _uses_reasoning_effort(provider_key: str | None) -> bool:
434
+ return _normalize_provider_key(provider_key) in _OPENAI_STYLE_REASONING_EFFORT_PROVIDERS
435
+
436
+
437
+ def _model_name_parts(model: str | None) -> set[str]:
438
+ normalized = str(model or "").strip().casefold()
439
+ return {part for part in re.split(r"[^a-z0-9]+", normalized) if part}
440
+
441
+
442
+ def _gemini_model_allows_none_reasoning_effort(model: str | None) -> bool:
443
+ parts = _model_name_parts(model)
444
+ if "gemini" not in parts or "2" not in parts or "5" not in parts:
445
+ return False
446
+ if "pro" in parts:
447
+ return False
448
+ return "flash" in parts
449
+
450
+
451
+ def _gemini_reasoning_effort(
452
+ *,
453
+ model: str | None,
454
+ reasoning_effort: str | None,
455
+ ) -> str | None:
456
+ effort = str(reasoning_effort or "").strip().casefold()
457
+ if not effort:
458
+ return None
459
+ contract = reasoning_contract_for("gemini", model)
460
+ if contract.wire == WIRE_THINKING_LEVEL:
461
+ if contract.allows_value(effort):
462
+ return effort
463
+ return contract.default or None
464
+ if effort in _GEMINI_REASONING_EFFORTS:
465
+ return effort
466
+ if effort == "none" and _gemini_model_allows_none_reasoning_effort(model):
467
+ return effort
468
+ return None
469
+
470
+
471
+ def _reasoning_effort_enables_thinking(reasoning_effort: str | None) -> bool | None:
472
+ normalized = _normalize_provider_key(reasoning_effort)
473
+ if not normalized:
474
+ return None
475
+ return normalized != "none"
476
+
477
+
478
+ def _documented_reasoning_effort(
479
+ *,
480
+ provider_key: str | None,
481
+ model: str | None,
482
+ reasoning_effort: str | None,
483
+ ) -> str | None:
484
+ """Return an exact provider-documented effort value, never a guessed alias."""
485
+
486
+ normalized = str(reasoning_effort or "").strip().casefold()
487
+ if not normalized or normalized == "none":
488
+ return None
489
+ contract = reasoning_contract_for(provider_key, model)
490
+ if contract.wire != WIRE_REASONING_EFFORT or not contract.allows_value(normalized):
491
+ return None
492
+ return normalized
493
+
494
+
495
+ def _documented_flat_reasoning_effort(
496
+ *,
497
+ provider_key: str | None,
498
+ model: str | None,
499
+ enable_thinking: bool | None,
500
+ reasoning_effort: str | None,
501
+ ) -> str | None:
502
+ """Return the contract-approved flat value, including an explicit off value.
503
+
504
+ ``_documented_reasoning_effort`` intentionally treats ``none`` as omission
505
+ for transports with a separate thinking toggle. Generic Chat Completions
506
+ providers may instead document ``reasoning_effort='none'`` as their only
507
+ off wire shape, so this path preserves that structural distinction.
508
+ """
509
+
510
+ contract = reasoning_contract_for(provider_key, model)
511
+ if not contract.emits_flat_reasoning_effort or contract.wire != WIRE_REASONING_EFFORT:
512
+ return None
513
+ if enable_thinking is False:
514
+ return "none" if contract.off == OFF_EXPLICIT and contract.allows_value("none") else None
515
+ normalized = str(reasoning_effort or "").strip().casefold()
516
+ if not normalized:
517
+ return None
518
+ if normalized == "none" and enable_thinking is True:
519
+ return None
520
+ return normalized if contract.allows_value(normalized) else None
521
+
522
+
523
+ def _deepseek_reasoning_payload_enabled(
524
+ *,
525
+ enable_thinking: bool | None,
526
+ reasoning_effort: str | None,
527
+ ) -> bool | None:
528
+ if enable_thinking is not None:
529
+ return enable_thinking
530
+ return _reasoning_effort_enables_thinking(reasoning_effort)
531
+
532
+
533
+ def _reasoning_contract_active(
534
+ *,
535
+ provider_key: str | None,
536
+ model: str | None,
537
+ enable_thinking: bool | None,
538
+ reasoning_effort: str | None,
539
+ ) -> bool | None:
540
+ """Resolve reasoning activity from an explicit per-model wire contract.
541
+
542
+ ``None`` means that the catalog does not know the model's default. This is
543
+ intentionally tri-state so an unknown OpenAI-compatible route never gains
544
+ provider behavior from a name guess.
545
+ """
546
+
547
+ contract = reasoning_contract_for(provider_key, model)
548
+ if contract.mode not in {ALWAYS_ON, OPTIONAL}:
549
+ return None
550
+ if contract.mode == ALWAYS_ON:
551
+ return True
552
+ if enable_thinking is not None:
553
+ return enable_thinking
554
+ normalized_effort = str(reasoning_effort or "").strip().casefold()
555
+ if normalized_effort:
556
+ return normalized_effort != "none"
557
+ if contract.default:
558
+ return contract.default.casefold() != "none"
559
+ return None
560
+
561
+
562
+ def _openrouter_reasoning_payload(
563
+ *,
564
+ enable_thinking: bool | None,
565
+ reasoning_effort: str | None,
566
+ ) -> dict[str, Any] | None:
567
+ normalized_effort = str(reasoning_effort or "").strip().lower()
568
+ if normalized_effort:
569
+ return {"effort": normalized_effort}
570
+ if enable_thinking is True:
571
+ return {"enabled": True}
572
+ if enable_thinking is False:
573
+ return {"enabled": False}
574
+ return None
575
+
576
+
577
+ def _tool_choice_forces_a_call(tool_choice: Any) -> bool:
578
+ """Whether ``tool_choice`` compels the model to emit a tool call.
579
+
580
+ A specific-function object (``{"type": "function", ...}``) or the strings
581
+ ``"required"`` / ``"any"`` force a call. ``"auto"`` / ``"none"`` / unset do
582
+ not. Reasoning providers (DeepSeek, OpenRouter/MiMo, DashScope/Qwen, Zhipu
583
+ GLM) reject a *forced* choice while thinking is on -- the API returns
584
+ ``400 "Thinking mode does not support this tool_choice"`` -- so the caller
585
+ omits ``tool_choice`` when the request runs in thinking mode.
586
+ """
587
+ if isinstance(tool_choice, dict):
588
+ return True
589
+ if isinstance(tool_choice, str):
590
+ return tool_choice.strip().lower() in {"required", "any"}
591
+ return False
592
+
593
+
594
+ def _deepseek_reasoning_provider_metadata(reasoning_content: str) -> dict[str, Any] | None:
595
+ reasoning = str(reasoning_content or "")
596
+ if not reasoning:
597
+ return None
598
+ return {
599
+ _DEEPSEEK_PROVIDER_KEY: {
600
+ _DEEPSEEK_REASONING_CONTENT_KEY: reasoning,
601
+ }
602
+ }
603
+
604
+
605
+ def _qwen_reasoning_provider_metadata(reasoning_content: str) -> dict[str, Any] | None:
606
+ reasoning = str(reasoning_content or "")
607
+ if not reasoning:
608
+ return None
609
+ return {
610
+ _QWEN_PROVIDER_KEY: {
611
+ _DEEPSEEK_REASONING_CONTENT_KEY: reasoning,
612
+ }
613
+ }
614
+
615
+
616
+ def _moonshot_reasoning_provider_metadata(reasoning_content: str) -> dict[str, Any] | None:
617
+ reasoning = str(reasoning_content or "")
618
+ if not reasoning:
619
+ return None
620
+ return {
621
+ _MOONSHOT_PROVIDER_KEY: {
622
+ _DEEPSEEK_REASONING_CONTENT_KEY: reasoning,
623
+ }
624
+ }
625
+
626
+
627
+ def _nvidia_reasoning_provider_metadata(reasoning_content: str) -> dict[str, Any] | None:
628
+ reasoning = str(reasoning_content or "")
629
+ if not reasoning:
630
+ return None
631
+ return {
632
+ _NVIDIA_PROVIDER_KEY: {
633
+ _DEEPSEEK_REASONING_CONTENT_KEY: reasoning,
634
+ }
635
+ }
636
+
637
+
638
+ def _is_mistral_thinking_chunk(value: Any) -> bool:
639
+ return isinstance(value, dict) and str(value.get("type") or "").casefold() == "thinking"
640
+
641
+
642
+ def _mistral_content_provider_metadata(content: Any) -> dict[str, Any] | None:
643
+ if not isinstance(content, list) or not any(
644
+ _is_mistral_thinking_chunk(chunk) for chunk in content
645
+ ):
646
+ return None
647
+ return {
648
+ _MISTRAL_PROVIDER_KEY: {
649
+ _MISTRAL_CONTENT_CHUNKS_KEY: copy.deepcopy(content),
650
+ }
651
+ }
652
+
653
+
654
+ def _openrouter_reasoning_provider_metadata(
655
+ *,
656
+ reasoning: str | None = None,
657
+ reasoning_details: Any = None,
658
+ ) -> dict[str, Any] | None:
659
+ payload: dict[str, Any] = {}
660
+ reasoning_text = str(reasoning or "")
661
+ if reasoning_text:
662
+ payload[_OPENROUTER_REASONING_KEY] = reasoning_text
663
+ if isinstance(reasoning_details, list) and reasoning_details:
664
+ payload[_OPENROUTER_REASONING_DETAILS_KEY] = reasoning_details
665
+ if not payload:
666
+ return None
667
+ return {_OPENROUTER_PROVIDER_KEY: payload}
668
+
669
+
670
+ def _text_from_reasoning_detail(detail: dict[str, Any]) -> tuple[str, ReasoningOutputKind] | None:
671
+ detail_type = str(detail.get("type") or "").strip().lower()
672
+ if detail_type == "reasoning.summary":
673
+ value = detail.get("summary")
674
+ kind = ReasoningOutputKind.SUMMARY
675
+ elif detail_type == "reasoning.text":
676
+ value = detail.get("text")
677
+ kind = ReasoningOutputKind.PROVIDER_REASONING
678
+ else:
679
+ return None
680
+ if not isinstance(value, str) or not value.strip():
681
+ return None
682
+ return value, kind
683
+
684
+
685
+ def _reasoning_outputs_from_message(
686
+ message: dict[str, Any],
687
+ *,
688
+ provider_key: str | None,
689
+ ) -> tuple[ReasoningOutput, ...]:
690
+ provider = _normalize_provider_key(provider_key) or None
691
+ outputs: list[ReasoningOutput] = []
692
+ seen: set[tuple[ReasoningOutputKind, str]] = set()
693
+
694
+ details = message.get(_OPENROUTER_REASONING_DETAILS_KEY)
695
+ if isinstance(details, list):
696
+ for detail in details:
697
+ parsed = _text_from_reasoning_detail(detail) if isinstance(detail, dict) else None
698
+ if parsed is None:
699
+ continue
700
+ text, kind = parsed
701
+ if kind != ReasoningOutputKind.SUMMARY:
702
+ continue
703
+ dedupe_key = (kind, text)
704
+ if dedupe_key in seen:
705
+ continue
706
+ seen.add(dedupe_key)
707
+ outputs.append(ReasoningOutput(text=text, kind=kind, provider=provider))
708
+ return tuple(outputs)
709
+
710
+
711
+ def _provider_metadata_for_reasoning(
712
+ *,
713
+ provider_key: str | None,
714
+ message: dict[str, Any],
715
+ model: str | None = None,
716
+ ) -> dict[str, Any] | None:
717
+ if _is_together_deepseek_pro(provider_key, model):
718
+ reasoning = message.get(_OPENROUTER_REASONING_KEY)
719
+ if not isinstance(reasoning, str):
720
+ # Together accepts the older reasoning_content spelling on input,
721
+ # so tolerate it in compatible proxy responses as well.
722
+ reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
723
+ return _deepseek_reasoning_provider_metadata(
724
+ reasoning if isinstance(reasoning, str) else ""
725
+ )
726
+ if _is_deepseek_provider(provider_key):
727
+ reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
728
+ return _deepseek_reasoning_provider_metadata(
729
+ reasoning if isinstance(reasoning, str) else ""
730
+ )
731
+ if _is_dashscope_provider(provider_key):
732
+ reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
733
+ return _qwen_reasoning_provider_metadata(reasoning if isinstance(reasoning, str) else "")
734
+ if _is_moonshot_provider(provider_key):
735
+ reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
736
+ return _moonshot_reasoning_provider_metadata(
737
+ reasoning if isinstance(reasoning, str) else ""
738
+ )
739
+ if _is_nvidia_provider(provider_key):
740
+ reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
741
+ return _nvidia_reasoning_provider_metadata(reasoning if isinstance(reasoning, str) else "")
742
+ if _is_openrouter_provider(provider_key):
743
+ reasoning = message.get(_OPENROUTER_REASONING_KEY)
744
+ reasoning_details = message.get(_OPENROUTER_REASONING_DETAILS_KEY)
745
+ return _openrouter_reasoning_provider_metadata(
746
+ reasoning=reasoning if isinstance(reasoning, str) else None,
747
+ reasoning_details=reasoning_details,
748
+ )
749
+ if _is_mistral_provider(provider_key):
750
+ return _mistral_content_provider_metadata(message.get("content"))
751
+ return None
752
+
753
+
754
+ def _deepseek_reasoning_from_provider_metadata(metadata: Any) -> str:
755
+ if not isinstance(metadata, dict):
756
+ return ""
757
+ deepseek = metadata.get(_DEEPSEEK_PROVIDER_KEY)
758
+ if not isinstance(deepseek, dict):
759
+ return ""
760
+ reasoning = deepseek.get(_DEEPSEEK_REASONING_CONTENT_KEY)
761
+ return reasoning if isinstance(reasoning, str) else ""
762
+
763
+
764
+ def _openrouter_reasoning_from_provider_metadata(metadata: Any) -> tuple[str, list[Any] | None]:
765
+ if not isinstance(metadata, dict):
766
+ return "", None
767
+ openrouter = metadata.get(_OPENROUTER_PROVIDER_KEY)
768
+ if not isinstance(openrouter, dict):
769
+ return "", None
770
+ reasoning = openrouter.get(_OPENROUTER_REASONING_KEY)
771
+ reasoning_details = openrouter.get(_OPENROUTER_REASONING_DETAILS_KEY)
772
+ return (
773
+ reasoning if isinstance(reasoning, str) else "",
774
+ list(reasoning_details) if isinstance(reasoning_details, list) else None,
775
+ )
776
+
777
+
778
+ def _qwen_reasoning_from_provider_metadata(metadata: Any) -> str:
779
+ if not isinstance(metadata, dict):
780
+ return ""
781
+ qwen = metadata.get(_QWEN_PROVIDER_KEY)
782
+ if not isinstance(qwen, dict):
783
+ return ""
784
+ reasoning = qwen.get(_DEEPSEEK_REASONING_CONTENT_KEY)
785
+ return reasoning if isinstance(reasoning, str) else ""
786
+
787
+
788
+ def _moonshot_reasoning_from_provider_metadata(metadata: Any) -> str:
789
+ if not isinstance(metadata, dict):
790
+ return ""
791
+ moonshot = metadata.get(_MOONSHOT_PROVIDER_KEY)
792
+ if not isinstance(moonshot, dict):
793
+ return ""
794
+ reasoning = moonshot.get(_DEEPSEEK_REASONING_CONTENT_KEY)
795
+ return reasoning if isinstance(reasoning, str) else ""
796
+
797
+
798
+ def _nvidia_reasoning_from_provider_metadata(metadata: Any) -> str:
799
+ if not isinstance(metadata, dict):
800
+ return ""
801
+ nvidia = metadata.get(_NVIDIA_PROVIDER_KEY)
802
+ if not isinstance(nvidia, dict):
803
+ return ""
804
+ reasoning = nvidia.get(_DEEPSEEK_REASONING_CONTENT_KEY)
805
+ return reasoning if isinstance(reasoning, str) else ""
806
+
807
+
808
+ def _mistral_content_from_provider_metadata(metadata: Any) -> list[Any] | None:
809
+ if not isinstance(metadata, dict):
810
+ return None
811
+ mistral = metadata.get(_MISTRAL_PROVIDER_KEY)
812
+ if not isinstance(mistral, dict):
813
+ return None
814
+ content = mistral.get(_MISTRAL_CONTENT_CHUNKS_KEY)
815
+ if not isinstance(content, list) or not any(
816
+ _is_mistral_thinking_chunk(chunk) for chunk in content
817
+ ):
818
+ return None
819
+ return copy.deepcopy(content)
820
+
821
+
822
+ def _gemini_tool_call_provider_metadata(tool_call: dict[str, Any]) -> dict[str, Any] | None:
823
+ extra_content = tool_call.get(_GEMINI_EXTRA_CONTENT_KEY)
824
+ if not isinstance(extra_content, dict) or not extra_content:
825
+ return None
826
+ return {
827
+ _GEMINI_PROVIDER_KEY: {
828
+ _GEMINI_EXTRA_CONTENT_KEY: copy.deepcopy(extra_content),
829
+ }
830
+ }
831
+
832
+
833
+ def _copy_transport_tool_calls(
834
+ tool_calls: Any,
835
+ *,
836
+ preserve_extra_content: bool,
837
+ ) -> list[Any] | Any:
838
+ if not isinstance(tool_calls, list):
839
+ return tool_calls
840
+ copied_tool_calls: list[Any] = []
841
+ for tool_call in tool_calls:
842
+ if not isinstance(tool_call, dict):
843
+ copied_tool_calls.append(tool_call)
844
+ continue
845
+ copied_tool_call = copy.deepcopy(tool_call)
846
+ if not preserve_extra_content:
847
+ copied_tool_call.pop(_GEMINI_EXTRA_CONTENT_KEY, None)
848
+ copied_tool_calls.append(copied_tool_call)
849
+ return copied_tool_calls
850
+
851
+
852
+ def _gemini_extra_content_indexes(metadata: Any) -> tuple[dict[str, Any], dict[int, Any]]:
853
+ if not isinstance(metadata, dict):
854
+ return {}, {}
855
+ entries = metadata.get(_TOOL_CALL_PROVIDER_METADATA_KEY)
856
+ if not isinstance(entries, list):
857
+ return {}, {}
858
+ by_id: dict[str, Any] = {}
859
+ by_index: dict[int, Any] = {}
860
+ for entry in entries:
861
+ if not isinstance(entry, dict):
862
+ continue
863
+ entry_metadata = entry.get("metadata")
864
+ if not isinstance(entry_metadata, dict):
865
+ continue
866
+ gemini = entry_metadata.get(_GEMINI_PROVIDER_KEY)
867
+ if not isinstance(gemini, dict):
868
+ continue
869
+ extra_content = gemini.get(_GEMINI_EXTRA_CONTENT_KEY)
870
+ if not isinstance(extra_content, dict) or not extra_content:
871
+ continue
872
+ tool_call_id = entry.get("id")
873
+ if isinstance(tool_call_id, str) and tool_call_id:
874
+ by_id[tool_call_id] = copy.deepcopy(extra_content)
875
+ index = entry.get("index")
876
+ if isinstance(index, int):
877
+ by_index[index] = copy.deepcopy(extra_content)
878
+ return by_id, by_index
879
+
880
+
881
+ def _reattach_gemini_tool_call_extra_content(
882
+ message: dict[str, Any],
883
+ metadata: Any,
884
+ ) -> None:
885
+ tool_calls = message.get("tool_calls")
886
+ if not isinstance(tool_calls, list) or not tool_calls:
887
+ return
888
+ by_id, by_index = _gemini_extra_content_indexes(metadata)
889
+ if not by_id and not by_index:
890
+ return
891
+ reattached: list[Any] = []
892
+ for index, tool_call in enumerate(tool_calls):
893
+ if not isinstance(tool_call, dict):
894
+ reattached.append(tool_call)
895
+ continue
896
+ copied_tool_call = dict(tool_call)
897
+ tool_call_id = str(copied_tool_call.get("id") or "")
898
+ extra_content = by_id.get(tool_call_id) if tool_call_id else None
899
+ if extra_content is None:
900
+ extra_content = by_index.get(index)
901
+ if isinstance(extra_content, dict) and extra_content:
902
+ copied_tool_call[_GEMINI_EXTRA_CONTENT_KEY] = copy.deepcopy(extra_content)
903
+ reattached.append(copied_tool_call)
904
+ message["tool_calls"] = reattached
905
+
906
+
907
+ def _message_for_transport(
908
+ message: dict[str, Any],
909
+ *,
910
+ provider_key: str | None,
911
+ reasoning_provider_key: str | None = None,
912
+ model: str | None = None,
913
+ ) -> dict[str, Any]:
914
+ metadata = message.get(PROVIDER_METADATA_KEY)
915
+ copied = strip_provider_metadata_from_message(message)
916
+ if str(copied.get("role") or "") != "assistant":
917
+ return copied
918
+ has_tool_calls = bool(copied.get("tool_calls"))
919
+ if has_tool_calls:
920
+ copied["tool_calls"] = _copy_transport_tool_calls(
921
+ copied.get("tool_calls"),
922
+ preserve_extra_content=_is_gemini_provider(provider_key),
923
+ )
924
+ if _is_gemini_provider(provider_key):
925
+ _reattach_gemini_tool_call_extra_content(copied, metadata)
926
+ together_deepseek_transport = _is_together_deepseek_pro(provider_key, model)
927
+ deepseek_transport = _is_deepseek_provider(reasoning_provider_key)
928
+ replay_all_deepseek_turns = reasoning_contract_for(
929
+ reasoning_provider_key,
930
+ model,
931
+ ).replay_reasoning_content
932
+ if (deepseek_transport or together_deepseek_transport) and (
933
+ has_tool_calls or replay_all_deepseek_turns
934
+ ):
935
+ reasoning = _deepseek_reasoning_from_provider_metadata(metadata)
936
+ if reasoning:
937
+ if together_deepseek_transport:
938
+ copied[_OPENROUTER_REASONING_KEY] = reasoning
939
+ else:
940
+ copied[_DEEPSEEK_REASONING_CONTENT_KEY] = reasoning
941
+ elif _is_dashscope_provider(reasoning_provider_key) and (
942
+ has_tool_calls or reasoning_contract_for(_QWEN_PROVIDER_KEY, model).replay_reasoning_content
943
+ ):
944
+ reasoning = _qwen_reasoning_from_provider_metadata(metadata)
945
+ if reasoning:
946
+ copied[_DEEPSEEK_REASONING_CONTENT_KEY] = reasoning
947
+ elif has_tool_calls and _is_openrouter_provider(reasoning_provider_key):
948
+ reasoning, reasoning_details = _openrouter_reasoning_from_provider_metadata(metadata)
949
+ if reasoning:
950
+ copied[_OPENROUTER_REASONING_KEY] = reasoning
951
+ if reasoning_details:
952
+ copied[_OPENROUTER_REASONING_DETAILS_KEY] = reasoning_details
953
+ elif has_tool_calls and _is_nvidia_provider(reasoning_provider_key):
954
+ reasoning = _nvidia_reasoning_from_provider_metadata(metadata)
955
+ if reasoning:
956
+ copied[_DEEPSEEK_REASONING_CONTENT_KEY] = reasoning
957
+ elif _is_moonshot_provider(reasoning_provider_key):
958
+ reasoning = _moonshot_reasoning_from_provider_metadata(metadata)
959
+ if reasoning:
960
+ copied[_DEEPSEEK_REASONING_CONTENT_KEY] = reasoning
961
+ elif _is_mistral_provider(reasoning_provider_key):
962
+ content = _mistral_content_from_provider_metadata(metadata)
963
+ if content:
964
+ copied["content"] = content
965
+ return copied
966
+
967
+
968
+ def _messages_for_transport(
969
+ messages: list[dict[str, Any]],
970
+ *,
971
+ provider_key: str | None,
972
+ reasoning_provider_key: str | None = None,
973
+ model: str | None = None,
974
+ ) -> list[dict[str, Any]]:
975
+ return [
976
+ _message_for_transport(
977
+ message,
978
+ provider_key=provider_key,
979
+ reasoning_provider_key=reasoning_provider_key,
980
+ model=model,
981
+ )
982
+ for message in messages
983
+ if isinstance(message, dict)
984
+ ]
985
+
986
+
987
+ def _normalize_assistant_content_to_text(raw: Any) -> str:
988
+ if raw is None:
989
+ return ""
990
+ if isinstance(raw, str):
991
+ return raw
992
+ if isinstance(raw, list):
993
+ return "".join(_normalize_assistant_content_to_text(item) for item in raw)
994
+ if isinstance(raw, dict):
995
+ part_type = raw.get("type")
996
+ text = raw.get("text")
997
+ if isinstance(text, str) and (
998
+ part_type in _TEXT_LIKE_CONTENT_PART_TYPES or part_type is None
999
+ ):
1000
+ return text
1001
+ return ""
1002
+ return ""
1003
+
1004
+
1005
+ def _append_mistral_stream_content(chunks: list[dict[str, Any]], raw: Any) -> None:
1006
+ """Reconstruct replayable Mistral content without exposing ThinkChunk text."""
1007
+
1008
+ if isinstance(raw, str):
1009
+ incoming: list[Any] = [{"type": "text", "text": raw}] if raw else []
1010
+ elif isinstance(raw, list):
1011
+ incoming = raw
1012
+ else:
1013
+ return
1014
+
1015
+ for item in incoming:
1016
+ if not isinstance(item, dict):
1017
+ continue
1018
+ copied = copy.deepcopy(item)
1019
+ item_type = str(copied.get("type") or "").casefold()
1020
+ if item_type == "text" and chunks:
1021
+ previous = chunks[-1]
1022
+ previous_text = previous.get("text")
1023
+ incoming_text = copied.get("text")
1024
+ if (
1025
+ str(previous.get("type") or "").casefold() == "text"
1026
+ and isinstance(previous_text, str)
1027
+ and isinstance(incoming_text, str)
1028
+ ):
1029
+ previous["text"] = previous_text + incoming_text
1030
+ continue
1031
+ if item_type == "thinking" and chunks:
1032
+ previous = chunks[-1]
1033
+ previous_thinking = previous.get("thinking")
1034
+ incoming_thinking = copied.get("thinking")
1035
+ if (
1036
+ str(previous.get("type") or "").casefold() == "thinking"
1037
+ and previous.get("closed") is not True
1038
+ ):
1039
+ if isinstance(previous_thinking, list) and isinstance(incoming_thinking, list):
1040
+ previous["thinking"] = previous_thinking + incoming_thinking
1041
+ elif isinstance(previous_thinking, str) and isinstance(incoming_thinking, str):
1042
+ previous["thinking"] = previous_thinking + incoming_thinking
1043
+ else:
1044
+ chunks.append(copied)
1045
+ continue
1046
+ for key, value in copied.items():
1047
+ if key == "thinking":
1048
+ continue
1049
+ if key == "signature" and value is None:
1050
+ continue
1051
+ previous[key] = value
1052
+ continue
1053
+ chunks.append(copied)
1054
+
1055
+
1056
+ def _parse_arguments(args_s: str) -> dict[str, Any]:
1057
+ try:
1058
+ args = json.loads(args_s)
1059
+ except json.JSONDecodeError:
1060
+ return {"_raw_arguments": args_s}
1061
+ if not isinstance(args, dict):
1062
+ return {"_raw_arguments": args_s}
1063
+ return args
1064
+
1065
+
1066
+ def _parse_tool_calls(tool_calls_raw: list[dict[str, Any]]) -> list[ToolCall]:
1067
+ tool_calls: list[ToolCall] = []
1068
+ for tc in tool_calls_raw:
1069
+ try:
1070
+ tc_id = tc["id"]
1071
+ fn = tc["function"]
1072
+ name = fn["name"]
1073
+ args_s = fn.get("arguments") or "{}"
1074
+ if not isinstance(args_s, str):
1075
+ args_s = json.dumps(args_s)
1076
+ tool_calls.append(
1077
+ ToolCall(
1078
+ id=tc_id,
1079
+ name=name,
1080
+ arguments=_parse_arguments(args_s),
1081
+ provider_metadata=_gemini_tool_call_provider_metadata(tc),
1082
+ )
1083
+ )
1084
+ except Exception:
1085
+ continue
1086
+ return tool_calls
1087
+
1088
+
1089
+ def _parse_stream_tool_calls(tool_chunks: dict[int, dict[str, Any]]) -> list[ToolCall]:
1090
+ out: list[ToolCall] = []
1091
+ for idx in sorted(tool_chunks):
1092
+ chunk = tool_chunks[idx]
1093
+ name = chunk.get("name") or ""
1094
+ if not name:
1095
+ continue
1096
+ tc_id = chunk.get("id") or f"call_{idx}"
1097
+ args_s = chunk.get("arguments") or "{}"
1098
+ metadata = chunk.get("provider_metadata")
1099
+ out.append(
1100
+ ToolCall(
1101
+ id=tc_id,
1102
+ name=name,
1103
+ arguments=_parse_arguments(args_s),
1104
+ provider_metadata=dict(metadata)
1105
+ if isinstance(metadata, dict) and metadata
1106
+ else None,
1107
+ )
1108
+ )
1109
+ return out
1110
+
1111
+
1112
+ def _parse_usage(raw: Any, *, provider_key: str | None = None) -> LLMUsage | None:
1113
+ return parse_compatible_usage(raw, provider_key=provider_key)
1114
+
1115
+
1116
+ def _is_stream_options_unsupported_error(err: LLMError) -> bool:
1117
+ msg = str(err).lower()
1118
+ if "stream_options" not in msg:
1119
+ return False
1120
+ return any(token in msg for token in ("unsupported", "unknown", "invalid", "not allowed"))
1121
+
1122
+
1123
+ def _llm_error_status_code(err: LLMError) -> int | None:
1124
+ status_code = getattr(err, "provider_status_code", None)
1125
+ if isinstance(status_code, int):
1126
+ return status_code
1127
+ match = re.match(r"LLM error\s+(\d{3}):", str(err or "").strip())
1128
+ if match is None:
1129
+ return None
1130
+ try:
1131
+ return int(match.group(1))
1132
+ except ValueError:
1133
+ return None
1134
+
1135
+
1136
+ def _llm_error_body(err: LLMError) -> str:
1137
+ full_body = getattr(err, "provider_error_body", None)
1138
+ if isinstance(full_body, str):
1139
+ return full_body.strip()
1140
+ _prefix, sep, body = str(err or "").partition(":")
1141
+ return body.strip() if sep else str(err or "").strip()
1142
+
1143
+
1144
+ def _json_error_payload(body: str) -> dict[str, Any]:
1145
+ try:
1146
+ payload = json.loads(body)
1147
+ except json.JSONDecodeError:
1148
+ return {}
1149
+ return payload if isinstance(payload, dict) else {}
1150
+
1151
+
1152
+ # Friendly, user-facing copy for the Alysis Code MiMo trial proxy's error codes.
1153
+ # The proxy (Supabase Edge Function) returns an OpenAI-shaped envelope
1154
+ # {"error": {"message": ..., "type": ..., "code": "<reason>"}} with these string
1155
+ # codes; upstream/other-provider errors use different codes and fall through.
1156
+ _ALYSIS_PROXY_ERROR_MESSAGES: dict[str, str] = {
1157
+ "invalid_key": (
1158
+ "Your Alysis Code session is invalid or has been reset. "
1159
+ "Run `alysis login` to reconnect your account."
1160
+ ),
1161
+ "trial_expired": ("Your 10-day free MiMo trial has ended. See your options at {account_url}"),
1162
+ "quota_exhausted": (
1163
+ "You've used all of your free MiMo trial tokens. See your options at {account_url}"
1164
+ ),
1165
+ "email_not_verified": (
1166
+ "Please confirm your email to use the MiMo trial — "
1167
+ "check your inbox for the verification link."
1168
+ ),
1169
+ "plan_inactive": ("Your Alysis Code plan is not active. Visit {account_url} to continue."),
1170
+ "rate_limit_exceeded": (
1171
+ "You're sending requests too quickly. Please wait a moment and try again."
1172
+ ),
1173
+ "global_budget_exceeded": (
1174
+ "The free MiMo trial is at capacity right now. Please try again shortly."
1175
+ ),
1176
+ "proxy_unconfigured": ("The MiMo service is temporarily unavailable. Please try again later."),
1177
+ }
1178
+
1179
+
1180
+ def alysis_trial_error_message(err: LLMError) -> str | None:
1181
+ """Friendly message for an Alysis Code MiMo proxy error, or None if not ours.
1182
+
1183
+ Maps the proxy's known error ``code`` (trial_expired, quota_exhausted, ...) to
1184
+ human copy so a user whose trial ended sees a clear next step instead of a raw
1185
+ ``LLM error 402: {...}`` dump. Returns None for any other failure (including
1186
+ upstream OpenRouter errors, which use numeric codes), so non-proxy errors
1187
+ render unchanged.
1188
+ """
1189
+ payload = _json_error_payload(_llm_error_body(err))
1190
+ error = payload.get("error")
1191
+ if not isinstance(error, dict):
1192
+ return None
1193
+ code = str(error.get("code") or "").strip()
1194
+ template = _ALYSIS_PROXY_ERROR_MESSAGES.get(code)
1195
+ if template is None or "{account_url}" not in template:
1196
+ # str.format would also choke on any literal brace in an unrelated
1197
+ # message, so substitute only where the placeholder actually appears.
1198
+ return template
1199
+ # Resolved at render time, not import time: the account URL follows
1200
+ # ALYSIS_SITE_URL, which a staging deployment overrides per-process.
1201
+ from ..alysis_cloud import account_url
1202
+
1203
+ return template.replace("{account_url}", account_url())
1204
+
1205
+
1206
+ def _temperature_error_fields(err: LLMError) -> tuple[str, str, str] | None:
1207
+ status_code = _llm_error_status_code(err)
1208
+ if status_code not in _TEMPERATURE_UNSUPPORTED_STATUS_CODES:
1209
+ return None
1210
+
1211
+ body = _llm_error_body(err)
1212
+ payload = _json_error_payload(body)
1213
+ if not payload:
1214
+ message = body.strip().casefold()
1215
+ if "temperature" in message and any(
1216
+ token in message for token in _TEMPERATURE_UNSUPPORTED_TOKENS
1217
+ ):
1218
+ return "", "", message
1219
+ return None
1220
+
1221
+ raw_error = payload.get("error")
1222
+ error: dict[str, Any] = raw_error if isinstance(raw_error, dict) else payload
1223
+ param = str(error.get("param") or "").strip().casefold()
1224
+ code = str(error.get("code") or "").strip().casefold()
1225
+ message = str(error.get("message") or "").strip().casefold()
1226
+ combined = f"{code} {message}"
1227
+ has_unsupported_marker = any(token in combined for token in _TEMPERATURE_UNSUPPORTED_TOKENS)
1228
+ if param == "temperature" and (code or message) and has_unsupported_marker:
1229
+ return param, code, message
1230
+ if "temperature" in message and has_unsupported_marker:
1231
+ return param, code, message
1232
+ return None
1233
+
1234
+
1235
+ def _temperature_unsupported_error(err: LLMError) -> bool:
1236
+ return _temperature_error_fields(err) is not None
1237
+
1238
+
1239
+ def _tool_choice_unsupported_error(err: LLMError) -> bool:
1240
+ status_code = _llm_error_status_code(err)
1241
+ if status_code not in _TOOL_CHOICE_UNSUPPORTED_STATUS_CODES:
1242
+ return False
1243
+
1244
+ body = _llm_error_body(err)
1245
+ payload = _json_error_payload(body)
1246
+ if not payload:
1247
+ message = body.strip().casefold()
1248
+ return "tool_choice" in message and any(
1249
+ token in message for token in _TOOL_CHOICE_UNSUPPORTED_TOKENS
1250
+ )
1251
+
1252
+ raw_error = payload.get("error")
1253
+ error: dict[str, Any] = raw_error if isinstance(raw_error, dict) else payload
1254
+ param = str(error.get("param") or "").strip().casefold()
1255
+ code = str(error.get("code") or "").strip().casefold()
1256
+ message = str(error.get("message") or "").strip().casefold()
1257
+ combined = f"{param} {code} {message}"
1258
+ if param == "tool_choice":
1259
+ return True
1260
+ return "tool_choice" in combined and any(
1261
+ token in combined for token in _TOOL_CHOICE_UNSUPPORTED_TOKENS
1262
+ )
1263
+
1264
+
1265
+ def _tool_calling_unsupported_error(err: LLMError) -> bool:
1266
+ status_code = _llm_error_status_code(err)
1267
+ if status_code is None or status_code < 400 or status_code >= 500 or status_code == 429:
1268
+ return False
1269
+
1270
+ body = _llm_error_body(err)
1271
+ payload = _json_error_payload(body)
1272
+ if not payload:
1273
+ combined = body.strip().casefold()
1274
+ else:
1275
+ raw_error = payload.get("error")
1276
+ error: dict[str, Any] = raw_error if isinstance(raw_error, dict) else payload
1277
+ param = str(error.get("param") or "").strip().casefold()
1278
+ code = str(error.get("code") or "").strip().casefold()
1279
+ message = str(error.get("message") or "").strip().casefold()
1280
+ if param in _TOOL_CALLING_REJECTION_PARAMS:
1281
+ return True
1282
+ combined = f"{param} {code} {message}"
1283
+ if "tool_choice" in combined:
1284
+ return False
1285
+ return any(term in combined for term in _TOOL_CALLING_REJECTION_TERMS) and any(
1286
+ token in combined for token in _TOOL_CHOICE_UNSUPPORTED_TOKENS
1287
+ )
1288
+
1289
+
1290
+ def _is_temperature_default_value(value: Any) -> bool:
1291
+ try:
1292
+ return float(value) == _TEMPERATURE_DEFAULT_VALUE
1293
+ except (TypeError, ValueError):
1294
+ return False
1295
+
1296
+
1297
+ def _temperature_compat_mode_for_error(
1298
+ err: LLMError,
1299
+ *,
1300
+ current_temperature: Any,
1301
+ ) -> str | None:
1302
+ fields = _temperature_error_fields(err)
1303
+ if fields is None:
1304
+ return None
1305
+ _param, _code, message = fields
1306
+ if "deprecated" in message:
1307
+ return _TEMPERATURE_COMPAT_MODE_OMIT
1308
+ if not _is_temperature_default_value(current_temperature):
1309
+ return _TEMPERATURE_COMPAT_MODE_DEFAULT
1310
+ return _TEMPERATURE_COMPAT_MODE_OMIT
1311
+
1312
+
1313
+ def _safe_cache_request_field_values(values: Mapping[str, Any] | None) -> dict[str, str]:
1314
+ if not isinstance(values, Mapping):
1315
+ return {}
1316
+ safe: dict[str, str] = {}
1317
+ for field in _PROMPT_CACHE_FIELDS:
1318
+ value = values.get(field)
1319
+ text = str(value or "").strip()
1320
+ if not text or "\r" in text or "\n" in text:
1321
+ continue
1322
+ safe[field] = text
1323
+ return safe
1324
+
1325
+
1326
+ def _set_header_if_absent(headers: dict[str, str], field: str, value: str) -> None:
1327
+ if not value:
1328
+ return
1329
+ lowered = field.casefold()
1330
+ if any(str(key).casefold() == lowered for key in headers):
1331
+ return
1332
+ headers[field] = value
1333
+
1334
+
1335
+ def _strip_header_case_insensitive(headers: dict[str, str], field: str) -> None:
1336
+ lowered = field.casefold()
1337
+ for key in list(headers):
1338
+ if str(key).casefold() == lowered:
1339
+ headers.pop(key, None)
1340
+
1341
+
1342
+ def _cache_fields_in_request(
1343
+ *,
1344
+ payload: Mapping[str, Any],
1345
+ headers: Mapping[str, str],
1346
+ ) -> tuple[str, ...]:
1347
+ header_keys = {str(key).casefold() for key in headers}
1348
+ fields: list[str] = []
1349
+ for field in _PROMPT_CACHE_FIELDS:
1350
+ if field in _CACHE_HEADER_FIELDS:
1351
+ if field.casefold() in header_keys:
1352
+ fields.append(field)
1353
+ elif field == CACHE_CONTROL_FIELD:
1354
+ if count_cache_control_blocks(payload) > 0:
1355
+ fields.append(field)
1356
+ elif field in payload:
1357
+ fields.append(field)
1358
+ return tuple(fields)
1359
+
1360
+
1361
+ def _strip_cache_request_fields(
1362
+ *,
1363
+ payload: dict[str, Any],
1364
+ headers: dict[str, str],
1365
+ fields: tuple[str, ...],
1366
+ ) -> None:
1367
+ for field in fields:
1368
+ if field in _CACHE_HEADER_FIELDS:
1369
+ _strip_header_case_insensitive(headers, field)
1370
+ elif field == CACHE_CONTROL_FIELD:
1371
+ payload.pop(field, None)
1372
+ strip_cache_control_blocks(payload)
1373
+ else:
1374
+ payload.pop(field, None)
1375
+
1376
+
1377
+ def _cache_param_rejected_fields(
1378
+ err: LLMError,
1379
+ *,
1380
+ payload: Mapping[str, Any],
1381
+ headers: Mapping[str, str],
1382
+ ) -> tuple[str, ...]:
1383
+ active_fields = _cache_fields_in_request(payload=payload, headers=headers)
1384
+ if not active_fields:
1385
+ return ()
1386
+ status_code = _llm_error_status_code(err)
1387
+ if status_code not in _CACHE_PARAM_UNSUPPORTED_STATUS_CODES:
1388
+ return ()
1389
+
1390
+ body = _llm_error_body(err)
1391
+ payload_error = _json_error_payload(body)
1392
+ if payload_error:
1393
+ raw_error = payload_error.get("error")
1394
+ error = raw_error if isinstance(raw_error, dict) else payload_error
1395
+ param = str(error.get("param") or "").strip().casefold()
1396
+ code = str(error.get("code") or "").strip().casefold()
1397
+ message = str(error.get("message") or "").strip().casefold()
1398
+ combined = f"{param} {code} {message}"
1399
+ if not combined.strip():
1400
+ # JSON error envelopes without param/code/message (FastAPI/pydantic
1401
+ # detail lists, bare {"error": "<string>"}) still name the field in
1402
+ # the raw body.
1403
+ combined = body.strip().casefold()
1404
+ else:
1405
+ combined = body.strip().casefold()
1406
+
1407
+ rejected = [field for field in active_fields if field.casefold() in combined]
1408
+ if PROMPT_CACHE_KEY_FIELD in rejected and PROMPT_CACHE_RETENTION_FIELD in active_fields:
1409
+ rejected.append(PROMPT_CACHE_RETENTION_FIELD)
1410
+ if (
1411
+ OPENROUTER_SESSION_ID_FIELD in rejected
1412
+ and OPENROUTER_SESSION_ID_HEADER_FIELD in active_fields
1413
+ ):
1414
+ rejected.append(OPENROUTER_SESSION_ID_HEADER_FIELD)
1415
+ if (
1416
+ OPENROUTER_SESSION_ID_HEADER_FIELD in rejected
1417
+ and OPENROUTER_SESSION_ID_FIELD in active_fields
1418
+ ):
1419
+ rejected.append(OPENROUTER_SESSION_ID_FIELD)
1420
+ if rejected:
1421
+ return tuple(dict.fromkeys(rejected))
1422
+
1423
+ has_cache_signal = (
1424
+ "prompt cache" in combined
1425
+ or "prompt_cache" in combined
1426
+ or "cache_control" in combined
1427
+ or "cache control" in combined
1428
+ or "cache routing" in combined
1429
+ or "sticky" in combined
1430
+ )
1431
+ has_unsupported_marker = any(token in combined for token in _CACHE_PARAM_UNSUPPORTED_TOKENS)
1432
+ if has_cache_signal and has_unsupported_marker:
1433
+ return active_fields
1434
+ return ()
1435
+
1436
+
1437
+ def _cache_policy_after_fields_disabled(
1438
+ cache_policy: Mapping[str, Any] | None,
1439
+ *,
1440
+ fields: tuple[str, ...],
1441
+ fallback: str,
1442
+ ) -> dict[str, Any] | None:
1443
+ if not isinstance(cache_policy, Mapping) or not fields:
1444
+ return None if cache_policy is None else dict(cache_policy)
1445
+ disabled = set(fields)
1446
+ updated = dict(cache_policy)
1447
+ for key in ("emitted_fields", "allowed_fields"):
1448
+ value = updated.get(key)
1449
+ if isinstance(value, (list, tuple)):
1450
+ updated[key] = [field for field in value if str(field) not in disabled]
1451
+ existing_disabled = updated.get("disabled_fields")
1452
+ disabled_fields = []
1453
+ if isinstance(existing_disabled, (list, tuple)):
1454
+ disabled_fields.extend(str(field) for field in existing_disabled)
1455
+ disabled_fields.extend(fields)
1456
+ unique_disabled_fields = list(dict.fromkeys(disabled_fields))
1457
+ updated["disabled_fields"] = unique_disabled_fields
1458
+ updated["runtime_disabled_fields"] = unique_disabled_fields
1459
+ updated["capability_downgrade"] = "session_local_provider_rejection"
1460
+ updated["fallback"] = fallback
1461
+ if not updated.get("emitted_fields") and updated.get("status") == "enabled":
1462
+ updated["status"] = "available"
1463
+ updated["enabled"] = bool(updated.get("emitted_fields"))
1464
+ updated["emits_request_fields"] = bool(updated.get("allowed_fields"))
1465
+ return updated
1466
+
1467
+
1468
+ def _merge_transport_metadata(
1469
+ response: LLMResponse,
1470
+ *,
1471
+ transport_metadata: dict[str, Any] | None,
1472
+ ) -> LLMResponse:
1473
+ if not transport_metadata:
1474
+ return response
1475
+ provider_metadata = _merge_provider_metadata(
1476
+ response.provider_metadata,
1477
+ {"transport": transport_metadata},
1478
+ )
1479
+ return LLMResponse(
1480
+ content=response.content,
1481
+ tool_calls=list(response.tool_calls),
1482
+ raw=response.raw,
1483
+ response_model=response.response_model,
1484
+ usage=response.usage,
1485
+ provider_metadata=provider_metadata,
1486
+ reasoning=response.reasoning,
1487
+ )
1488
+
1489
+
1490
+ def _merge_request_plan_metadata(
1491
+ response: LLMResponse,
1492
+ *,
1493
+ request_plan_metadata: dict[str, Any] | None,
1494
+ ) -> LLMResponse:
1495
+ if not request_plan_metadata:
1496
+ return response
1497
+ provider_metadata = _merge_provider_metadata(
1498
+ response.provider_metadata,
1499
+ {"openai_compat": {"request_plan": request_plan_metadata}},
1500
+ )
1501
+ return LLMResponse(
1502
+ content=response.content,
1503
+ tool_calls=list(response.tool_calls),
1504
+ raw=response.raw,
1505
+ response_model=response.response_model,
1506
+ usage=response.usage,
1507
+ provider_metadata=provider_metadata,
1508
+ reasoning=response.reasoning,
1509
+ )
1510
+
1511
+
1512
+ def _display_error_body(body: str) -> str:
1513
+ if len(body) > _ERROR_BODY_DISPLAY_LIMIT:
1514
+ return body[:_ERROR_BODY_DISPLAY_LIMIT] + "...(truncated)"
1515
+ return body
1516
+
1517
+
1518
+ def _error_from_status_body(*, status_code: int, body: str) -> LLMError:
1519
+ safe_body = sanitize_error_text_for_output(body)
1520
+ err = LLMError(f"LLM error {status_code}: {_display_error_body(safe_body)}")
1521
+ err.provider_status_code = int(status_code)
1522
+ err.provider_error_body = safe_body
1523
+ return err
1524
+
1525
+
1526
+ def _response_with_stream_restart_metadata(
1527
+ response: LLMResponse,
1528
+ *,
1529
+ count: int,
1530
+ reason: str,
1531
+ ) -> LLMResponse:
1532
+ raw = dict(response.raw) if isinstance(response.raw, dict) else {}
1533
+ raw["stream_restart_count"] = max(0, int(count))
1534
+ if reason:
1535
+ raw["stream_restart_reason"] = str(reason)
1536
+ return LLMResponse(
1537
+ content=response.content,
1538
+ tool_calls=list(response.tool_calls),
1539
+ raw=raw,
1540
+ response_model=response.response_model,
1541
+ usage=response.usage,
1542
+ provider_metadata=response.provider_metadata,
1543
+ reasoning=response.reasoning,
1544
+ )
1545
+
1546
+
1547
+ class OpenAICompatClient:
1548
+ supports_forced_tool_choice = True
1549
+
1550
+ def __init__(
1551
+ self,
1552
+ *,
1553
+ base_url: str,
1554
+ api_key: str,
1555
+ model: str,
1556
+ timeout_s: float = 60.0,
1557
+ temperature: float = 1.0,
1558
+ prompt_cache_key: str | None = None,
1559
+ prompt_cache_retention: str | None = None,
1560
+ prompt_cache_request_field_values: Mapping[str, Any] | None = None,
1561
+ enable_thinking: bool | None = None,
1562
+ reasoning_effort: str | None = None,
1563
+ transport: httpx.BaseTransport | None = None,
1564
+ extra_headers: dict[str, str] | None = None,
1565
+ provider_key: str | None = None,
1566
+ reasoning_trace_adapter: str | None = "auto",
1567
+ usage_contract: UsageContract | None = None,
1568
+ usage_counts_authoritative: bool | None = None,
1569
+ provider_concurrency_caps: dict[str, int] | None = None,
1570
+ provider_retry_settings: ProviderRetrySettings | None = None,
1571
+ provider_sleep_fn: Callable[[float], None] | None = None,
1572
+ provider_random_fn: Callable[[], float] | None = None,
1573
+ prompt_cache_policy_metadata: Mapping[str, Any] | None = None,
1574
+ route_identity: ProviderRouteIdentity | None = None,
1575
+ stream_no_progress_timeout_s: float = 240.0,
1576
+ stream_progress_clock: Callable[[], float] | None = None,
1577
+ inflight_deadline_grace_s: float = 10.0,
1578
+ ) -> None:
1579
+ self.base_url = base_url.rstrip("/")
1580
+ self.api_key = api_key
1581
+ self.model = model
1582
+ self.timeout_s = timeout_s
1583
+ self.temperature = temperature
1584
+ self.prompt_cache_key = str(prompt_cache_key or "").strip() or None
1585
+ self.prompt_cache_retention = str(prompt_cache_retention or "").strip() or None
1586
+ self.prompt_cache_request_field_values = _safe_cache_request_field_values(
1587
+ prompt_cache_request_field_values
1588
+ )
1589
+ self.enable_thinking = enable_thinking
1590
+ self.reasoning_effort = str(reasoning_effort or "").strip().lower() or None
1591
+ self._transport = transport
1592
+ self.extra_headers = canonicalize_extra_headers(extra_headers)
1593
+ self.provider_key = str(provider_key or "").strip() or None
1594
+ self.reasoning_trace_adapter = validate_reasoning_trace_adapter_for_protocol(
1595
+ protocol=OPENAI_COMPAT_PROTOCOL,
1596
+ adapter=reasoning_trace_adapter,
1597
+ )
1598
+ self.route_identity = route_identity or build_provider_route_identity(
1599
+ protocol=OPENAI_COMPAT_PROTOCOL,
1600
+ base_url=self.base_url,
1601
+ provider_key=self.provider_key,
1602
+ model=self.model,
1603
+ credential_scope=credential_scope_fingerprint(self.api_key),
1604
+ routing_headers=self.extra_headers,
1605
+ routing_fields=self.prompt_cache_request_field_values,
1606
+ reasoning_state_adapter=self.reasoning_trace_adapter,
1607
+ )
1608
+ if usage_contract is None:
1609
+ usage_contract = UsageContract(
1610
+ response_usage_confidence=(
1611
+ UsageConfidence.AUTHORITATIVE
1612
+ if usage_counts_authoritative
1613
+ else UsageConfidence.REPORTED
1614
+ ),
1615
+ input_token_count_strategy="openai_compat_provider_payload",
1616
+ )
1617
+ self.usage_contract = usage_contract
1618
+ self.usage_counts_authoritative = usage_contract.response_usage_authoritative
1619
+ self.provider_concurrency_caps = dict(
1620
+ DEFAULT_PROVIDER_CONCURRENCY_CAPS
1621
+ if provider_concurrency_caps is None
1622
+ else provider_concurrency_caps
1623
+ )
1624
+ self.provider_retry_settings = provider_retry_settings or ProviderRetrySettings()
1625
+ self._provider_sleep_fn = provider_sleep_fn
1626
+ self._provider_random_fn = provider_random_fn
1627
+ self._provider_retry_deadline_allows: Callable[[float], bool] | None = None
1628
+ self._provider_retry_event_observer: Callable[[dict[str, object]], None] | None = None
1629
+ self.stream_no_progress_timeout_s = max(
1630
+ 0.001,
1631
+ float(stream_no_progress_timeout_s),
1632
+ )
1633
+ self._stream_progress_clock = stream_progress_clock or monotonic
1634
+ self.inflight_deadline_grace_s = max(
1635
+ 0.0,
1636
+ float(inflight_deadline_grace_s),
1637
+ )
1638
+ self.prompt_cache_policy_metadata = (
1639
+ copy.deepcopy(dict(prompt_cache_policy_metadata))
1640
+ if isinstance(prompt_cache_policy_metadata, Mapping)
1641
+ else None
1642
+ )
1643
+ self._disabled_prompt_cache_fields: set[str] = set()
1644
+ self._disabled_prompt_cache_fields_lock = threading.Lock()
1645
+ self._provider_retry_wall_clock_cap_seconds = _PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS
1646
+ self._temperature_compat_modes: dict[tuple[str, str], str] = {}
1647
+ self._temperature_compat_lock = threading.Lock()
1648
+ self._tool_choice_compat_disabled: set[tuple[str, str]] = set()
1649
+ self._tool_choice_compat_lock = threading.Lock()
1650
+ self._tool_calling_compat_disabled: set[tuple[str, str, str]] = set()
1651
+ self._tool_calling_compat_lock = threading.Lock()
1652
+
1653
+ @property
1654
+ def reasoning_active(self) -> bool | None:
1655
+ """Whether incompatible reasoning is known to be active on this route.
1656
+
1657
+ Agent recovery uses this to avoid manufacturing a forced tool choice
1658
+ that the provider's active reasoning mode cannot accept. Contracts that
1659
+ accept tool choice, plus unknown custom transports, remain ``None`` and
1660
+ keep the legacy capability fallback.
1661
+ """
1662
+
1663
+ transport_provider_key = _transport_provider_key(
1664
+ base_url=self.base_url,
1665
+ provider_key=self.provider_key,
1666
+ model=self.model,
1667
+ )
1668
+ reasoning_provider_key = _reasoning_transport_provider_key(
1669
+ transport_provider_key=transport_provider_key,
1670
+ reasoning_trace_adapter=self.reasoning_trace_adapter,
1671
+ )
1672
+ contract = reasoning_contract_for(reasoning_provider_key, self.model)
1673
+ if contract.accepts_tool_choice_while_reasoning:
1674
+ return None
1675
+ return _reasoning_contract_active(
1676
+ provider_key=reasoning_provider_key,
1677
+ model=self.model,
1678
+ enable_thinking=self.enable_thinking,
1679
+ reasoning_effort=self.reasoning_effort,
1680
+ )
1681
+
1682
+ def _temperature_compat_key(self, provider_key: str | None) -> tuple[str, str]:
1683
+ provider = _normalize_provider_key(provider_key) or _normalize_provider_key(self.base_url)
1684
+ model = str(self.model or "").strip().casefold()
1685
+ return provider, model
1686
+
1687
+ def _temperature_compat_mode_for(self, key: tuple[str, str]) -> str | None:
1688
+ with self._temperature_compat_lock:
1689
+ return self._temperature_compat_modes.get(key)
1690
+
1691
+ def _mark_temperature_compat_mode(self, key: tuple[str, str], mode: str) -> None:
1692
+ if mode not in _TEMPERATURE_COMPAT_MODES:
1693
+ raise ValueError(f"Unknown temperature compatibility mode: {mode}")
1694
+ with self._temperature_compat_lock:
1695
+ self._temperature_compat_modes[key] = mode
1696
+
1697
+ def _disabled_prompt_cache_fields_snapshot(self) -> tuple[str, ...]:
1698
+ with self._disabled_prompt_cache_fields_lock:
1699
+ return tuple(
1700
+ field
1701
+ for field in _PROMPT_CACHE_FIELDS
1702
+ if field in self._disabled_prompt_cache_fields
1703
+ )
1704
+
1705
+ def _disable_prompt_cache_fields(self, fields: tuple[str, ...]) -> tuple[str, ...]:
1706
+ requested = set(fields)
1707
+ clean_fields = tuple(field for field in _PROMPT_CACHE_FIELDS if field in requested)
1708
+ if not clean_fields:
1709
+ return ()
1710
+ with self._disabled_prompt_cache_fields_lock:
1711
+ for field in clean_fields:
1712
+ self._disabled_prompt_cache_fields.add(field)
1713
+ return tuple(
1714
+ field
1715
+ for field in _PROMPT_CACHE_FIELDS
1716
+ if field in self._disabled_prompt_cache_fields
1717
+ )
1718
+
1719
+ def _active_prompt_cache_request_field_values(
1720
+ self,
1721
+ disabled_fields: tuple[str, ...],
1722
+ ) -> dict[str, str]:
1723
+ disabled = set(disabled_fields)
1724
+ safe_values = _safe_cache_request_field_values(self.prompt_cache_request_field_values)
1725
+ return {field: value for field, value in safe_values.items() if field not in disabled}
1726
+
1727
+ def _tool_choice_compat_key(self, provider_key: str | None) -> tuple[str, str]:
1728
+ provider = _normalize_provider_key(provider_key) or _normalize_provider_key(self.base_url)
1729
+ model = str(self.model or "").strip().casefold()
1730
+ return provider, model
1731
+
1732
+ def _tool_choice_compat_disabled_for(self, key: tuple[str, str]) -> bool:
1733
+ with self._tool_choice_compat_lock:
1734
+ return key in self._tool_choice_compat_disabled
1735
+
1736
+ def _mark_tool_choice_compat_disabled(self, key: tuple[str, str]) -> None:
1737
+ with self._tool_choice_compat_lock:
1738
+ self._tool_choice_compat_disabled.add(key)
1739
+
1740
+ def _tool_calling_compat_key(self, provider_key: str | None) -> tuple[str, str, str]:
1741
+ provider = _normalize_provider_key(provider_key) or _normalize_provider_key(self.base_url)
1742
+ model = str(self.model or "").strip().casefold()
1743
+ base_url = str(self.base_url or "").strip().casefold()
1744
+ return provider, model, base_url
1745
+
1746
+ def _tool_calling_compat_disabled_for(self, key: tuple[str, str, str]) -> bool:
1747
+ with self._tool_calling_compat_lock:
1748
+ return key in self._tool_calling_compat_disabled
1749
+
1750
+ def _mark_tool_calling_compat_disabled(self, key: tuple[str, str, str]) -> None:
1751
+ with self._tool_calling_compat_lock:
1752
+ self._tool_calling_compat_disabled.add(key)
1753
+
1754
+ @property
1755
+ def supports_tool_calling(self) -> bool:
1756
+ provider_key = _transport_provider_key(
1757
+ base_url=self.base_url,
1758
+ provider_key=self.provider_key,
1759
+ model=self.model,
1760
+ )
1761
+ return not self._tool_calling_compat_disabled_for(
1762
+ self._tool_calling_compat_key(provider_key)
1763
+ )
1764
+
1765
+ def count_input_tokens(
1766
+ self,
1767
+ *,
1768
+ messages: list[dict[str, Any]],
1769
+ tools: list[dict[str, Any]] | None = None,
1770
+ tool_choice: Any | None = None,
1771
+ ) -> InputTokenCount:
1772
+ """Estimate the prompt-bearing payload that this compatibility route sends.
1773
+
1774
+ OpenAI-compatible wire format does not imply a shared tokenizer or a
1775
+ standard preflight counting endpoint. This method therefore exposes the
1776
+ adapter's provider-transformed payload through the common counting
1777
+ contract while explicitly retaining ``local_estimate`` / ``estimated``
1778
+ provenance. Response usage and overflow recovery remain the authority.
1779
+ """
1780
+
1781
+ del tool_choice # The chat estimator intentionally excludes control-only fields.
1782
+ messages = gate_messages_for_provider_route(messages, self.route_identity)
1783
+ transport_provider_key = _transport_provider_key(
1784
+ base_url=self.base_url,
1785
+ provider_key=self.provider_key,
1786
+ model=self.model,
1787
+ )
1788
+ reasoning_provider_key = _reasoning_transport_provider_key(
1789
+ transport_provider_key=transport_provider_key,
1790
+ reasoning_trace_adapter=self.reasoning_trace_adapter,
1791
+ )
1792
+ disabled_prompt_cache_fields = self._disabled_prompt_cache_fields_snapshot()
1793
+ active_cache_field_values = self._active_prompt_cache_request_field_values(
1794
+ disabled_prompt_cache_fields
1795
+ )
1796
+ effective_tools = tools
1797
+ if self._tool_calling_compat_disabled_for(
1798
+ self._tool_calling_compat_key(transport_provider_key)
1799
+ ):
1800
+ effective_tools = None
1801
+
1802
+ provider_messages = _messages_for_transport(
1803
+ messages,
1804
+ provider_key=transport_provider_key,
1805
+ reasoning_provider_key=reasoning_provider_key,
1806
+ model=self.model,
1807
+ )
1808
+ if CACHE_CONTROL_FIELD in active_cache_field_values:
1809
+ cache_policy = merge_cache_policy_metadata(
1810
+ self.prompt_cache_policy_metadata,
1811
+ RequestCachePlan(
1812
+ strategy="openai_prompt_cache",
1813
+ mode="automatic",
1814
+ prompt_cache_key=self.prompt_cache_key,
1815
+ prompt_cache_retention=self.prompt_cache_retention,
1816
+ ).openai_prompt_cache_policy_metadata(),
1817
+ )
1818
+ provider_messages = apply_openai_compatible_cache_control_breakpoint(
1819
+ provider_messages,
1820
+ cache_policy=cache_policy,
1821
+ ).messages
1822
+
1823
+ prompt_payload: dict[str, Any] = {"messages": provider_messages}
1824
+ if effective_tools:
1825
+ prompt_payload["tools"] = effective_tools
1826
+ prompt_payload = _sanitize_transport_value(prompt_payload)
1827
+ return InputTokenCount(
1828
+ input_tokens=estimate_provider_payload_tokens(prompt_payload),
1829
+ source=UsageSource.LOCAL_ESTIMATE,
1830
+ confidence=UsageConfidence.ESTIMATED,
1831
+ raw_provider_usage={
1832
+ "estimator": "cl100k_base",
1833
+ "estimate_basis": "provider_prompt_payload",
1834
+ "provider_key": transport_provider_key,
1835
+ "protocol": "openai_compat",
1836
+ "model": self.model,
1837
+ "message_count": len(prompt_payload.get("messages") or []),
1838
+ "tool_count": len(prompt_payload.get("tools") or []),
1839
+ },
1840
+ )
1841
+
1842
+ def chat(
1843
+ self,
1844
+ *,
1845
+ messages: list[dict[str, Any]],
1846
+ tools: list[dict[str, Any]] | None = None,
1847
+ tool_choice: Any | None = None,
1848
+ response_format: dict[str, Any] | None = None,
1849
+ stream: bool = False,
1850
+ on_text_delta: Callable[[str], None] | None = None,
1851
+ on_reasoning_delta: Callable[[str], None] | None = None,
1852
+ temperature: float | None = None,
1853
+ max_tokens: int | None = None,
1854
+ cancellation_token: Any | None = None,
1855
+ ) -> LLMResponse:
1856
+ messages = gate_messages_for_provider_route(messages, self.route_identity)
1857
+ url = f"{self.base_url}/chat/completions"
1858
+ headers = merge_canonical_headers(
1859
+ {
1860
+ "Authorization": f"Bearer {self.api_key}",
1861
+ "Content-Type": "application/json",
1862
+ "User-Agent": "alysis-code/0.1.0",
1863
+ },
1864
+ self.extra_headers,
1865
+ )
1866
+ headers = _headers_with_default_accept_encoding(headers)
1867
+ resolved_temperature = self.temperature if temperature is None else float(temperature)
1868
+ transport_provider_key = _transport_provider_key(
1869
+ base_url=self.base_url,
1870
+ provider_key=self.provider_key,
1871
+ model=self.model,
1872
+ )
1873
+ reasoning_provider_key = _reasoning_transport_provider_key(
1874
+ transport_provider_key=transport_provider_key,
1875
+ reasoning_trace_adapter=self.reasoning_trace_adapter,
1876
+ )
1877
+ deepseek_thinking_enabled = (
1878
+ _deepseek_reasoning_payload_enabled(
1879
+ enable_thinking=self.enable_thinking,
1880
+ reasoning_effort=self.reasoning_effort,
1881
+ )
1882
+ if _is_deepseek_provider(reasoning_provider_key)
1883
+ else None
1884
+ )
1885
+ documented_temperature_reason = documented_temperature_omit_reason(
1886
+ self.model,
1887
+ provider_key=transport_provider_key,
1888
+ thinking_enabled=deepseek_thinking_enabled,
1889
+ )
1890
+ temperature_key = self._temperature_compat_key(transport_provider_key)
1891
+ cached_temperature_compat_mode = self._temperature_compat_mode_for(temperature_key)
1892
+ disabled_prompt_cache_fields = self._disabled_prompt_cache_fields_snapshot()
1893
+ active_cache_field_values = self._active_prompt_cache_request_field_values(
1894
+ disabled_prompt_cache_fields
1895
+ )
1896
+ active_prompt_cache_key = (
1897
+ None
1898
+ if PROMPT_CACHE_KEY_FIELD in disabled_prompt_cache_fields
1899
+ else self.prompt_cache_key or active_cache_field_values.get(PROMPT_CACHE_KEY_FIELD)
1900
+ )
1901
+ active_prompt_cache_retention = (
1902
+ None
1903
+ if PROMPT_CACHE_RETENTION_FIELD in disabled_prompt_cache_fields
1904
+ else self.prompt_cache_retention
1905
+ or active_cache_field_values.get(PROMPT_CACHE_RETENTION_FIELD)
1906
+ )
1907
+ tool_choice_key = self._tool_choice_compat_key(transport_provider_key)
1908
+ cached_tool_choice_compat_disabled = self._tool_choice_compat_disabled_for(tool_choice_key)
1909
+ tool_calling_key = self._tool_calling_compat_key(transport_provider_key)
1910
+ cached_tool_calling_compat_disabled = self._tool_calling_compat_disabled_for(
1911
+ tool_calling_key
1912
+ )
1913
+ transport_metadata: dict[str, Any] = {}
1914
+ reasoning_contract = reasoning_contract_for(reasoning_provider_key, self.model)
1915
+ payload: dict[str, Any] = {
1916
+ "model": self.model,
1917
+ "messages": _messages_for_transport(
1918
+ messages,
1919
+ provider_key=transport_provider_key,
1920
+ reasoning_provider_key=reasoning_provider_key,
1921
+ model=self.model,
1922
+ ),
1923
+ }
1924
+ if documented_temperature_reason is not None:
1925
+ transport_metadata["temperature_adjusted"] = True
1926
+ transport_metadata["temperature_adjustment"] = _TEMPERATURE_COMPAT_MODE_OMIT
1927
+ transport_metadata["temperature_adjustment_reason"] = "documented_model_policy"
1928
+ transport_metadata["temperature_omitted"] = True
1929
+ transport_metadata["temperature_omit_reason"] = documented_temperature_reason
1930
+ elif cached_temperature_compat_mode == _TEMPERATURE_COMPAT_MODE_DEFAULT:
1931
+ payload["temperature"] = _TEMPERATURE_DEFAULT_VALUE
1932
+ transport_metadata["temperature_adjusted"] = True
1933
+ transport_metadata["temperature_adjustment"] = _TEMPERATURE_COMPAT_MODE_DEFAULT
1934
+ transport_metadata["temperature_adjustment_reason"] = "cached_provider_rejection"
1935
+ elif cached_temperature_compat_mode == _TEMPERATURE_COMPAT_MODE_OMIT:
1936
+ transport_metadata["temperature_adjusted"] = True
1937
+ transport_metadata["temperature_adjustment"] = _TEMPERATURE_COMPAT_MODE_OMIT
1938
+ transport_metadata["temperature_adjustment_reason"] = "cached_provider_rejection"
1939
+ transport_metadata["temperature_omitted"] = True
1940
+ transport_metadata["temperature_omit_reason"] = "cached_provider_rejection"
1941
+ else:
1942
+ payload["temperature"] = resolved_temperature
1943
+ if active_prompt_cache_key:
1944
+ payload[PROMPT_CACHE_KEY_FIELD] = active_prompt_cache_key
1945
+ if active_prompt_cache_retention:
1946
+ payload[PROMPT_CACHE_RETENTION_FIELD] = active_prompt_cache_retention
1947
+ openrouter_session_id = active_cache_field_values.get(OPENROUTER_SESSION_ID_FIELD)
1948
+ if openrouter_session_id:
1949
+ payload[OPENROUTER_SESSION_ID_FIELD] = openrouter_session_id
1950
+ _set_header_if_absent(
1951
+ headers,
1952
+ OPENROUTER_SESSION_ID_HEADER_FIELD,
1953
+ active_cache_field_values.get(OPENROUTER_SESSION_ID_HEADER_FIELD, ""),
1954
+ )
1955
+ _set_header_if_absent(
1956
+ headers,
1957
+ XAI_CONVERSATION_ID_HEADER_FIELD,
1958
+ active_cache_field_values.get(XAI_CONVERSATION_ID_HEADER_FIELD, ""),
1959
+ )
1960
+ # Track whether this request runs in thinking/reasoning mode on a provider
1961
+ # that rejects a forced tool_choice while thinking (DeepSeek, OpenRouter/
1962
+ # MiMo, DashScope/Qwen, Zhipu GLM). OpenAI/Gemini-style reasoning accept a
1963
+ # forced choice, so those branches leave this False.
1964
+ thinking_active = False
1965
+ if _is_moonshot_provider(transport_provider_key):
1966
+ # Moonshot Platform and Kimi Code share response-state behavior but
1967
+ # expose different request contracts. Resolve the endpoint surface
1968
+ # first, then shape the request from its contract rather than from a
1969
+ # model-name heuristic.
1970
+ contract = reasoning_contract_for(transport_provider_key, self.model)
1971
+ if contract.wire == WIRE_REASONING_EFFORT:
1972
+ reasoning_effort = _documented_reasoning_effort(
1973
+ provider_key=transport_provider_key,
1974
+ model=self.model,
1975
+ reasoning_effort=self.reasoning_effort,
1976
+ )
1977
+ if not reasoning_effort and contract.mode == ALWAYS_ON:
1978
+ reasoning_effort = contract.default or None
1979
+ if reasoning_effort:
1980
+ payload["reasoning_effort"] = reasoning_effort
1981
+ elif contract.wire == WIRE_THINKING_TYPE and contract.mode == ALWAYS_ON:
1982
+ thinking_active = True
1983
+ elif contract.wire == WIRE_THINKING_TYPE:
1984
+ thinking_enabled = self.enable_thinking
1985
+ if thinking_enabled is None:
1986
+ thinking_enabled = _reasoning_effort_enables_thinking(self.reasoning_effort)
1987
+ # Optional Moonshot thinking defaults on when the toggle is
1988
+ # omitted; preserve that default for forced-tool handling.
1989
+ thinking_active = thinking_enabled is not False
1990
+ if thinking_enabled is not None:
1991
+ payload["thinking"] = {"type": "enabled" if thinking_enabled else "disabled"}
1992
+ elif _is_dashscope_provider(reasoning_provider_key):
1993
+ enable_thinking = self.enable_thinking
1994
+ if enable_thinking is None:
1995
+ enable_thinking = _reasoning_effort_enables_thinking(self.reasoning_effort)
1996
+ if enable_thinking is not None:
1997
+ payload["enable_thinking"] = enable_thinking
1998
+ reasoning_effort = _documented_reasoning_effort(
1999
+ provider_key=reasoning_provider_key,
2000
+ model=self.model,
2001
+ reasoning_effort=self.reasoning_effort,
2002
+ )
2003
+ if enable_thinking is not False and reasoning_effort:
2004
+ payload["reasoning_effort"] = reasoning_effort
2005
+ thinking_active = enable_thinking is True
2006
+ elif _is_deepseek_provider(reasoning_provider_key):
2007
+ thinking_enabled = deepseek_thinking_enabled
2008
+ if thinking_enabled is not None:
2009
+ payload["thinking"] = {"type": "enabled" if thinking_enabled else "disabled"}
2010
+ reasoning_effort = _documented_reasoning_effort(
2011
+ provider_key=reasoning_provider_key,
2012
+ model=self.model,
2013
+ reasoning_effort=self.reasoning_effort,
2014
+ )
2015
+ if thinking_enabled is not False and reasoning_effort:
2016
+ payload["reasoning_effort"] = reasoning_effort
2017
+ thinking_active = (
2018
+ _reasoning_contract_active(
2019
+ provider_key=reasoning_provider_key,
2020
+ model=self.model,
2021
+ enable_thinking=self.enable_thinking,
2022
+ reasoning_effort=self.reasoning_effort,
2023
+ )
2024
+ is True
2025
+ )
2026
+ elif _is_together_deepseek_pro(transport_provider_key, self.model):
2027
+ # Together exposes DeepSeek's effort field but uses its own object
2028
+ # shape for disabling reasoning. The model otherwise reasons by
2029
+ # default, so omission preserves provider behavior.
2030
+ if self.enable_thinking is False:
2031
+ payload["reasoning"] = {"enabled": False}
2032
+ else:
2033
+ reasoning_effort = _documented_reasoning_effort(
2034
+ provider_key=_TOGETHER_PROVIDER_KEY,
2035
+ model=self.model,
2036
+ reasoning_effort=self.reasoning_effort,
2037
+ )
2038
+ if reasoning_effort:
2039
+ payload["reasoning_effort"] = reasoning_effort
2040
+ elif _is_openrouter_provider(reasoning_provider_key):
2041
+ reasoning = _openrouter_reasoning_payload(
2042
+ enable_thinking=self.enable_thinking,
2043
+ reasoning_effort=self.reasoning_effort,
2044
+ )
2045
+ if reasoning is not None:
2046
+ payload["reasoning"] = reasoning
2047
+ thinking_active = reasoning is not None and reasoning.get("enabled") is not False
2048
+ elif _is_gemini_provider(reasoning_provider_key):
2049
+ reasoning_effort = _gemini_reasoning_effort(
2050
+ model=self.model,
2051
+ reasoning_effort=self.reasoning_effort,
2052
+ )
2053
+ if reasoning_effort:
2054
+ payload["reasoning_effort"] = reasoning_effort
2055
+ elif _is_nvidia_provider(reasoning_provider_key):
2056
+ # NVIDIA hosts models from many vendors behind one endpoint. Request
2057
+ # shaping is therefore model-contract-driven: the host alone never
2058
+ # implies Nemotron controls, and unknown models receive no guessed
2059
+ # reasoning parameter.
2060
+ contract = reasoning_contract_for(_NVIDIA_PROVIDER_KEY, self.model)
2061
+ thinking_enabled = self.enable_thinking
2062
+ if thinking_enabled is None:
2063
+ thinking_enabled = _reasoning_effort_enables_thinking(self.reasoning_effort)
2064
+ chat_template_kwargs: dict[str, bool] = {}
2065
+ if contract.wire == WIRE_REASONING_EFFORT:
2066
+ if thinking_enabled is False and contract.off == OFF_EXPLICIT:
2067
+ payload["reasoning_effort"] = "none"
2068
+ elif thinking_enabled is not False:
2069
+ reasoning_effort = _documented_reasoning_effort(
2070
+ provider_key=_NVIDIA_PROVIDER_KEY,
2071
+ model=self.model,
2072
+ reasoning_effort=self.reasoning_effort,
2073
+ )
2074
+ if reasoning_effort:
2075
+ payload["reasoning_effort"] = reasoning_effort
2076
+ elif (
2077
+ contract.wire == WIRE_CHAT_TEMPLATE_ENABLE_THINKING and thinking_enabled is not None
2078
+ ):
2079
+ chat_template_kwargs["enable_thinking"] = thinking_enabled
2080
+ if chat_template_kwargs:
2081
+ payload["chat_template_kwargs"] = chat_template_kwargs
2082
+ else:
2083
+ # Any researched provider contract that uses the flat Chat
2084
+ # Completions effort field can opt in here without another
2085
+ # provider-specific request branch. Unknown routes retain the
2086
+ # legacy OpenAI/Azure/Mistral behavior and receive no newly guessed
2087
+ # controls.
2088
+ documented_effort = _documented_flat_reasoning_effort(
2089
+ provider_key=reasoning_provider_key,
2090
+ model=self.model,
2091
+ enable_thinking=self.enable_thinking,
2092
+ reasoning_effort=self.reasoning_effort,
2093
+ )
2094
+ if documented_effort:
2095
+ payload["reasoning_effort"] = documented_effort
2096
+ elif _uses_reasoning_effort(reasoning_provider_key) and self.reasoning_effort:
2097
+ payload["reasoning_effort"] = self.reasoning_effort
2098
+ # A reasoning model in thinking mode can 400 on any tool_choice parameter.
2099
+ # Omitting it preserves the default "auto" behavior while keeping tools
2100
+ # available to the model.
2101
+ if (
2102
+ thinking_active
2103
+ and tool_choice is not None
2104
+ and (
2105
+ not reasoning_contract.accepts_tool_choice_while_reasoning
2106
+ or _tool_choice_forces_a_call(tool_choice)
2107
+ )
2108
+ ):
2109
+ tool_choice = None
2110
+ transport_metadata["tool_choice_omitted"] = True
2111
+ transport_metadata["tool_choice_omit_reason"] = "thinking_mode"
2112
+ elif cached_tool_choice_compat_disabled and tool_choice is not None:
2113
+ tool_choice = None
2114
+ transport_metadata["tool_choice_omitted"] = True
2115
+ transport_metadata["tool_choice_omit_reason"] = "cached_provider_rejection"
2116
+ if cached_tool_calling_compat_disabled and (tools or tool_choice is not None):
2117
+ tools = None
2118
+ tool_choice = None
2119
+ transport_metadata["tools_omitted"] = True
2120
+ transport_metadata["tools_omit_reason"] = "cached_provider_rejection"
2121
+ if tools:
2122
+ payload["tools"] = tools
2123
+ if tool_choice is not None:
2124
+ payload["tool_choice"] = tool_choice
2125
+ elif tool_choice is not None:
2126
+ payload["tool_choice"] = tool_choice
2127
+ if response_format:
2128
+ payload["response_format"] = response_format
2129
+ if max_tokens is not None:
2130
+ output_limit_field = (
2131
+ "max_completion_tokens"
2132
+ if _is_moonshot_provider(transport_provider_key)
2133
+ else "max_tokens"
2134
+ )
2135
+ payload[output_limit_field] = int(max_tokens)
2136
+ if stream:
2137
+ payload["stream"] = True
2138
+ payload["stream_options"] = {"include_usage": True}
2139
+ provider_key = (
2140
+ transport_provider_key
2141
+ or self.provider_key
2142
+ or best_effort_provider_key(
2143
+ base_url=self.base_url,
2144
+ model=self.model,
2145
+ )
2146
+ )
2147
+ cache_policy = merge_cache_policy_metadata(
2148
+ self.prompt_cache_policy_metadata,
2149
+ RequestCachePlan(
2150
+ strategy=(
2151
+ "openai_prompt_cache"
2152
+ if active_prompt_cache_key or active_prompt_cache_retention
2153
+ else "none"
2154
+ ),
2155
+ mode=(
2156
+ "automatic"
2157
+ if active_prompt_cache_key or active_prompt_cache_retention
2158
+ else "manual"
2159
+ ),
2160
+ prompt_cache_key=active_prompt_cache_key,
2161
+ prompt_cache_retention=active_prompt_cache_retention,
2162
+ ).openai_prompt_cache_policy_metadata(),
2163
+ )
2164
+ if CACHE_CONTROL_FIELD in active_cache_field_values:
2165
+ application = apply_openai_compatible_cache_control_breakpoint(
2166
+ payload.get("messages", []),
2167
+ cache_policy=cache_policy,
2168
+ )
2169
+ payload["messages"] = application.messages
2170
+ if cache_policy is not None:
2171
+ cache_policy = merge_cache_policy_metadata(
2172
+ cache_policy,
2173
+ application.policy_metadata(),
2174
+ )
2175
+ if disabled_prompt_cache_fields:
2176
+ cache_policy = _cache_policy_after_fields_disabled(
2177
+ cache_policy,
2178
+ fields=disabled_prompt_cache_fields,
2179
+ fallback="runtime_disabled_rejected_cache_fields",
2180
+ )
2181
+ # Sampling determinism controls, applied last so this is the single
2182
+ # place temperature/top_p/seed can enter the request. With none of the
2183
+ # three configured -- the default -- this writes nothing and the
2184
+ # payload keeps the exact keys, order and values the branches above
2185
+ # produced, so the request stays byte-identical to the pre-PR6
2186
+ # transport. The temperature override is declined whenever the
2187
+ # transport already rewrote or omitted the field for
2188
+ # provider-compatibility reasons, because that adjustment exists to
2189
+ # avoid a 400 this must not reintroduce.
2190
+ sampling_settings = active_sampling_settings()
2191
+ applied_sampling_fields = apply_sampling_to_payload(
2192
+ payload,
2193
+ sampling_settings,
2194
+ allow_temperature_override=not transport_metadata.get("temperature_adjusted"),
2195
+ )
2196
+ if applied_sampling_fields:
2197
+ transport_metadata["sampling_fields_applied"] = list(applied_sampling_fields)
2198
+ payload = _sanitize_transport_value(payload)
2199
+ prompt_estimation_payload = {
2200
+ "messages": payload.get("messages", []),
2201
+ }
2202
+ for key in ("tools", "response_format"):
2203
+ if key in payload:
2204
+ prompt_estimation_payload[key] = payload[key]
2205
+ request_shape = build_request_shape_report(
2206
+ messages=messages,
2207
+ tools=tools,
2208
+ cache_policy=cache_policy,
2209
+ provider_payload=prompt_estimation_payload,
2210
+ )
2211
+ input_estimate_tokens = estimate_provider_payload_tokens(prompt_estimation_payload)
2212
+ layout_plan = LLMRequestPlan.from_chat_args(
2213
+ messages=messages,
2214
+ tools=tools,
2215
+ tool_choice=tool_choice,
2216
+ response_format=response_format,
2217
+ stream=stream,
2218
+ temperature=temperature,
2219
+ max_tokens=max_tokens,
2220
+ cache=RequestCachePlan(
2221
+ strategy=(
2222
+ "openai_prompt_cache"
2223
+ if active_prompt_cache_key or active_prompt_cache_retention
2224
+ else "none"
2225
+ ),
2226
+ mode=(
2227
+ "automatic"
2228
+ if active_prompt_cache_key or active_prompt_cache_retention
2229
+ else "manual"
2230
+ ),
2231
+ prompt_cache_key=active_prompt_cache_key,
2232
+ prompt_cache_retention=active_prompt_cache_retention,
2233
+ ),
2234
+ )
2235
+ request_plan_metadata = layout_plan.request_plan_metadata(
2236
+ input_mode="full",
2237
+ continuation_strategy="full_replay",
2238
+ provider_payload=prompt_estimation_payload,
2239
+ sent_provider_payload=prompt_estimation_payload,
2240
+ cache_policy_metadata=cache_policy,
2241
+ )
2242
+ token_reconciliation = {
2243
+ "input_estimate_tokens": input_estimate_tokens,
2244
+ "sent_input_estimate_tokens": input_estimate_tokens,
2245
+ "estimator": "cl100k_base",
2246
+ "estimate_basis": "provider_prompt_payload",
2247
+ "input_mode": "full",
2248
+ }
2249
+ telemetry = ProviderCallTelemetryRecorder(
2250
+ provider_key=provider_key,
2251
+ protocol="openai_compat",
2252
+ model=self.model,
2253
+ base_url=self.base_url,
2254
+ stream=stream,
2255
+ tools=tools,
2256
+ cache_policy=cache_policy,
2257
+ request_plan=request_plan_metadata,
2258
+ request_shape=request_shape,
2259
+ token_reconciliation=token_reconciliation,
2260
+ operation="chat_completions",
2261
+ sampling=sampling_settings,
2262
+ )
2263
+ telemetry_on_text_delta = telemetry.wrap_text_delta(on_text_delta)
2264
+ telemetry_on_reasoning_delta = telemetry.wrap_reasoning_delta(on_reasoning_delta)
2265
+ stream_restart_count = 0
2266
+ stream_restart_reason = ""
2267
+ any_text_delta_emitted = False
2268
+
2269
+ def _record_retry(attempt: int, reason: str, wait_seconds: float) -> None:
2270
+ nonlocal stream_restart_count, stream_restart_reason, any_text_delta_emitted
2271
+ telemetry.on_retry(attempt, reason, wait_seconds)
2272
+ if stream and str(reason).startswith("provider_stream_"):
2273
+ stream_restart_count += 1
2274
+ stream_restart_reason = str(reason)
2275
+ transport_metadata["stream_restart_count"] = stream_restart_count
2276
+ transport_metadata["stream_restart_reason"] = stream_restart_reason
2277
+ if stream and any_text_delta_emitted:
2278
+ # Tokens from the abandoned attempt already rendered; give the
2279
+ # surface a chance to reset its live block before the retry
2280
+ # restreams the reply (duck-typed hook — see the chat loop's
2281
+ # _on_text_delta). Observers must never change retry behavior.
2282
+ any_text_delta_emitted = False
2283
+ restart_hook = getattr(on_text_delta, "stream_restart", None)
2284
+ if callable(restart_hook):
2285
+ try:
2286
+ restart_hook()
2287
+ except Exception: # noqa: BLE001
2288
+ _LOGGER.debug("stream_restart_hook_failed", exc_info=True)
2289
+
2290
+ def _send_request() -> LLMResponse:
2291
+ nonlocal cache_policy, request_plan_metadata, request_shape, token_reconciliation
2292
+ nonlocal stream_restart_count, stream_restart_reason
2293
+ temperature_retry_count = 0
2294
+ temperature_retry_modes: set[str] = set()
2295
+ cache_param_retry_used = False
2296
+ try:
2297
+ with httpx.Client(
2298
+ timeout=_httpx_request_timeout(self.timeout_s),
2299
+ transport=self._transport,
2300
+ ) as client:
2301
+ while True:
2302
+ try:
2303
+ if stream:
2304
+ with client.stream(
2305
+ "POST", url, headers=headers, json=payload
2306
+ ) as resp:
2307
+ # Before parsing: the routing headers are
2308
+ # the evidence for which backend answered,
2309
+ # and they must survive a stream that then
2310
+ # fails or is retried.
2311
+ telemetry.set_response_headers(resp.headers)
2312
+ attempt_deltas: list[str] = []
2313
+ attempt_reasoning_deltas: list[str] = []
2314
+
2315
+ def _attempt_text_delta(
2316
+ delta: str,
2317
+ *,
2318
+ _attempt_deltas: list[str] = attempt_deltas,
2319
+ ) -> None:
2320
+ nonlocal any_text_delta_emitted
2321
+ _attempt_deltas.append(delta)
2322
+ if delta:
2323
+ any_text_delta_emitted = True
2324
+ if telemetry_on_text_delta is not None:
2325
+ telemetry_on_text_delta(delta)
2326
+
2327
+ def _attempt_reasoning_delta(
2328
+ delta: str,
2329
+ *,
2330
+ _attempt_reasoning_deltas: list[str] = (
2331
+ attempt_reasoning_deltas
2332
+ ),
2333
+ ) -> None:
2334
+ _attempt_reasoning_deltas.append(delta)
2335
+ if telemetry_on_reasoning_delta is not None:
2336
+ telemetry_on_reasoning_delta(delta)
2337
+
2338
+ try:
2339
+ response = self._parse_stream_response(
2340
+ resp,
2341
+ on_text_delta=(
2342
+ _attempt_text_delta
2343
+ if on_text_delta is not None
2344
+ else None
2345
+ ),
2346
+ on_reasoning_delta=(
2347
+ _attempt_reasoning_delta
2348
+ if on_reasoning_delta is not None
2349
+ else None
2350
+ ),
2351
+ provider_key=reasoning_provider_key,
2352
+ cancellation_token=cancellation_token,
2353
+ )
2354
+ except Exception as stream_error:
2355
+ if isinstance(stream_error, DeadlineExhausted):
2356
+ raise
2357
+ if attempt_deltas or attempt_reasoning_deltas:
2358
+ stream_restart_count += 1
2359
+ stream_restart_reason = (
2360
+ provider_unavailable_retry_reason(stream_error)
2361
+ or "provider_stream_interrupted"
2362
+ )
2363
+ transport_metadata["stream_restart_count"] = (
2364
+ stream_restart_count
2365
+ )
2366
+ transport_metadata["stream_restart_reason"] = (
2367
+ stream_restart_reason
2368
+ )
2369
+ # Once public output reached the UI, replaying the
2370
+ # request would duplicate text/trace content —
2371
+ # UNLESS the delta callback carries the
2372
+ # stream_restart reset channel, in which case the
2373
+ # surface erases the partial output right before
2374
+ # the retry restreams (the hook fires from
2375
+ # _record_retry, i.e. only when a retry actually
2376
+ # runs), and replaying is safe.
2377
+ surface_can_reset = callable(
2378
+ getattr(on_text_delta, "stream_restart", None)
2379
+ )
2380
+ if surface_can_reset:
2381
+ raise
2382
+ if isinstance(stream_error, LLMError):
2383
+ mark_provider_call_non_retryable(stream_error)
2384
+ raise
2385
+ interrupted = LLMError(
2386
+ "LLM stream interrupted after partial output: "
2387
+ f"{stream_error}"
2388
+ )
2389
+ mark_provider_call_non_retryable(interrupted)
2390
+ raise interrupted from stream_error
2391
+ raise
2392
+ if stream_restart_count:
2393
+ response = _response_with_stream_restart_metadata(
2394
+ response,
2395
+ count=stream_restart_count,
2396
+ reason=stream_restart_reason,
2397
+ )
2398
+ else:
2399
+ resp = client.post(url, headers=headers, json=payload)
2400
+ # Captured before the status check so a 4xx/5xx
2401
+ # keeps its request id, which is usually the
2402
+ # only handle a provider accepts when asked
2403
+ # what happened to a specific call.
2404
+ telemetry.set_response_headers(resp.headers)
2405
+ if resp.status_code >= 400:
2406
+ raise self._error_from_response(resp)
2407
+ response = self._parse_non_stream_response(
2408
+ resp,
2409
+ provider_key=reasoning_provider_key,
2410
+ model=self.model,
2411
+ )
2412
+ except LLMError as e:
2413
+ if stream and _is_stream_options_unsupported_error(e):
2414
+ payload.pop("stream_options", None)
2415
+ continue
2416
+ rejected_cache_fields = _cache_param_rejected_fields(
2417
+ e,
2418
+ payload=payload,
2419
+ headers=headers,
2420
+ )
2421
+ if rejected_cache_fields and not cache_param_retry_used:
2422
+ cache_param_retry_used = True
2423
+ disabled_fields = self._disable_prompt_cache_fields(
2424
+ rejected_cache_fields
2425
+ )
2426
+ _strip_cache_request_fields(
2427
+ payload=payload,
2428
+ headers=headers,
2429
+ fields=rejected_cache_fields,
2430
+ )
2431
+ cache_policy = _cache_policy_after_fields_disabled(
2432
+ cache_policy,
2433
+ fields=disabled_fields or rejected_cache_fields,
2434
+ fallback="stripped_rejected_cache_fields",
2435
+ )
2436
+ prompt_estimation_payload = {
2437
+ "messages": payload.get("messages", []),
2438
+ }
2439
+ for key in ("tools", "response_format"):
2440
+ if key in payload:
2441
+ prompt_estimation_payload[key] = payload[key]
2442
+ request_shape = build_request_shape_report(
2443
+ messages=messages,
2444
+ tools=tools,
2445
+ cache_policy=cache_policy,
2446
+ provider_payload=prompt_estimation_payload,
2447
+ input_mode="cache_param_fallback",
2448
+ )
2449
+ request_plan_metadata = layout_plan.request_plan_metadata(
2450
+ input_mode="cache_param_fallback",
2451
+ continuation_strategy="full_replay",
2452
+ provider_payload=prompt_estimation_payload,
2453
+ sent_provider_payload=prompt_estimation_payload,
2454
+ cache_policy_metadata=cache_policy,
2455
+ extra={"fallback_used": True},
2456
+ )
2457
+ fallback_input_estimate = estimate_provider_payload_tokens(
2458
+ prompt_estimation_payload
2459
+ )
2460
+ token_reconciliation = {
2461
+ **token_reconciliation,
2462
+ "input_estimate_tokens": fallback_input_estimate,
2463
+ "sent_input_estimate_tokens": fallback_input_estimate,
2464
+ "input_mode": "cache_param_fallback",
2465
+ }
2466
+ telemetry.set_cache_policy(cache_policy)
2467
+ telemetry.set_request_plan(request_plan_metadata)
2468
+ telemetry.set_request_shape(request_shape)
2469
+ telemetry.set_token_reconciliation(token_reconciliation)
2470
+ transport_metadata["cache_param_fallback_used"] = True
2471
+ transport_metadata["cache_param_retry_used"] = True
2472
+ transport_metadata["cache_param_disabled_fields"] = list(
2473
+ disabled_fields or rejected_cache_fields
2474
+ )
2475
+ _LOGGER.info(
2476
+ "llm_cache_parameter_rejected_retrying_without_cache_fields",
2477
+ extra={
2478
+ "provider_key": provider_key,
2479
+ "model": self.model,
2480
+ "disabled_fields": list(
2481
+ disabled_fields or rejected_cache_fields
2482
+ ),
2483
+ },
2484
+ )
2485
+ continue
2486
+ if "tool_choice" in payload and _tool_choice_unsupported_error(e):
2487
+ rejected_tool_choice = payload.pop("tool_choice", None)
2488
+ self._mark_tool_choice_compat_disabled(tool_choice_key)
2489
+ transport_metadata["tool_choice_omitted"] = True
2490
+ transport_metadata["tool_choice_omit_reason"] = (
2491
+ "provider_rejected_parameter"
2492
+ )
2493
+ transport_metadata["tool_choice_retry_used"] = True
2494
+ _LOGGER.info(
2495
+ "llm_tool_choice_parameter_rejected_retrying_without_it",
2496
+ extra={
2497
+ "provider_key": provider_key,
2498
+ "model": self.model,
2499
+ "tool_choice": rejected_tool_choice,
2500
+ },
2501
+ )
2502
+ continue
2503
+ if "tools" in payload and _tool_calling_unsupported_error(e):
2504
+ payload.pop("tools", None)
2505
+ payload.pop("tool_choice", None)
2506
+ self._mark_tool_calling_compat_disabled(tool_calling_key)
2507
+ fallback_prompt_payload: dict[str, Any] = {
2508
+ "messages": payload.get("messages", []),
2509
+ }
2510
+ if "response_format" in payload:
2511
+ fallback_prompt_payload["response_format"] = payload[
2512
+ "response_format"
2513
+ ]
2514
+ request_shape = build_request_shape_report(
2515
+ messages=messages,
2516
+ tools=None,
2517
+ cache_policy=cache_policy,
2518
+ provider_payload=fallback_prompt_payload,
2519
+ input_mode="tool_calling_fallback",
2520
+ )
2521
+ fallback_layout = LLMRequestPlan.from_chat_args(
2522
+ messages=messages,
2523
+ tools=None,
2524
+ tool_choice=None,
2525
+ response_format=response_format,
2526
+ stream=stream,
2527
+ temperature=temperature,
2528
+ max_tokens=max_tokens,
2529
+ cache=layout_plan.cache,
2530
+ )
2531
+ request_plan_metadata = fallback_layout.request_plan_metadata(
2532
+ input_mode="tool_calling_fallback",
2533
+ continuation_strategy="full_replay",
2534
+ provider_payload=fallback_prompt_payload,
2535
+ sent_provider_payload=fallback_prompt_payload,
2536
+ cache_policy_metadata=cache_policy,
2537
+ extra={"fallback_used": True},
2538
+ )
2539
+ fallback_input_estimate = estimate_provider_payload_tokens(
2540
+ fallback_prompt_payload
2541
+ )
2542
+ token_reconciliation = {
2543
+ **token_reconciliation,
2544
+ "input_estimate_tokens": fallback_input_estimate,
2545
+ "sent_input_estimate_tokens": fallback_input_estimate,
2546
+ "input_mode": "tool_calling_fallback",
2547
+ }
2548
+ telemetry.set_request_plan(request_plan_metadata)
2549
+ telemetry.set_request_shape(request_shape)
2550
+ telemetry.set_token_reconciliation(token_reconciliation)
2551
+ transport_metadata["tools_omitted"] = True
2552
+ transport_metadata["tools_omit_reason"] = (
2553
+ "provider_rejected_tool_calling"
2554
+ )
2555
+ transport_metadata["tools_retry_used"] = True
2556
+ _LOGGER.info(
2557
+ "llm_tool_calling_rejected_retrying_without_tools",
2558
+ extra={
2559
+ "provider_key": provider_key,
2560
+ "model": self.model,
2561
+ "base_url_descriptor": endpoint_descriptor(self.base_url),
2562
+ },
2563
+ )
2564
+ continue
2565
+ if "temperature" in payload and _temperature_unsupported_error(e):
2566
+ rejected_temperature = payload.get("temperature")
2567
+ compat_mode = _temperature_compat_mode_for_error(
2568
+ e,
2569
+ current_temperature=rejected_temperature,
2570
+ )
2571
+ if compat_mode is None or compat_mode in temperature_retry_modes:
2572
+ raise
2573
+ temperature_retry_modes.add(compat_mode)
2574
+ temperature_retry_count += 1
2575
+ self._mark_temperature_compat_mode(temperature_key, compat_mode)
2576
+ transport_metadata["temperature_adjusted"] = True
2577
+ transport_metadata["temperature_adjustment"] = compat_mode
2578
+ transport_metadata["temperature_adjustment_reason"] = (
2579
+ "provider_rejected_parameter"
2580
+ )
2581
+ transport_metadata["temperature_retry_used"] = True
2582
+ transport_metadata["temperature_retry_count"] = (
2583
+ temperature_retry_count
2584
+ )
2585
+ if compat_mode == _TEMPERATURE_COMPAT_MODE_DEFAULT:
2586
+ payload["temperature"] = _TEMPERATURE_DEFAULT_VALUE
2587
+ else:
2588
+ payload.pop("temperature", None)
2589
+ transport_metadata["temperature_omitted"] = True
2590
+ transport_metadata["temperature_omit_reason"] = (
2591
+ "provider_rejected_parameter"
2592
+ )
2593
+ _LOGGER.info(
2594
+ "llm_temperature_parameter_rejected_retrying_with_compat_mode",
2595
+ extra={
2596
+ "provider_key": provider_key,
2597
+ "model": self.model,
2598
+ "temperature": rejected_temperature,
2599
+ "temperature_compat_mode": compat_mode,
2600
+ },
2601
+ )
2602
+ continue
2603
+ raise
2604
+ if not stream and telemetry_on_reasoning_delta is not None:
2605
+ for reasoning_output in response.reasoning:
2606
+ if reasoning_output.kind == ReasoningOutputKind.SUMMARY:
2607
+ telemetry_on_reasoning_delta(reasoning_output.text)
2608
+ return _merge_transport_metadata(
2609
+ _merge_request_plan_metadata(
2610
+ response,
2611
+ request_plan_metadata=request_plan_metadata,
2612
+ ),
2613
+ transport_metadata=transport_metadata,
2614
+ )
2615
+ except LLMError:
2616
+ raise
2617
+ except DeadlineExhausted:
2618
+ raise
2619
+ except httpx.DecodingError as e:
2620
+ raise LLMError(
2621
+ f"LLM response decompression failed: {sanitize_error_text_for_output(e)}"
2622
+ ) from e
2623
+ except Exception as e: # noqa: BLE001 - network errors vary
2624
+ if _is_connect_failure(e):
2625
+ raise LLMError(
2626
+ "LLM request failed for "
2627
+ f"{endpoint_label(self.base_url)}: {sanitize_error_text_for_output(e)}"
2628
+ ) from e
2629
+ if _is_read_timeout(e):
2630
+ raise LLMError(
2631
+ "LLM request failed for "
2632
+ f"{endpoint_label(self.base_url)}: {sanitize_error_text_for_output(e)}"
2633
+ ) from e
2634
+ raise LLMError(f"LLM request failed: {sanitize_error_text_for_output(e)}") from e
2635
+
2636
+ return stamp_response_for_route(
2637
+ telemetry.run(
2638
+ lambda: run_provider_limited_call(
2639
+ call=_send_request,
2640
+ provider_key=provider_key,
2641
+ provider_concurrency_caps=self.provider_concurrency_caps,
2642
+ retry_settings=self.provider_retry_settings,
2643
+ operation="chat_completions",
2644
+ sleep_fn=self._provider_sleep_fn,
2645
+ random_fn=self._provider_random_fn,
2646
+ on_retry=_record_retry,
2647
+ on_retry_event=self._provider_retry_event_observer,
2648
+ retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
2649
+ retry_wall_clock_cap_seconds=getattr(
2650
+ self,
2651
+ "_provider_retry_wall_clock_cap_seconds",
2652
+ _PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS,
2653
+ ),
2654
+ )
2655
+ ),
2656
+ self.route_identity,
2657
+ )
2658
+
2659
+ @staticmethod
2660
+ def _parse_non_stream_response(
2661
+ resp: httpx.Response,
2662
+ *,
2663
+ provider_key: str | None,
2664
+ model: str | None = None,
2665
+ ) -> LLMResponse:
2666
+ try:
2667
+ data = resp.json()
2668
+ except Exception as e: # noqa: BLE001
2669
+ raise LLMError("LLM returned non-JSON response") from e
2670
+
2671
+ if not isinstance(data, dict):
2672
+ raise LLMError("Unexpected LLM response shape: expected a JSON object")
2673
+ choices = data.get("choices")
2674
+ if not isinstance(choices, list) or not choices:
2675
+ raise LLMError("Unexpected LLM response shape: missing choices[0]")
2676
+ choice0 = choices[0]
2677
+ if not isinstance(choice0, dict) or not isinstance(choice0.get("message"), dict):
2678
+ raise LLMError("Unexpected LLM response shape: missing choices[0].message")
2679
+ msg = choice0["message"]
2680
+
2681
+ content = _normalize_assistant_content_to_text(msg.get("content"))
2682
+ tool_calls_raw = msg.get("tool_calls") or []
2683
+ tool_calls = _parse_tool_calls(tool_calls_raw)
2684
+ response_model = data.get("model") if isinstance(data.get("model"), str) else None
2685
+ return LLMResponse(
2686
+ content=content,
2687
+ tool_calls=tool_calls,
2688
+ raw=data,
2689
+ response_model=response_model,
2690
+ usage=_parse_usage(data.get("usage"), provider_key=provider_key),
2691
+ provider_metadata=_provider_metadata_for_reasoning(
2692
+ provider_key=provider_key,
2693
+ message=msg,
2694
+ model=model,
2695
+ ),
2696
+ reasoning=_reasoning_outputs_from_message(msg, provider_key=provider_key),
2697
+ )
2698
+
2699
+ @staticmethod
2700
+ def _error_from_response(resp: httpx.Response) -> LLMError:
2701
+ try:
2702
+ body = resp.text
2703
+ except Exception:
2704
+ body = "<unable to read response body>"
2705
+ return _error_from_status_body(status_code=resp.status_code, body=body)
2706
+
2707
+ def _parse_stream_response(
2708
+ self,
2709
+ resp: httpx.Response,
2710
+ *,
2711
+ on_text_delta: Callable[[str], None] | None,
2712
+ on_reasoning_delta: Callable[[str], None] | None = None,
2713
+ provider_key: str | None,
2714
+ cancellation_token: Any | None = None,
2715
+ ) -> LLMResponse:
2716
+ if resp.status_code >= 400:
2717
+ body = self._safe_error_body(resp)
2718
+ raise _error_from_status_body(status_code=resp.status_code, body=body)
2719
+
2720
+ content_parts: list[str] = []
2721
+ tool_chunks: dict[int, dict[str, Any]] = {}
2722
+ event_count = 0
2723
+ response_model: str | None = None
2724
+ # Streaming chunks carry system_fingerprint the same way they carry
2725
+ # model. The non-stream path keeps the whole body in ``raw``, so it
2726
+ # already has it; without this the streaming path -- which is the
2727
+ # default -- would silently record no fingerprint at all.
2728
+ system_fingerprint: str | None = None
2729
+ usage: LLMUsage | None = None
2730
+ accumulated_content = ""
2731
+ reasoning_parts: list[str] = []
2732
+ reasoning_details: list[Any] = []
2733
+ reasoning_summary_parts: dict[str, str] = {}
2734
+ mistral_content_chunks: list[dict[str, Any]] = []
2735
+ saw_done = False
2736
+ progress_clock = self._stream_progress_clock
2737
+ last_meaningful_progress = progress_clock()
2738
+
2739
+ # Make the (possibly long) initial read interruptible: register the live
2740
+ # response's close so a cancel from another thread unblocks iter_lines, and
2741
+ # re-check the flag per line. A close mid-read surfaces as a read error that
2742
+ # we translate into a clean interrupt below.
2743
+ _set_abort = getattr(cancellation_token, "set_abort_callback", None)
2744
+ _clear_abort = getattr(cancellation_token, "clear_abort_callback", None)
2745
+ if callable(_set_abort):
2746
+ _set_abort(resp.close)
2747
+ _stream_iter = resp.iter_lines()
2748
+ while True:
2749
+ try:
2750
+ line = next(_stream_iter)
2751
+ except StopIteration:
2752
+ break
2753
+ except Exception:
2754
+ if cancellation_token is not None and getattr(
2755
+ cancellation_token, "is_cancelled", False
2756
+ ):
2757
+ raise KeyboardInterrupt("cancelled_by_user") from None
2758
+ raise
2759
+ if cancellation_token is not None and getattr(
2760
+ cancellation_token, "is_cancelled", False
2761
+ ):
2762
+ raise KeyboardInterrupt("cancelled_by_user")
2763
+ stream_deadline_exhausted = getattr(
2764
+ self,
2765
+ "_stream_deadline_exhausted",
2766
+ None,
2767
+ )
2768
+ if callable(stream_deadline_exhausted) and stream_deadline_exhausted():
2769
+ raise DeadlineExhausted("run deadline and in-flight provider grace elapsed")
2770
+ progress_now = progress_clock()
2771
+ if progress_now - last_meaningful_progress >= self.stream_no_progress_timeout_s:
2772
+ raise LLMStreamNoProgressError(
2773
+ "LLM stream produced no meaningful payload within "
2774
+ f"{self.stream_no_progress_timeout_s:g}s."
2775
+ )
2776
+ if not line:
2777
+ continue
2778
+ if isinstance(line, bytes):
2779
+ text = line.decode("utf-8", errors="ignore")
2780
+ else:
2781
+ text = line
2782
+ if not text.startswith("data:"):
2783
+ continue
2784
+ payload = text[5:].strip()
2785
+ if not payload:
2786
+ continue
2787
+ if payload == "[DONE]":
2788
+ last_meaningful_progress = progress_now
2789
+ saw_done = True
2790
+ break
2791
+
2792
+ try:
2793
+ event = json.loads(payload)
2794
+ except json.JSONDecodeError:
2795
+ continue
2796
+ if not isinstance(event, dict):
2797
+ continue
2798
+ last_meaningful_progress = progress_now
2799
+ event_count += 1
2800
+ model = event.get("model")
2801
+ if isinstance(model, str) and model:
2802
+ response_model = model
2803
+ chunk_fingerprint = event.get("system_fingerprint")
2804
+ if isinstance(chunk_fingerprint, str) and chunk_fingerprint:
2805
+ system_fingerprint = chunk_fingerprint
2806
+ parsed_usage = _parse_usage(event.get("usage"), provider_key=provider_key)
2807
+ if parsed_usage is not None:
2808
+ usage = parsed_usage
2809
+
2810
+ choices = event.get("choices") or []
2811
+ if not isinstance(choices, list) or not choices:
2812
+ continue
2813
+ choice0 = choices[0]
2814
+ if not isinstance(choice0, dict):
2815
+ continue
2816
+ delta = choice0.get("delta") or {}
2817
+ if not isinstance(delta, dict):
2818
+ continue
2819
+
2820
+ reasoning_delta = delta.get(_DEEPSEEK_REASONING_CONTENT_KEY)
2821
+ if not isinstance(reasoning_delta, str):
2822
+ reasoning_delta = delta.get(_OPENROUTER_REASONING_KEY)
2823
+ if isinstance(reasoning_delta, str) and reasoning_delta:
2824
+ reasoning_parts.append(reasoning_delta)
2825
+ details_delta = delta.get(_OPENROUTER_REASONING_DETAILS_KEY)
2826
+ if isinstance(details_delta, list) and details_delta:
2827
+ reasoning_details.extend(details_delta)
2828
+ for detail_index, detail in enumerate(details_delta):
2829
+ parsed = (
2830
+ _text_from_reasoning_detail(detail) if isinstance(detail, dict) else None
2831
+ )
2832
+ if parsed is None:
2833
+ continue
2834
+ detail_text, detail_kind = parsed
2835
+ if detail_kind != ReasoningOutputKind.SUMMARY:
2836
+ continue
2837
+ key = str(detail.get("id") or detail.get("index") or f"summary_{detail_index}")
2838
+ previous = reasoning_summary_parts.get(key, "")
2839
+ suffix = _stream_delta_suffix(previous=previous, incoming=detail_text)
2840
+ if not suffix:
2841
+ continue
2842
+ reasoning_summary_parts[key] = previous + suffix
2843
+ if on_reasoning_delta is not None:
2844
+ on_reasoning_delta(suffix)
2845
+
2846
+ raw_content_delta = delta.get("content")
2847
+ if _is_mistral_provider(provider_key):
2848
+ _append_mistral_stream_content(mistral_content_chunks, raw_content_delta)
2849
+ content_delta = _normalize_assistant_content_to_text(raw_content_delta)
2850
+ if content_delta:
2851
+ content_suffix = _stream_delta_suffix(
2852
+ previous=accumulated_content,
2853
+ incoming=content_delta,
2854
+ )
2855
+ if content_suffix:
2856
+ content_parts.append(content_suffix)
2857
+ accumulated_content += content_suffix
2858
+ if on_text_delta is not None:
2859
+ on_text_delta(content_suffix)
2860
+
2861
+ tc_delta = delta.get("tool_calls") or []
2862
+ if not isinstance(tc_delta, list):
2863
+ continue
2864
+ for raw_tc in tc_delta:
2865
+ if not isinstance(raw_tc, dict):
2866
+ continue
2867
+ idx = raw_tc.get("index")
2868
+ if not isinstance(idx, int):
2869
+ continue
2870
+ entry = tool_chunks.setdefault(
2871
+ idx,
2872
+ {"id": "", "name": "", "arguments": "", "provider_metadata": None},
2873
+ )
2874
+
2875
+ tc_id = raw_tc.get("id")
2876
+ if isinstance(tc_id, str) and tc_id:
2877
+ entry["id"] = tc_id
2878
+
2879
+ provider_metadata = _gemini_tool_call_provider_metadata(raw_tc)
2880
+ if provider_metadata:
2881
+ existing_metadata = entry.get("provider_metadata")
2882
+ entry["provider_metadata"] = _merge_provider_metadata(
2883
+ existing_metadata if isinstance(existing_metadata, dict) else None,
2884
+ provider_metadata,
2885
+ )
2886
+
2887
+ fn = raw_tc.get("function")
2888
+ if not isinstance(fn, dict):
2889
+ continue
2890
+ name = fn.get("name")
2891
+ if isinstance(name, str) and name:
2892
+ entry["name"] = name
2893
+ args_piece = fn.get("arguments")
2894
+ if isinstance(args_piece, str):
2895
+ entry["arguments"] += _stream_delta_suffix(
2896
+ previous=entry["arguments"],
2897
+ incoming=args_piece,
2898
+ )
2899
+
2900
+ if callable(_clear_abort):
2901
+ _clear_abort()
2902
+ if cancellation_token is not None and getattr(cancellation_token, "is_cancelled", False):
2903
+ # Stream ended because the abort closed it (clean EOF, not an error).
2904
+ raise KeyboardInterrupt("cancelled_by_user")
2905
+ if not saw_done:
2906
+ raise LLMError("LLM stream truncated before [DONE]")
2907
+ streamed_tool_calls = _parse_stream_tool_calls(tool_chunks)
2908
+ streamed_content = "".join(content_parts)
2909
+ reasoning_message = {
2910
+ _DEEPSEEK_REASONING_CONTENT_KEY: "".join(reasoning_parts),
2911
+ _OPENROUTER_REASONING_KEY: "".join(reasoning_parts),
2912
+ _OPENROUTER_REASONING_DETAILS_KEY: reasoning_details,
2913
+ "content": mistral_content_chunks,
2914
+ }
2915
+ stream_raw: dict[str, Any] = {"stream": True, "events": event_count}
2916
+ if system_fingerprint:
2917
+ # Added only when the provider actually sent one, so a provider
2918
+ # that never emits it leaves ``raw`` exactly as before.
2919
+ stream_raw["system_fingerprint"] = system_fingerprint
2920
+ return LLMResponse(
2921
+ content=streamed_content,
2922
+ tool_calls=streamed_tool_calls,
2923
+ raw=stream_raw,
2924
+ response_model=response_model,
2925
+ usage=usage,
2926
+ provider_metadata=_provider_metadata_for_reasoning(
2927
+ provider_key=provider_key,
2928
+ message=reasoning_message,
2929
+ model=self.model,
2930
+ ),
2931
+ reasoning=_reasoning_outputs_from_message(
2932
+ reasoning_message,
2933
+ provider_key=provider_key,
2934
+ ),
2935
+ )
2936
+
2937
+ @staticmethod
2938
+ def _safe_error_body(resp: httpx.Response) -> str:
2939
+ try:
2940
+ resp.read()
2941
+ except Exception:
2942
+ pass
2943
+ try:
2944
+ body = resp.text
2945
+ except Exception:
2946
+ body = "<unable to read response body>"
2947
+ return body