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,2604 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import re
8
+ from collections.abc import Callable, Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from ..error_text import sanitize_error_text_for_output
15
+ from ..provider_auth import ProviderAuthAdapter
16
+ from ..provider_telemetry import ProviderCallTelemetryRecorder
17
+ from ..request_estimation import estimate_provider_payload_tokens
18
+ from ..web_search_adapters import AUTO_WEB_SEARCH_ADAPTER, OPENAI_RESPONSES_ADAPTER
19
+ from .cache_policy import merge_cache_policy_metadata
20
+ from .metadata import (
21
+ OPENAI_RESPONSES_PROVIDER_METADATA_KEY,
22
+ PROVIDER_METADATA_KEY,
23
+ ProviderRouteIdentity,
24
+ build_provider_route_identity,
25
+ canonicalize_extra_headers,
26
+ credential_scope_fingerprint,
27
+ gate_messages_for_provider_route,
28
+ merge_canonical_headers,
29
+ stamp_response_for_route,
30
+ )
31
+ from .provider_limits import (
32
+ DEFAULT_PROVIDER_CONCURRENCY_CAPS,
33
+ ProviderRetrySettings,
34
+ best_effort_provider_key,
35
+ mark_provider_call_non_retryable,
36
+ run_provider_limited_call,
37
+ )
38
+ from .request_plan import LLMRequestPlan, RequestCachePlan
39
+ from .request_shape import build_request_shape_report
40
+ from .streaming import SSEFrame, iter_sse_frames, parse_sse_json_frame
41
+ from .types import (
42
+ AssistantResponsePhase,
43
+ InputTokenCount,
44
+ LLMError,
45
+ LLMResponse,
46
+ LLMUsage,
47
+ ReasoningOutput,
48
+ ReasoningOutputKind,
49
+ ToolCall,
50
+ UsageConfidence,
51
+ UsageContract,
52
+ )
53
+ from .usage_normalization import parse_compatible_usage
54
+
55
+
56
+ class ResponsesError(RuntimeError):
57
+ def __init__(self, message: object = "") -> None:
58
+ super().__init__(sanitize_error_text_for_output(message))
59
+
60
+
61
+ _DEFAULT_ACCEPT_ENCODING = "identity"
62
+ _PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS = 60.0
63
+ _OPENAI_RESPONSES_METADATA_KEY = OPENAI_RESPONSES_PROVIDER_METADATA_KEY
64
+ _WEB_SEARCH_MODES_ALLOWING_OPENAI_BUILTIN = frozenset({"auto", "native"})
65
+ _RESPONSES_TOOL_CHOICE_STRINGS = frozenset({"auto", "none", "required"})
66
+ _RESPONSES_REASONING_EFFORTS = frozenset(
67
+ {"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
68
+ )
69
+ _RESPONSES_JSON_SCHEMA_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
70
+ _ALYSIS_WEB_SEARCH_FUNCTION_NAME = "web_search"
71
+ _RESPONSES_HOSTED_WEB_SEARCH_TYPES = frozenset({"web_search", "web_search_preview"})
72
+ _MIN_RESPONSES_OUTPUT_TOKENS = 16
73
+ _LOGGER = logging.getLogger(__name__)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class _ResponsesContinuation:
78
+ previous_response_id: str
79
+ suffix_messages: list[dict[str, Any]]
80
+ anchor_index: int
81
+
82
+
83
+ def _stable_request_signature(value: Any) -> str:
84
+ payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
85
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
86
+
87
+
88
+ def _non_negative_int(value: Any) -> int | None:
89
+ try:
90
+ parsed = int(value)
91
+ except (TypeError, ValueError):
92
+ return None
93
+ return parsed if parsed >= 0 else None
94
+
95
+
96
+ def _clamp_responses_max_output_tokens(value: int) -> int:
97
+ requested = int(value)
98
+ if requested >= _MIN_RESPONSES_OUTPUT_TOKENS:
99
+ return requested
100
+ _LOGGER.warning(
101
+ "OpenAI Responses max_output_tokens adjusted from %d to documented minimum %d",
102
+ requested,
103
+ _MIN_RESPONSES_OUTPUT_TOKENS,
104
+ )
105
+ return _MIN_RESPONSES_OUTPUT_TOKENS
106
+
107
+
108
+ # Some models (notably the GPT-5 reasoning family) reject a non-default
109
+ # ``temperature`` on the Responses API with a 400/422 ("temperature is not
110
+ # supported … only the default (1) value is supported"). Unlike the chat-compat
111
+ # client, this one had no fallback, so such a model failed on every call — and on
112
+ # setup-wizard key/model validation. We now detect that error once per
113
+ # (base_url, model), drop ``temperature`` from the payload, retry the offending
114
+ # call immediately, and omit it from every later call to the same model.
115
+ _TEMPERATURE_UNSUPPORTED_STATUS_MARKERS = ("error 400", "error 422")
116
+ _TEMPERATURE_UNSUPPORTED_TOKENS = (
117
+ "unsupported",
118
+ "not support",
119
+ "not allowed",
120
+ "only the default",
121
+ "out of range",
122
+ "deprecated",
123
+ )
124
+ _RESPONSES_OMIT_TEMPERATURE_MODELS: set[str] = set()
125
+
126
+ # Endpoints (base_url + model) whose gateway rejected the optional ``include``
127
+ # entries (e.g. ``web_search_call.action.sources``). Source metadata is an
128
+ # enhancement, not a requirement, so once a gateway rejects it we stop sending
129
+ # it for the rest of the process instead of failing every request.
130
+ _RESPONSES_OMIT_INCLUDE_ENDPOINTS: set[str] = set()
131
+
132
+ _INCLUDE_UNSUPPORTED_STATUS_MARKERS = ("error 400", "error 422", "web_search unsupported")
133
+ _INCLUDE_VALUE_CONTEXT_TOKENS = (
134
+ "web_search_call.action.sources",
135
+ "include value",
136
+ "include values",
137
+ "'include'",
138
+ '"include"',
139
+ "include parameter",
140
+ "include field",
141
+ "param 'include'",
142
+ 'param "include"',
143
+ "parameter: include",
144
+ "parameter 'include'",
145
+ )
146
+ _INCLUDE_UNSUPPORTED_TOKENS = (
147
+ "invalid",
148
+ "unsupported",
149
+ "not support",
150
+ "not allowed",
151
+ "not permitted",
152
+ "unknown",
153
+ "unrecognized",
154
+ "unexpected",
155
+ "cannot be produced",
156
+ "cannot",
157
+ "forbidden",
158
+ "must be omitted",
159
+ "extra",
160
+ )
161
+
162
+ _REASONING_SUMMARY_UNSUPPORTED_STATUS_MARKERS = ("error 400", "error 422")
163
+ _REASONING_SUMMARY_UNSUPPORTED_TOKENS = (
164
+ "invalid",
165
+ "unsupported",
166
+ "not support",
167
+ "not allowed",
168
+ "not permitted",
169
+ "unknown",
170
+ "unknown parameter",
171
+ "unknown field",
172
+ "unrecognized",
173
+ "unexpected",
174
+ "invalid parameter",
175
+ "extra",
176
+ "extra inputs",
177
+ "forbidden",
178
+ "cannot be set",
179
+ "must be omitted",
180
+ )
181
+
182
+
183
+ def _responses_endpoint_model_key(base_url: str, model: str) -> str:
184
+ return f"{str(base_url).strip().rstrip('/')}\n{str(model).strip()}"
185
+
186
+
187
+ def _responses_temperature_omit_key(base_url: str, model: str) -> str:
188
+ return _responses_endpoint_model_key(base_url, model)
189
+
190
+
191
+ def _responses_include_unsupported(err: Exception) -> bool:
192
+ """Return whether a 400/422 clearly rejects the optional ``include`` entries.
193
+
194
+ Requires an explicit ``include``-value context token so unrelated 400s that
195
+ merely contain the word "include" (e.g. "input must include a message") do
196
+ not permanently drop source metadata for the endpoint.
197
+ """
198
+
199
+ text = str(err).casefold()
200
+ if not any(marker in text for marker in _INCLUDE_UNSUPPORTED_STATUS_MARKERS):
201
+ return False
202
+ if not any(token in text for token in _INCLUDE_VALUE_CONTEXT_TOKENS):
203
+ return False
204
+ return any(token in text for token in _INCLUDE_UNSUPPORTED_TOKENS)
205
+
206
+
207
+ def _responses_temperature_unsupported(err: Exception) -> bool:
208
+ text = str(err).casefold()
209
+ if "temperature" not in text:
210
+ return False
211
+ if not any(marker in text for marker in _TEMPERATURE_UNSUPPORTED_STATUS_MARKERS):
212
+ return False
213
+ return any(token in text for token in _TEMPERATURE_UNSUPPORTED_TOKENS)
214
+
215
+
216
+ def _responses_reasoning_summary_unsupported(err: Exception) -> bool:
217
+ """Return whether a 400/422 clearly rejects ``reasoning.summary``.
218
+
219
+ Keep this deliberately narrower than the generic unsupported-parameter
220
+ fallback: an unrelated response summary error must not disable reasoning
221
+ summaries for the rest of the client session.
222
+ """
223
+
224
+ text = str(err).casefold()
225
+ if not any(marker in text for marker in _REASONING_SUMMARY_UNSUPPORTED_STATUS_MARKERS):
226
+ return False
227
+ if "summary" not in text:
228
+ return False
229
+ if not any(
230
+ context in text
231
+ for context in (
232
+ "reasoning",
233
+ "parameter",
234
+ "field",
235
+ "request argument",
236
+ "extra inputs",
237
+ )
238
+ ):
239
+ return False
240
+ return any(token in text for token in _REASONING_SUMMARY_UNSUPPORTED_TOKENS)
241
+
242
+
243
+ def _without_responses_reasoning_summary(payload: dict[str, Any]) -> bool:
244
+ """Remove only ``reasoning.summary`` from an already-adapted payload."""
245
+
246
+ raw_reasoning = payload.get("reasoning")
247
+ if not isinstance(raw_reasoning, dict) or "summary" not in raw_reasoning:
248
+ return False
249
+ reasoning = copy.deepcopy(raw_reasoning)
250
+ reasoning.pop("summary", None)
251
+ if reasoning:
252
+ payload["reasoning"] = reasoning
253
+ else:
254
+ payload.pop("reasoning", None)
255
+ return True
256
+
257
+
258
+ def _headers_with_default_accept_encoding(headers: dict[str, str]) -> dict[str, str]:
259
+ request_headers = dict(headers)
260
+ if not any(key.lower() == "accept-encoding" for key in request_headers):
261
+ request_headers["accept-encoding"] = _DEFAULT_ACCEPT_ENCODING
262
+ return request_headers
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class WebSearchCitation:
267
+ title: str
268
+ url: str
269
+ start_index: int | None = None
270
+ end_index: int | None = None
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class WebSearchSource:
275
+ url: str
276
+ title: str = ""
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class WebSearchResponse:
281
+ answer: str
282
+ citations: list[WebSearchCitation]
283
+ sources: list[WebSearchSource]
284
+ queries: list[str]
285
+ raw: dict[str, Any]
286
+ response_id: str | None = None
287
+ model: str | None = None
288
+
289
+
290
+ def _coerce_int(value: Any) -> int | None:
291
+ try:
292
+ return int(value)
293
+ except (TypeError, ValueError):
294
+ return None
295
+
296
+
297
+ def _extract_error_message(data: Any) -> str | None:
298
+ if not isinstance(data, dict):
299
+ return None
300
+ error_obj = data.get("error")
301
+ if isinstance(error_obj, dict):
302
+ message = str(error_obj.get("message") or "").strip()
303
+ if message:
304
+ return message
305
+ return None
306
+
307
+
308
+ def _parse_arguments(args_s: Any) -> dict[str, Any]:
309
+ if isinstance(args_s, dict):
310
+ return dict(args_s)
311
+ if not isinstance(args_s, str):
312
+ args_s = json.dumps(args_s if args_s is not None else {})
313
+ try:
314
+ args = json.loads(args_s)
315
+ except json.JSONDecodeError:
316
+ return {"_raw_arguments": args_s}
317
+ if not isinstance(args, dict):
318
+ return {"_raw_arguments": args_s}
319
+ return args
320
+
321
+
322
+ def _json_arguments(args: Any) -> str:
323
+ if isinstance(args, str):
324
+ try:
325
+ parsed = json.loads(args)
326
+ except json.JSONDecodeError:
327
+ return args
328
+ return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
329
+ if args is None:
330
+ return "{}"
331
+ return json.dumps(args, ensure_ascii=False, separators=(",", ":"))
332
+
333
+
334
+ def _content_to_text(raw: Any) -> str:
335
+ if raw is None:
336
+ return ""
337
+ if isinstance(raw, str):
338
+ return raw
339
+ if isinstance(raw, list):
340
+ parts: list[str] = []
341
+ for item in raw:
342
+ if isinstance(item, str):
343
+ parts.append(item)
344
+ elif isinstance(item, dict):
345
+ text = item.get("text") or item.get("content")
346
+ if isinstance(text, str):
347
+ parts.append(text)
348
+ return "".join(parts)
349
+ if isinstance(raw, dict):
350
+ text = raw.get("text") or raw.get("content")
351
+ return text if isinstance(text, str) else ""
352
+ return str(raw)
353
+
354
+
355
+ def _responses_message_content(raw: Any, *, role: str) -> str | list[dict[str, Any]]:
356
+ if raw is None:
357
+ return ""
358
+ if isinstance(raw, str):
359
+ return raw
360
+ if not isinstance(raw, list):
361
+ return _content_to_text(raw)
362
+
363
+ parts: list[dict[str, Any]] = []
364
+ for item in raw:
365
+ if isinstance(item, str):
366
+ text = item
367
+ if text:
368
+ parts.append(
369
+ {"type": "output_text" if role == "assistant" else "input_text", "text": text}
370
+ )
371
+ continue
372
+ if not isinstance(item, dict):
373
+ continue
374
+ part_type = str(item.get("type") or "").strip()
375
+ text = item.get("text") or item.get("content")
376
+ if part_type in {"text", "input_text", "output_text"} and isinstance(text, str):
377
+ parts.append(
378
+ {
379
+ "type": "output_text" if role == "assistant" else "input_text",
380
+ "text": text,
381
+ }
382
+ )
383
+ continue
384
+ if role != "user":
385
+ continue
386
+ if part_type == "image_url":
387
+ image_url = item.get("image_url")
388
+ url = ""
389
+ if isinstance(image_url, dict):
390
+ url = str(image_url.get("url") or "").strip()
391
+ elif isinstance(image_url, str):
392
+ url = image_url.strip()
393
+ if url:
394
+ parts.append({"type": "input_image", "image_url": url})
395
+ continue
396
+ if part_type == "input_image":
397
+ copied = {key: copy.deepcopy(value) for key, value in item.items()}
398
+ if copied.get("image_url") or copied.get("file_id"):
399
+ parts.append(copied)
400
+ return parts if parts else ""
401
+
402
+
403
+ def _chat_tool_call_parts(raw_tool_call: Any) -> tuple[str, str, str] | None:
404
+ if not isinstance(raw_tool_call, dict):
405
+ return None
406
+ call_id = str(raw_tool_call.get("id") or raw_tool_call.get("call_id") or "").strip()
407
+ function = raw_tool_call.get("function")
408
+ if isinstance(function, dict):
409
+ name = str(function.get("name") or "").strip()
410
+ arguments = _json_arguments(function.get("arguments"))
411
+ else:
412
+ name = str(raw_tool_call.get("name") or "").strip()
413
+ arguments = _json_arguments(raw_tool_call.get("arguments"))
414
+ if not name:
415
+ return None
416
+ if not call_id:
417
+ call_id = f"call_{name}"
418
+ return call_id, name, arguments
419
+
420
+
421
+ def _metadata_output_items(message: dict[str, Any]) -> list[dict[str, Any]]:
422
+ metadata = message.get(PROVIDER_METADATA_KEY)
423
+ if not isinstance(metadata, dict):
424
+ return []
425
+ responses_metadata = metadata.get(_OPENAI_RESPONSES_METADATA_KEY)
426
+ if not isinstance(responses_metadata, dict):
427
+ return []
428
+ output_items = responses_metadata.get("output_items")
429
+ if not isinstance(output_items, list):
430
+ return []
431
+ copied: list[dict[str, Any]] = []
432
+ for item in output_items:
433
+ if isinstance(item, dict):
434
+ copied.append(copy.deepcopy(item))
435
+ return copied
436
+
437
+
438
+ def _metadata_responses_payload(message: dict[str, Any]) -> dict[str, Any] | None:
439
+ metadata = message.get(PROVIDER_METADATA_KEY)
440
+ if not isinstance(metadata, dict):
441
+ return None
442
+ responses_metadata = metadata.get(_OPENAI_RESPONSES_METADATA_KEY)
443
+ if not isinstance(responses_metadata, dict):
444
+ return None
445
+ return responses_metadata
446
+
447
+
448
+ def _response_with_request_plan_metadata(
449
+ response: LLMResponse,
450
+ request_plan_metadata: dict[str, Any],
451
+ ) -> LLMResponse:
452
+ provider_metadata = copy.deepcopy(response.provider_metadata) or {}
453
+ responses_metadata = provider_metadata.setdefault(_OPENAI_RESPONSES_METADATA_KEY, {})
454
+ if isinstance(responses_metadata, dict):
455
+ responses_metadata["request_plan"] = copy.deepcopy(request_plan_metadata)
456
+ return LLMResponse(
457
+ content=response.content,
458
+ tool_calls=response.tool_calls,
459
+ raw=response.raw,
460
+ response_model=response.response_model,
461
+ usage=response.usage,
462
+ provider_metadata=provider_metadata,
463
+ reasoning=response.reasoning,
464
+ assistant_phase=response.assistant_phase,
465
+ )
466
+
467
+
468
+ def _responses_continuation_from_messages(
469
+ messages: list[dict[str, Any]],
470
+ ) -> _ResponsesContinuation | None:
471
+ for index in range(len(messages) - 1, -1, -1):
472
+ message = messages[index]
473
+ if not isinstance(message, dict):
474
+ continue
475
+ if str(message.get("role") or "").strip() != "assistant":
476
+ continue
477
+ responses_metadata = _metadata_responses_payload(message)
478
+ if not responses_metadata:
479
+ continue
480
+ previous_response_id = str(responses_metadata.get("response_id") or "").strip()
481
+ request_plan_metadata = responses_metadata.get("request_plan")
482
+ if not previous_response_id or not isinstance(request_plan_metadata, dict):
483
+ continue
484
+ request_message_count = _non_negative_int(
485
+ request_plan_metadata.get("request_message_count")
486
+ )
487
+ request_messages_signature = str(
488
+ request_plan_metadata.get("request_messages_signature") or ""
489
+ ).strip()
490
+ if request_message_count != index or not request_messages_signature:
491
+ continue
492
+ prefix_messages = messages[:index]
493
+ if _stable_request_signature(prefix_messages) != request_messages_signature:
494
+ continue
495
+ suffix_messages = messages[index + 1 :]
496
+ if not suffix_messages:
497
+ continue
498
+ return _ResponsesContinuation(
499
+ previous_response_id=previous_response_id,
500
+ suffix_messages=copy.deepcopy(suffix_messages),
501
+ anchor_index=index,
502
+ )
503
+ return None
504
+
505
+
506
+ def _responses_previous_response_rejected(err: Exception) -> bool:
507
+ text = str(err).casefold()
508
+ if "previous_response_id" in text:
509
+ return True
510
+ return "previous response" in text and any(
511
+ marker in text
512
+ for marker in ("not found", "invalid", "expired", "unknown", "does not exist")
513
+ )
514
+
515
+
516
+ def _responses_input_from_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
517
+ input_items: list[dict[str, Any]] = []
518
+ for message in messages:
519
+ if not isinstance(message, dict):
520
+ continue
521
+ role = str(message.get("role") or "").strip()
522
+ if role in {"system", "developer", "user"}:
523
+ input_items.append(
524
+ {
525
+ "role": role,
526
+ "content": _responses_message_content(message.get("content"), role=role),
527
+ }
528
+ )
529
+ continue
530
+ if role == "assistant":
531
+ metadata_items = _metadata_output_items(message)
532
+ if metadata_items:
533
+ input_items.extend(metadata_items)
534
+ continue
535
+
536
+ content = _responses_message_content(message.get("content"), role=role)
537
+ if content:
538
+ input_items.append({"role": "assistant", "content": content})
539
+ raw_tool_calls = message.get("tool_calls")
540
+ if isinstance(raw_tool_calls, list):
541
+ for raw_tool_call in raw_tool_calls:
542
+ parts = _chat_tool_call_parts(raw_tool_call)
543
+ if parts is None:
544
+ continue
545
+ call_id, name, arguments = parts
546
+ input_items.append(
547
+ {
548
+ "type": "function_call",
549
+ "call_id": call_id,
550
+ "name": name,
551
+ "arguments": arguments,
552
+ }
553
+ )
554
+ continue
555
+ if role == "tool":
556
+ call_id = str(message.get("tool_call_id") or message.get("call_id") or "").strip()
557
+ if not call_id:
558
+ raise LLMError("OpenAI Responses tool message is missing tool_call_id")
559
+ input_items.append(
560
+ {
561
+ "type": "function_call_output",
562
+ "call_id": call_id,
563
+ "output": _content_to_text(message.get("content")),
564
+ }
565
+ )
566
+ continue
567
+ raise LLMError(f"OpenAI Responses cannot send message role {role!r}")
568
+ return input_items
569
+
570
+
571
+ def _assistant_output_parts(data: dict[str, Any]) -> list[dict[str, Any]]:
572
+ output = data.get("output")
573
+ if not isinstance(output, list):
574
+ return []
575
+ parts: list[dict[str, Any]] = []
576
+ for item in output:
577
+ if not isinstance(item, dict):
578
+ continue
579
+ if str(item.get("type") or "") != "message":
580
+ continue
581
+ if str(item.get("role") or "") != "assistant":
582
+ continue
583
+ content = item.get("content")
584
+ if not isinstance(content, list):
585
+ continue
586
+ for part in content:
587
+ if isinstance(part, dict):
588
+ parts.append(part)
589
+ return parts
590
+
591
+
592
+ def _extract_answer_text(data: dict[str, Any]) -> str:
593
+ output_text = data.get("output_text")
594
+ if isinstance(output_text, str) and output_text.strip():
595
+ return output_text
596
+
597
+ text_parts: list[str] = []
598
+ for part in _assistant_output_parts(data):
599
+ part_type = str(part.get("type") or "")
600
+ if part_type not in {"output_text", "text"}:
601
+ continue
602
+ text = part.get("text")
603
+ if isinstance(text, str):
604
+ text_parts.append(text)
605
+ return "".join(text_parts)
606
+
607
+
608
+ def _assistant_response_phase(data: dict[str, Any]) -> AssistantResponsePhase | None:
609
+ """Normalize a provider-declared assistant phase without inferring from prose."""
610
+
611
+ output = data.get("output")
612
+ if not isinstance(output, list):
613
+ return None
614
+ for item in reversed(output):
615
+ if not isinstance(item, dict):
616
+ continue
617
+ if str(item.get("type") or "") != "message":
618
+ continue
619
+ if str(item.get("role") or "") != "assistant":
620
+ continue
621
+ try:
622
+ return AssistantResponsePhase(str(item.get("phase") or "").strip())
623
+ except ValueError:
624
+ return None
625
+ return None
626
+
627
+
628
+ def _extract_citations(data: dict[str, Any]) -> list[WebSearchCitation]:
629
+ citations: list[WebSearchCitation] = []
630
+ for part in _assistant_output_parts(data):
631
+ annotations = part.get("annotations")
632
+ if not isinstance(annotations, list):
633
+ continue
634
+ for annotation in annotations:
635
+ if not isinstance(annotation, dict):
636
+ continue
637
+ if str(annotation.get("type") or "") != "url_citation":
638
+ continue
639
+ url = str(annotation.get("url") or "").strip()
640
+ if not url:
641
+ continue
642
+ citations.append(
643
+ WebSearchCitation(
644
+ title=str(annotation.get("title") or "").strip(),
645
+ url=url,
646
+ start_index=_coerce_int(annotation.get("start_index")),
647
+ end_index=_coerce_int(annotation.get("end_index")),
648
+ )
649
+ )
650
+ raw_citations = data.get("citations")
651
+ if isinstance(raw_citations, list):
652
+ for raw_citation in raw_citations:
653
+ citation = _coerce_citation(raw_citation)
654
+ if citation is not None:
655
+ citations.append(citation)
656
+ return _dedupe_citations(citations)
657
+
658
+
659
+ def _coerce_citation(raw_citation: Any) -> WebSearchCitation | None:
660
+ if isinstance(raw_citation, str):
661
+ url = raw_citation.strip()
662
+ if not url:
663
+ return None
664
+ return WebSearchCitation(title="", url=url)
665
+ if not isinstance(raw_citation, dict):
666
+ return None
667
+
668
+ citation_payload = raw_citation
669
+ for nested_key in ("url_citation", "web_citation", "x_citation"):
670
+ nested = raw_citation.get(nested_key)
671
+ if isinstance(nested, dict):
672
+ citation_payload = nested
673
+ break
674
+
675
+ url = str(
676
+ citation_payload.get("url")
677
+ or citation_payload.get("uri")
678
+ or citation_payload.get("link")
679
+ or ""
680
+ ).strip()
681
+ if not url:
682
+ return None
683
+ start_index = citation_payload.get("start_index")
684
+ if start_index is None:
685
+ start_index = citation_payload.get("startIndex")
686
+ end_index = citation_payload.get("end_index")
687
+ if end_index is None:
688
+ end_index = citation_payload.get("endIndex")
689
+ return WebSearchCitation(
690
+ title=str(citation_payload.get("title") or citation_payload.get("name") or "").strip(),
691
+ url=url,
692
+ start_index=_coerce_int(start_index),
693
+ end_index=_coerce_int(end_index),
694
+ )
695
+
696
+
697
+ def _dedupe_citations(citations: list[WebSearchCitation]) -> list[WebSearchCitation]:
698
+ deduped: list[WebSearchCitation] = []
699
+ seen: set[str] = set()
700
+ for citation in citations:
701
+ url = str(citation.url or "").strip()
702
+ if not url or url in seen:
703
+ continue
704
+ seen.add(url)
705
+ deduped.append(citation)
706
+ return deduped
707
+
708
+
709
+ def _extract_sources_and_queries(data: dict[str, Any]) -> tuple[list[WebSearchSource], list[str]]:
710
+ output = data.get("output")
711
+ if not isinstance(output, list):
712
+ return [], []
713
+
714
+ sources: list[WebSearchSource] = []
715
+ queries: list[str] = []
716
+ for item in output:
717
+ if not isinstance(item, dict):
718
+ continue
719
+ if str(item.get("type") or "") != "web_search_call":
720
+ continue
721
+ action = item.get("action")
722
+ if not isinstance(action, dict):
723
+ continue
724
+
725
+ raw_sources = action.get("sources")
726
+ if isinstance(raw_sources, list):
727
+ for raw_source in raw_sources:
728
+ if not isinstance(raw_source, dict):
729
+ continue
730
+ url = str(raw_source.get("url") or "").strip()
731
+ if not url:
732
+ continue
733
+ sources.append(
734
+ WebSearchSource(
735
+ url=url,
736
+ title=str(raw_source.get("title") or "").strip(),
737
+ )
738
+ )
739
+
740
+ raw_queries = action.get("queries")
741
+ if isinstance(raw_queries, list):
742
+ for raw_query in raw_queries:
743
+ query = str(raw_query or "").strip()
744
+ if query:
745
+ queries.append(query)
746
+ raw_query = action.get("query")
747
+ if isinstance(raw_query, str) and raw_query.strip():
748
+ queries.append(raw_query.strip())
749
+
750
+ return sources, queries
751
+
752
+
753
+ def _has_web_search_call_output(data: dict[str, Any]) -> bool:
754
+ output = data.get("output")
755
+ if not isinstance(output, list):
756
+ return False
757
+ return any(
758
+ isinstance(item, dict) and str(item.get("type") or "") == "web_search_call"
759
+ for item in output
760
+ )
761
+
762
+
763
+ def _merge_citation_sources(
764
+ sources: list[WebSearchSource],
765
+ citations: list[WebSearchCitation],
766
+ ) -> list[WebSearchSource]:
767
+ merged = list(sources)
768
+ seen = {str(source.url or "").strip() for source in merged if str(source.url or "").strip()}
769
+ for citation in citations:
770
+ url = str(citation.url or "").strip()
771
+ if not url or url in seen:
772
+ continue
773
+ seen.add(url)
774
+ merged.append(WebSearchSource(url=url, title=str(citation.title or "").strip()))
775
+ return merged
776
+
777
+
778
+ def _function_name_from_tool(tool: dict[str, Any]) -> str:
779
+ function = tool.get("function")
780
+ if isinstance(function, dict):
781
+ return str(function.get("name") or "").strip()
782
+ if str(tool.get("type") or "") == "function":
783
+ return str(tool.get("name") or "").strip()
784
+ return ""
785
+
786
+
787
+ def _responses_tool_from_chat_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
788
+ if not isinstance(tool, dict):
789
+ return None
790
+ tool_type = str(tool.get("type") or "").strip()
791
+ if tool_type == "function":
792
+ function = tool.get("function")
793
+ if isinstance(function, dict):
794
+ name = str(function.get("name") or "").strip()
795
+ if not name:
796
+ return None
797
+ mapped: dict[str, Any] = {
798
+ "type": "function",
799
+ "name": name,
800
+ "parameters": copy.deepcopy(function.get("parameters") or {"type": "object"}),
801
+ }
802
+ description = str(function.get("description") or "").strip()
803
+ if description:
804
+ mapped["description"] = description
805
+ strict = function.get("strict", tool.get("strict"))
806
+ if strict is not None:
807
+ mapped["strict"] = bool(strict)
808
+ return mapped
809
+
810
+ name = str(tool.get("name") or "").strip()
811
+ if not name:
812
+ return None
813
+ mapped = copy.deepcopy(tool)
814
+ mapped["type"] = "function"
815
+ return mapped
816
+ if tool_type in {"web_search", "web_search_preview"}:
817
+ return copy.deepcopy(tool)
818
+ raise LLMError(f"OpenAI Responses does not support tool type {tool_type!r}")
819
+
820
+
821
+ def _tools_contain_function(tools: list[dict[str, Any]] | None, name: str) -> bool:
822
+ if not tools:
823
+ return False
824
+ return any(_function_name_from_tool(tool) == name for tool in tools if isinstance(tool, dict))
825
+
826
+
827
+ def _is_alysis_web_search_function(tool: dict[str, Any]) -> bool:
828
+ return _function_name_from_tool(tool) == _ALYSIS_WEB_SEARCH_FUNCTION_NAME
829
+
830
+
831
+ def _is_responses_hosted_web_search_tool(tool: dict[str, Any]) -> bool:
832
+ return str(tool.get("type") or "").strip() in _RESPONSES_HOSTED_WEB_SEARCH_TYPES
833
+
834
+
835
+ def _openai_builtin_web_search_allowed(*, mode: str, adapter: str) -> bool:
836
+ normalized_mode = str(mode or "").strip().lower()
837
+ normalized_adapter = str(adapter or "").strip().lower() or AUTO_WEB_SEARCH_ADAPTER
838
+ if normalized_mode not in _WEB_SEARCH_MODES_ALLOWING_OPENAI_BUILTIN:
839
+ return False
840
+ return normalized_adapter in {AUTO_WEB_SEARCH_ADAPTER, OPENAI_RESPONSES_ADAPTER}
841
+
842
+
843
+ @dataclass(frozen=True)
844
+ class _ResponsesToolMapping:
845
+ tools: list[dict[str, Any]]
846
+ added_builtin_web_search: bool
847
+ removed_alysis_web_search: bool
848
+
849
+
850
+ def _responses_tools(
851
+ tools: list[dict[str, Any]] | None,
852
+ *,
853
+ mode: str,
854
+ adapter: str,
855
+ ) -> _ResponsesToolMapping:
856
+ normalized_mode = str(mode or "off").strip().lower()
857
+ normalized_adapter = (
858
+ str(adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower() or AUTO_WEB_SEARCH_ADAPTER
859
+ )
860
+ raw_tools = [tool for tool in tools or [] if isinstance(tool, dict)]
861
+ alysis_web_search_present = any(_is_alysis_web_search_function(tool) for tool in raw_tools)
862
+ use_openai_builtin_web_search = (
863
+ alysis_web_search_present
864
+ and _openai_builtin_web_search_allowed(
865
+ mode=normalized_mode,
866
+ adapter=normalized_adapter,
867
+ )
868
+ )
869
+ if (
870
+ normalized_mode == "native"
871
+ and alysis_web_search_present
872
+ and not use_openai_builtin_web_search
873
+ ):
874
+ raise LLMError(
875
+ "web_search_mode=native with protocol=openai_responses requires "
876
+ "web_search_adapter='auto' or 'openai_responses' for OpenAI hosted web_search; "
877
+ f"got {normalized_adapter!r}"
878
+ )
879
+
880
+ mapped_tools: list[dict[str, Any]] = []
881
+ removed_alysis_web_search = False
882
+ for tool in raw_tools:
883
+ if _is_alysis_web_search_function(tool):
884
+ if normalized_mode in {"off", "native"} or use_openai_builtin_web_search:
885
+ removed_alysis_web_search = True
886
+ continue
887
+ if _is_responses_hosted_web_search_tool(tool) and normalized_mode in {"off", "external"}:
888
+ continue
889
+ mapped = _responses_tool_from_chat_tool(tool)
890
+ if mapped is not None:
891
+ mapped_tools.append(mapped)
892
+ if use_openai_builtin_web_search and not any(
893
+ _is_responses_hosted_web_search_tool(tool) for tool in mapped_tools
894
+ ):
895
+ mapped_tools.append({"type": "web_search", "external_web_access": True})
896
+ return _ResponsesToolMapping(
897
+ tools=mapped_tools,
898
+ added_builtin_web_search=use_openai_builtin_web_search,
899
+ removed_alysis_web_search=removed_alysis_web_search,
900
+ )
901
+
902
+
903
+ def _tool_choice_for_mapped_tools(
904
+ tool_choice: Any,
905
+ *,
906
+ removed_alysis_web_search: bool,
907
+ ) -> Any:
908
+ if not removed_alysis_web_search:
909
+ return _responses_tool_choice(tool_choice)
910
+ if isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "function":
911
+ if "name" in tool_choice:
912
+ name = str(tool_choice.get("name") or "").strip()
913
+ else:
914
+ function = tool_choice.get("function")
915
+ name = str(function.get("name") or "").strip() if isinstance(function, dict) else ""
916
+ if name == _ALYSIS_WEB_SEARCH_FUNCTION_NAME:
917
+ raise LLMError(
918
+ "OpenAI Responses removed the Alysis Code web_search function for the selected "
919
+ "web_search_mode; do not force tool_choice to function web_search"
920
+ )
921
+ return _responses_tool_choice(tool_choice)
922
+
923
+
924
+ def _responses_tool_choice(tool_choice: Any) -> Any:
925
+ if tool_choice is None:
926
+ return None
927
+ if isinstance(tool_choice, str):
928
+ normalized = tool_choice.strip()
929
+ if normalized in _RESPONSES_TOOL_CHOICE_STRINGS:
930
+ return normalized
931
+ raise LLMError(f"OpenAI Responses does not support tool_choice={tool_choice!r}")
932
+ if not isinstance(tool_choice, dict):
933
+ raise LLMError("OpenAI Responses tool_choice must be a string or object")
934
+
935
+ choice_type = str(tool_choice.get("type") or "").strip()
936
+ if choice_type == "function":
937
+ if "name" in tool_choice:
938
+ name = str(tool_choice.get("name") or "").strip()
939
+ else:
940
+ function = tool_choice.get("function")
941
+ name = str(function.get("name") or "").strip() if isinstance(function, dict) else ""
942
+ if not name:
943
+ raise LLMError("OpenAI Responses forced function tool_choice is missing name")
944
+ return {"type": "function", "name": name}
945
+ if choice_type == "allowed_tools":
946
+ return copy.deepcopy(tool_choice)
947
+ if choice_type in {"web_search", "web_search_preview"}:
948
+ return copy.deepcopy(tool_choice)
949
+ raise LLMError(f"OpenAI Responses does not support tool_choice type {choice_type!r}")
950
+
951
+
952
+ def _responses_text_config(response_format: dict[str, Any] | None) -> dict[str, Any] | None:
953
+ if not response_format:
954
+ return None
955
+ if "format" in response_format and isinstance(response_format.get("format"), dict):
956
+ return copy.deepcopy(response_format)
957
+
958
+ response_type = str(response_format.get("type") or "").strip()
959
+ if response_type == "json_schema":
960
+ raw_json_schema = response_format.get("json_schema")
961
+ json_schema = raw_json_schema if isinstance(raw_json_schema, dict) else response_format
962
+ name = str(json_schema.get("name") or "").strip()
963
+ schema = json_schema.get("schema")
964
+ if not name or not _RESPONSES_JSON_SCHEMA_NAME_RE.fullmatch(name):
965
+ raise LLMError(
966
+ "OpenAI Responses json_schema response_format requires a valid name "
967
+ "(letters, digits, underscores, or dashes; max 64 chars)"
968
+ )
969
+ if not isinstance(schema, dict):
970
+ raise LLMError("OpenAI Responses json_schema response_format requires schema object")
971
+ fmt: dict[str, Any] = {
972
+ "type": "json_schema",
973
+ "name": name,
974
+ "schema": copy.deepcopy(schema),
975
+ }
976
+ description = str(json_schema.get("description") or "").strip()
977
+ if description:
978
+ fmt["description"] = description
979
+ if "strict" in json_schema:
980
+ fmt["strict"] = bool(json_schema.get("strict"))
981
+ return {"format": fmt}
982
+ if response_type in {"json_object", "text"}:
983
+ return {"format": {"type": response_type}}
984
+ raise LLMError(f"OpenAI Responses does not support response_format type {response_type!r}")
985
+
986
+
987
+ def _responses_reasoning(
988
+ *,
989
+ enable_thinking: bool | None,
990
+ reasoning_effort: str | None,
991
+ request_summary: bool = False,
992
+ ) -> dict[str, Any] | None:
993
+ reasoning: dict[str, Any] = {}
994
+ effort = str(reasoning_effort or "").strip().lower()
995
+ if effort:
996
+ if effort not in _RESPONSES_REASONING_EFFORTS:
997
+ raise LLMError(f"OpenAI Responses reasoning_effort is not supported: {effort}")
998
+ reasoning["effort"] = effort
999
+ elif enable_thinking is False:
1000
+ reasoning["effort"] = "none"
1001
+ if request_summary:
1002
+ # Summary visibility is independent from reasoning effort. In
1003
+ # particular, an automatic/default effort must remain omitted while
1004
+ # still asking the provider for the best supported summary.
1005
+ reasoning["summary"] = "auto"
1006
+ return reasoning or None
1007
+
1008
+
1009
+ def _parse_usage(raw: Any) -> LLMUsage | None:
1010
+ usage = parse_compatible_usage(raw, responses_shape=True)
1011
+ if usage is None:
1012
+ return None
1013
+ if (
1014
+ usage.prompt_tokens is None
1015
+ and usage.completion_tokens is None
1016
+ and usage.total_tokens is None
1017
+ and usage.cached_prompt_tokens is None
1018
+ and usage.cache_creation_input_tokens is None
1019
+ and usage.reasoning_tokens is None
1020
+ and usage.provider_cost_usd is None
1021
+ ):
1022
+ return None
1023
+ return usage
1024
+
1025
+
1026
+ def _citation_to_dict(citation: WebSearchCitation) -> dict[str, Any]:
1027
+ return {
1028
+ "title": citation.title,
1029
+ "url": citation.url,
1030
+ "start_index": citation.start_index,
1031
+ "end_index": citation.end_index,
1032
+ }
1033
+
1034
+
1035
+ def _source_to_dict(source: WebSearchSource) -> dict[str, Any]:
1036
+ return {"title": source.title, "url": source.url}
1037
+
1038
+
1039
+ def _responses_provider_metadata(data: dict[str, Any]) -> dict[str, Any] | None:
1040
+ metadata: dict[str, Any] = {}
1041
+ response_id = str(data.get("id") or "").strip()
1042
+ if response_id:
1043
+ metadata["response_id"] = response_id
1044
+ output = data.get("output")
1045
+ if isinstance(output, list):
1046
+ metadata["output_items"] = copy.deepcopy(output)
1047
+ web_search_calls = [
1048
+ copy.deepcopy(item)
1049
+ for item in output
1050
+ if isinstance(item, dict) and str(item.get("type") or "") == "web_search_call"
1051
+ ]
1052
+ if web_search_calls:
1053
+ metadata["web_search_calls"] = web_search_calls
1054
+ citations = [_citation_to_dict(citation) for citation in _extract_citations(data)]
1055
+ if citations:
1056
+ metadata["citations"] = citations
1057
+ sources, queries = _extract_sources_and_queries(data)
1058
+ sources = _merge_citation_sources(sources, _extract_citations(data))
1059
+ if sources:
1060
+ metadata["sources"] = [_source_to_dict(source) for source in sources]
1061
+ if queries:
1062
+ metadata["queries"] = list(queries)
1063
+ stream_metadata = data.get("stream_metadata")
1064
+ if isinstance(stream_metadata, dict):
1065
+ metadata["stream_metadata"] = copy.deepcopy(stream_metadata)
1066
+ return {_OPENAI_RESPONSES_METADATA_KEY: metadata} if metadata else None
1067
+
1068
+
1069
+ def _responses_reasoning_outputs(data: dict[str, Any]) -> tuple[ReasoningOutput, ...]:
1070
+ """Extract provider-generated summaries, never opaque/raw reasoning state."""
1071
+
1072
+ output = data.get("output")
1073
+ if not isinstance(output, list):
1074
+ return ()
1075
+ seen: set[str] = set()
1076
+ summaries: list[ReasoningOutput] = []
1077
+ for item in output:
1078
+ if not isinstance(item, dict) or str(item.get("type") or "") != "reasoning":
1079
+ continue
1080
+ parts = item.get("summary")
1081
+ if not isinstance(parts, list):
1082
+ continue
1083
+ for part in parts:
1084
+ if not isinstance(part, dict):
1085
+ continue
1086
+ if str(part.get("type") or "") not in {"summary_text", "text"}:
1087
+ continue
1088
+ summary = part.get("text")
1089
+ if not isinstance(summary, str) or not summary.strip() or summary in seen:
1090
+ continue
1091
+ seen.add(summary)
1092
+ summaries.append(
1093
+ ReasoningOutput(
1094
+ text=summary,
1095
+ kind=ReasoningOutputKind.SUMMARY,
1096
+ provider="openai",
1097
+ )
1098
+ )
1099
+ return tuple(summaries)
1100
+
1101
+
1102
+ def _has_responses_reasoning_output(data: dict[str, Any]) -> bool:
1103
+ """Return whether the provider produced a reasoning item of any visibility class."""
1104
+
1105
+ output = data.get("output")
1106
+ return isinstance(output, list) and any(
1107
+ isinstance(item, dict) and str(item.get("type") or "") == "reasoning" for item in output
1108
+ )
1109
+
1110
+
1111
+ def _extract_refusal(data: dict[str, Any]) -> str:
1112
+ output = data.get("output")
1113
+ if not isinstance(output, list):
1114
+ return ""
1115
+ refusals: list[str] = []
1116
+ for item in output:
1117
+ if not isinstance(item, dict):
1118
+ continue
1119
+ content = item.get("content")
1120
+ if not isinstance(content, list):
1121
+ continue
1122
+ for part in content:
1123
+ if not isinstance(part, dict):
1124
+ continue
1125
+ refusal = part.get("refusal")
1126
+ if isinstance(refusal, str) and refusal.strip():
1127
+ refusals.append(refusal.strip())
1128
+ if str(part.get("type") or "") == "refusal":
1129
+ text = part.get("text")
1130
+ if isinstance(text, str) and text.strip():
1131
+ refusals.append(text.strip())
1132
+ return "\n".join(refusals)
1133
+
1134
+
1135
+ def _parse_response_tool_calls(data: dict[str, Any]) -> list[ToolCall]:
1136
+ output = data.get("output")
1137
+ if not isinstance(output, list):
1138
+ return []
1139
+ tool_calls: list[ToolCall] = []
1140
+ for index, item in enumerate(output):
1141
+ if not isinstance(item, dict):
1142
+ continue
1143
+ if str(item.get("type") or "") != "function_call":
1144
+ continue
1145
+ call_id = str(item.get("call_id") or item.get("id") or f"call_{index}").strip()
1146
+ name = str(item.get("name") or "").strip()
1147
+ if not name:
1148
+ continue
1149
+ provider_metadata: dict[str, Any] = {
1150
+ _OPENAI_RESPONSES_METADATA_KEY: {
1151
+ "item_id": item.get("id"),
1152
+ "output_index": index,
1153
+ "status": item.get("status"),
1154
+ }
1155
+ }
1156
+ tool_calls.append(
1157
+ ToolCall(
1158
+ id=call_id,
1159
+ name=name,
1160
+ arguments=_parse_arguments(item.get("arguments") or "{}"),
1161
+ provider_metadata=provider_metadata,
1162
+ )
1163
+ )
1164
+ return tool_calls
1165
+
1166
+
1167
+ def _response_from_json(data: dict[str, Any]) -> httpx.Response:
1168
+ return httpx.Response(200, json=data)
1169
+
1170
+
1171
+ def _event_output_index(data: dict[str, Any], *, event_type: str) -> int:
1172
+ index = data.get("output_index")
1173
+ if isinstance(index, int) and index >= 0:
1174
+ return index
1175
+ raise LLMError(f"OpenAI Responses stream {event_type} event is missing output_index")
1176
+
1177
+
1178
+ def _event_content_index(data: dict[str, Any]) -> int:
1179
+ index = data.get("content_index")
1180
+ if isinstance(index, int) and index >= 0:
1181
+ return index
1182
+ return 0
1183
+
1184
+
1185
+ def _stream_error_message(data: dict[str, Any]) -> str:
1186
+ message = _extract_error_message(data)
1187
+ if message:
1188
+ return message
1189
+ response = data.get("response")
1190
+ if isinstance(response, dict):
1191
+ message = _extract_error_message(response)
1192
+ if message:
1193
+ return message
1194
+ status = str(response.get("status") or "").strip()
1195
+ incomplete = response.get("incomplete_details")
1196
+ if isinstance(incomplete, dict):
1197
+ reason = str(incomplete.get("reason") or "").strip()
1198
+ if reason:
1199
+ return f"status={status or 'incomplete'} reason={reason}"
1200
+ if status:
1201
+ return f"status={status}"
1202
+ return repr(data)
1203
+
1204
+
1205
+ class _OpenAIResponsesStreamAccumulator:
1206
+ def __init__(
1207
+ self,
1208
+ *,
1209
+ on_text_delta: Callable[[str], None] | None,
1210
+ on_reasoning_delta: Callable[[str], None] | None,
1211
+ ) -> None:
1212
+ self.on_text_delta = on_text_delta
1213
+ self.on_reasoning_delta = on_reasoning_delta
1214
+ self.response: dict[str, Any] = {"object": "response", "output": []}
1215
+ self.output_items: dict[int, dict[str, Any]] = {}
1216
+ self.text_parts: dict[tuple[int, int], dict[str, Any]] = {}
1217
+ self.reasoning_summary_parts: dict[tuple[int, int], dict[str, Any]] = {}
1218
+ self.reasoning_delta_keys: set[tuple[int, int] | None] = set()
1219
+ self.argument_chunks: dict[int, list[str]] = {}
1220
+ self.unknown_events: list[dict[str, Any]] = []
1221
+ self.event_count = 0
1222
+ self.text_delta_seen = False
1223
+ self.seen_final = False
1224
+ self.final_response: dict[str, Any] | None = None
1225
+
1226
+ def handle(self, frame: SSEFrame, data: dict[str, Any]) -> None:
1227
+ event_type = str(data.get("type") or frame.event or "").strip()
1228
+ if not event_type:
1229
+ self._append_unknown(frame=frame, data=data)
1230
+ return
1231
+ self.event_count += 1
1232
+
1233
+ if event_type == "error":
1234
+ raise LLMError(f"OpenAI Responses stream error: {_stream_error_message(data)}")
1235
+ if event_type in {"response.failed", "response.incomplete"}:
1236
+ raise LLMError(f"OpenAI Responses stream {event_type}: {_stream_error_message(data)}")
1237
+ if event_type in {"response.created", "response.in_progress", "response.queued"}:
1238
+ self._merge_response(data.get("response"))
1239
+ return
1240
+ if event_type in {"response.completed", "response.done"}:
1241
+ self._handle_final_response(data)
1242
+ return
1243
+ if event_type in {"response.output_item.added", "response.output_item.done"}:
1244
+ self._handle_output_item(data)
1245
+ return
1246
+ if event_type in {"response.content_part.added", "response.content_part.done"}:
1247
+ self._handle_content_part(data)
1248
+ return
1249
+ if event_type == "response.output_text.delta":
1250
+ self._handle_output_text_delta(data)
1251
+ return
1252
+ if event_type == "response.output_text.done":
1253
+ self._handle_output_text_done(data)
1254
+ return
1255
+ if event_type == "response.output_text.annotation.added":
1256
+ self._handle_output_text_annotation(data)
1257
+ return
1258
+ if event_type in {
1259
+ "response.reasoning_summary_text.delta",
1260
+ # Compatibility event emitted by older/private Responses surfaces.
1261
+ "response.reasoning_summary.delta",
1262
+ }:
1263
+ self._handle_reasoning_summary_delta(data)
1264
+ return
1265
+ if event_type in {
1266
+ "response.reasoning_summary_text.done",
1267
+ "response.reasoning_summary.done",
1268
+ }:
1269
+ self._handle_reasoning_summary_done(data)
1270
+ return
1271
+ if event_type in {
1272
+ "response.reasoning_summary_part.added",
1273
+ "response.reasoning_summary_part.done",
1274
+ }:
1275
+ self._handle_reasoning_summary_part(data, done=event_type.endswith(".done"))
1276
+ return
1277
+ if event_type == "response.function_call_arguments.delta":
1278
+ self._handle_function_arguments_delta(data)
1279
+ return
1280
+ if event_type == "response.function_call_arguments.done":
1281
+ self._handle_function_arguments_done(data)
1282
+ return
1283
+ if event_type.startswith("response.web_search_call."):
1284
+ self._handle_web_search_call_state(event_type, data)
1285
+ return
1286
+
1287
+ self._append_unknown(frame=frame, data=data)
1288
+
1289
+ def finish(self) -> dict[str, Any]:
1290
+ if self.event_count <= 0:
1291
+ raise LLMError("OpenAI Responses stream returned no events")
1292
+ data = copy.deepcopy(self.final_response or self.response)
1293
+ output = data.get("output")
1294
+ if not isinstance(output, list) or not output:
1295
+ output = self._ordered_output_items()
1296
+ data["output"] = output
1297
+ elif self.output_items:
1298
+ data["output"] = self._merge_ordered_items(output)
1299
+ reasoning_only_partial = bool(output) and all(
1300
+ isinstance(item, dict) and str(item.get("type") or "") == "reasoning" for item in output
1301
+ )
1302
+ if not self.seen_final and not reasoning_only_partial:
1303
+ raise LLMError("OpenAI Responses stream ended before response.completed")
1304
+ if "output_text" not in data:
1305
+ text = _extract_answer_text(data)
1306
+ if text:
1307
+ data["output_text"] = text
1308
+ stream_metadata: dict[str, Any] = {"events": self.event_count}
1309
+ if not self.seen_final:
1310
+ stream_metadata["ended_before_response_completed"] = True
1311
+ if self.unknown_events:
1312
+ stream_metadata["unknown_events"] = copy.deepcopy(self.unknown_events)
1313
+ data["stream_metadata"] = stream_metadata
1314
+ return data
1315
+
1316
+ def _merge_response(self, raw_response: Any) -> None:
1317
+ if not isinstance(raw_response, dict):
1318
+ return
1319
+ response = copy.deepcopy(raw_response)
1320
+ output = response.pop("output", None)
1321
+ self.response.update(response)
1322
+ if isinstance(output, list):
1323
+ for index, item in enumerate(output):
1324
+ if isinstance(item, dict):
1325
+ self._set_output_item(index, item)
1326
+
1327
+ def _handle_final_response(self, data: dict[str, Any]) -> None:
1328
+ self._merge_response(data.get("response"))
1329
+ response = data.get("response")
1330
+ self.final_response = (
1331
+ copy.deepcopy(response) if isinstance(response, dict) else copy.deepcopy(self.response)
1332
+ )
1333
+ self.seen_final = True
1334
+
1335
+ def _handle_output_item(self, data: dict[str, Any]) -> None:
1336
+ index = _event_output_index(data, event_type=str(data.get("type") or "output_item"))
1337
+ item = data.get("item")
1338
+ if not isinstance(item, dict):
1339
+ return
1340
+ self._set_output_item(index, item)
1341
+
1342
+ def _set_output_item(self, index: int, item: dict[str, Any]) -> dict[str, Any]:
1343
+ copied = copy.deepcopy(item)
1344
+ existing = self.output_items.get(index)
1345
+ if existing is not None:
1346
+ merged = copy.deepcopy(existing)
1347
+ merged.update(copied)
1348
+ copied = merged
1349
+ self.output_items[index] = copied
1350
+ return copied
1351
+
1352
+ def _ensure_message_item(self, output_index: int, item_id: str | None = None) -> dict[str, Any]:
1353
+ item = self.output_items.get(output_index)
1354
+ if not isinstance(item, dict):
1355
+ item = {
1356
+ "type": "message",
1357
+ "role": "assistant",
1358
+ "content": [],
1359
+ }
1360
+ if item_id:
1361
+ item["id"] = item_id
1362
+ self.output_items[output_index] = item
1363
+ else:
1364
+ item.setdefault("type", "message")
1365
+ item.setdefault("role", "assistant")
1366
+ if item_id and not item.get("id"):
1367
+ item["id"] = item_id
1368
+ content = item.get("content")
1369
+ if not isinstance(content, list):
1370
+ item["content"] = []
1371
+ return item
1372
+
1373
+ def _ensure_text_part(
1374
+ self, output_index: int, content_index: int, item_id: str
1375
+ ) -> dict[str, Any]:
1376
+ key = (output_index, content_index)
1377
+ part = self.text_parts.get(key)
1378
+ if part is None:
1379
+ part = {"type": "output_text", "text": ""}
1380
+ self.text_parts[key] = part
1381
+ item = self._ensure_message_item(output_index, item_id)
1382
+ content = item.setdefault("content", [])
1383
+ if isinstance(content, list):
1384
+ while len(content) <= content_index:
1385
+ content.append({"type": "output_text", "text": ""})
1386
+ content[content_index] = part
1387
+ return part
1388
+
1389
+ def _handle_content_part(self, data: dict[str, Any]) -> None:
1390
+ output_index = _event_output_index(data, event_type=str(data.get("type") or "content_part"))
1391
+ content_index = _event_content_index(data)
1392
+ item_id = str(data.get("item_id") or "").strip()
1393
+ raw_part = data.get("part")
1394
+ if not isinstance(raw_part, dict):
1395
+ return
1396
+ item = self._ensure_message_item(output_index, item_id or None)
1397
+ content = item.setdefault("content", [])
1398
+ if not isinstance(content, list):
1399
+ content = []
1400
+ item["content"] = content
1401
+ while len(content) <= content_index:
1402
+ content.append({"type": "output_text", "text": ""})
1403
+ part = copy.deepcopy(raw_part)
1404
+ if str(part.get("type") or "") in {"text", "output_text"}:
1405
+ part["type"] = "output_text"
1406
+ part.setdefault("text", "")
1407
+ self.text_parts[(output_index, content_index)] = part
1408
+ content[content_index] = part
1409
+
1410
+ def _handle_output_text_delta(self, data: dict[str, Any]) -> None:
1411
+ output_index = _event_output_index(data, event_type="response.output_text.delta")
1412
+ content_index = _event_content_index(data)
1413
+ item_id = str(data.get("item_id") or "").strip()
1414
+ delta = data.get("delta")
1415
+ if not isinstance(delta, str) or not delta:
1416
+ return
1417
+ part = self._ensure_text_part(output_index, content_index, item_id)
1418
+ existing = part.get("text")
1419
+ part["text"] = (existing if isinstance(existing, str) else "") + delta
1420
+ self.text_delta_seen = True
1421
+ if self.on_text_delta is not None:
1422
+ self.on_text_delta(delta)
1423
+
1424
+ def _handle_output_text_done(self, data: dict[str, Any]) -> None:
1425
+ output_index = _event_output_index(data, event_type="response.output_text.done")
1426
+ content_index = _event_content_index(data)
1427
+ item_id = str(data.get("item_id") or "").strip()
1428
+ text = data.get("text")
1429
+ if not isinstance(text, str):
1430
+ return
1431
+ part = self._ensure_text_part(output_index, content_index, item_id)
1432
+ part["text"] = text
1433
+ part["type"] = "output_text"
1434
+
1435
+ def _handle_output_text_annotation(self, data: dict[str, Any]) -> None:
1436
+ output_index = _event_output_index(
1437
+ data,
1438
+ event_type="response.output_text.annotation.added",
1439
+ )
1440
+ content_index = _event_content_index(data)
1441
+ item_id = str(data.get("item_id") or "").strip()
1442
+ annotation = data.get("annotation")
1443
+ if not isinstance(annotation, dict):
1444
+ return
1445
+ part = self._ensure_text_part(output_index, content_index, item_id)
1446
+ annotations = part.setdefault("annotations", [])
1447
+ if isinstance(annotations, list):
1448
+ annotations.append(copy.deepcopy(annotation))
1449
+
1450
+ @staticmethod
1451
+ def _reasoning_indices(data: dict[str, Any]) -> tuple[int, int] | None:
1452
+ output_index = data.get("output_index")
1453
+ summary_index = data.get("summary_index")
1454
+ if not isinstance(output_index, int) or output_index < 0:
1455
+ return None
1456
+ if not isinstance(summary_index, int) or summary_index < 0:
1457
+ summary_index = 0
1458
+ return output_index, summary_index
1459
+
1460
+ def _ensure_reasoning_summary_part(
1461
+ self,
1462
+ *,
1463
+ output_index: int,
1464
+ summary_index: int,
1465
+ item_id: str,
1466
+ ) -> dict[str, Any] | None:
1467
+ key = (output_index, summary_index)
1468
+ existing_part = self.reasoning_summary_parts.get(key)
1469
+ if existing_part is not None:
1470
+ return existing_part
1471
+
1472
+ item = self.output_items.get(output_index)
1473
+ if item is None:
1474
+ item = {"type": "reasoning", "summary": []}
1475
+ if item_id:
1476
+ item["id"] = item_id
1477
+ self.output_items[output_index] = item
1478
+ elif str(item.get("type") or "") not in {"", "reasoning"}:
1479
+ # Malformed/colliding provider events must not overwrite another
1480
+ # output item merely to reconstruct optional reasoning metadata.
1481
+ return None
1482
+ else:
1483
+ item.setdefault("type", "reasoning")
1484
+ if item_id and not item.get("id"):
1485
+ item["id"] = item_id
1486
+
1487
+ summary = item.get("summary")
1488
+ if not isinstance(summary, list):
1489
+ summary = []
1490
+ item["summary"] = summary
1491
+ while len(summary) <= summary_index:
1492
+ summary.append({"type": "summary_text", "text": ""})
1493
+ part = summary[summary_index]
1494
+ if not isinstance(part, dict):
1495
+ part = {"type": "summary_text", "text": ""}
1496
+ summary[summary_index] = part
1497
+ else:
1498
+ part.setdefault("type", "summary_text")
1499
+ part.setdefault("text", "")
1500
+ self.reasoning_summary_parts[key] = part
1501
+ return part
1502
+
1503
+ def _reasoning_part_for_event(self, data: dict[str, Any]) -> dict[str, Any] | None:
1504
+ indices = self._reasoning_indices(data)
1505
+ if indices is None:
1506
+ return None
1507
+ output_index, summary_index = indices
1508
+ return self._ensure_reasoning_summary_part(
1509
+ output_index=output_index,
1510
+ summary_index=summary_index,
1511
+ item_id=str(data.get("item_id") or "").strip(),
1512
+ )
1513
+
1514
+ def _handle_reasoning_summary_delta(self, data: dict[str, Any]) -> None:
1515
+ delta = data.get("delta")
1516
+ if not isinstance(delta, str) or not delta:
1517
+ return
1518
+ key = self._reasoning_indices(data)
1519
+ part = self._reasoning_part_for_event(data)
1520
+ if part is not None:
1521
+ existing = part.get("text")
1522
+ part["text"] = (existing if isinstance(existing, str) else "") + delta
1523
+ self.reasoning_delta_keys.add(key)
1524
+ if self.on_reasoning_delta is not None:
1525
+ self.on_reasoning_delta(delta)
1526
+
1527
+ def _handle_reasoning_summary_done(self, data: dict[str, Any]) -> None:
1528
+ text = data.get("text")
1529
+ if not isinstance(text, str):
1530
+ return
1531
+ key = self._reasoning_indices(data)
1532
+ part = self._reasoning_part_for_event(data)
1533
+ if part is not None:
1534
+ part["type"] = "summary_text"
1535
+ part["text"] = text
1536
+ # Some compatible providers send only the completed summary event. It is
1537
+ # still genuine provider output, so surface it once, but never inject it
1538
+ # after visible answer text has already started or duplicate prior deltas.
1539
+ if (
1540
+ text
1541
+ and self.on_reasoning_delta is not None
1542
+ and key not in self.reasoning_delta_keys
1543
+ and None not in self.reasoning_delta_keys
1544
+ and not self.text_delta_seen
1545
+ ):
1546
+ self.reasoning_delta_keys.add(key)
1547
+ self.on_reasoning_delta(text)
1548
+
1549
+ def _handle_reasoning_summary_part(self, data: dict[str, Any], *, done: bool) -> None:
1550
+ raw_part = data.get("part")
1551
+ if not isinstance(raw_part, dict):
1552
+ return
1553
+ part = self._reasoning_part_for_event(data)
1554
+ if part is None:
1555
+ return
1556
+ part.update(copy.deepcopy(raw_part))
1557
+ part["type"] = "summary_text"
1558
+ text = raw_part.get("text")
1559
+ if done and isinstance(text, str):
1560
+ completed = dict(data)
1561
+ completed["text"] = text
1562
+ self._handle_reasoning_summary_done(completed)
1563
+
1564
+ def _handle_function_arguments_delta(self, data: dict[str, Any]) -> None:
1565
+ output_index = _event_output_index(
1566
+ data,
1567
+ event_type="response.function_call_arguments.delta",
1568
+ )
1569
+ delta = data.get("delta")
1570
+ if isinstance(delta, str):
1571
+ self.argument_chunks.setdefault(output_index, []).append(delta)
1572
+ item = self.output_items.get(output_index)
1573
+ if isinstance(item, dict) and str(item.get("type") or "") == "function_call":
1574
+ existing = item.get("arguments")
1575
+ item["arguments"] = (existing if isinstance(existing, str) else "") + (
1576
+ delta if isinstance(delta, str) else ""
1577
+ )
1578
+
1579
+ def _handle_function_arguments_done(self, data: dict[str, Any]) -> None:
1580
+ output_index = _event_output_index(
1581
+ data,
1582
+ event_type="response.function_call_arguments.done",
1583
+ )
1584
+ item = self.output_items.get(output_index)
1585
+ if not isinstance(item, dict):
1586
+ item = {"type": "function_call"}
1587
+ self.output_items[output_index] = item
1588
+ item["type"] = "function_call"
1589
+ for key in ("item_id", "call_id", "name", "status"):
1590
+ value = data.get(key)
1591
+ if value is not None:
1592
+ item["id" if key == "item_id" else key] = copy.deepcopy(value)
1593
+ arguments = data.get("arguments")
1594
+ if isinstance(arguments, str):
1595
+ item["arguments"] = arguments
1596
+ elif output_index in self.argument_chunks:
1597
+ item["arguments"] = "".join(self.argument_chunks[output_index])
1598
+
1599
+ def _handle_web_search_call_state(self, event_type: str, data: dict[str, Any]) -> None:
1600
+ try:
1601
+ output_index = _event_output_index(data, event_type=event_type)
1602
+ except LLMError:
1603
+ self._append_unknown(frame=SSEFrame(event=event_type, data=json.dumps(data)), data=data)
1604
+ return
1605
+ item = self.output_items.get(output_index)
1606
+ if not isinstance(item, dict):
1607
+ item = {"type": "web_search_call"}
1608
+ self.output_items[output_index] = item
1609
+ item["type"] = "web_search_call"
1610
+ item_id = data.get("item_id")
1611
+ if isinstance(item_id, str) and item_id.strip():
1612
+ item["id"] = item_id
1613
+ status = event_type.rsplit(".", 1)[-1]
1614
+ if status:
1615
+ item["status"] = status
1616
+ for key in ("action", "results"):
1617
+ value = data.get(key)
1618
+ if value is not None:
1619
+ item[key] = copy.deepcopy(value)
1620
+
1621
+ def _ordered_output_items(self) -> list[dict[str, Any]]:
1622
+ return [
1623
+ copy.deepcopy(item)
1624
+ for _index, item in sorted(self.output_items.items(), key=lambda pair: pair[0])
1625
+ if isinstance(item, dict)
1626
+ ]
1627
+
1628
+ def _merge_ordered_items(self, output: list[Any]) -> list[dict[str, Any]]:
1629
+ merged: dict[int, dict[str, Any]] = {}
1630
+ for index, item in enumerate(output):
1631
+ if isinstance(item, dict):
1632
+ merged[index] = copy.deepcopy(item)
1633
+ for index, item in self.output_items.items():
1634
+ if not isinstance(item, dict):
1635
+ continue
1636
+ if index in merged:
1637
+ copied = copy.deepcopy(item)
1638
+ copied.update(copy.deepcopy(merged[index]))
1639
+ merged[index] = copied
1640
+ else:
1641
+ merged[index] = copy.deepcopy(item)
1642
+ return [item for _index, item in sorted(merged.items(), key=lambda pair: pair[0])]
1643
+
1644
+ def _append_unknown(self, *, frame: SSEFrame, data: dict[str, Any]) -> None:
1645
+ self.unknown_events.append(
1646
+ {
1647
+ "event": frame.event,
1648
+ "data": copy.deepcopy(data),
1649
+ }
1650
+ )
1651
+
1652
+
1653
+ class OpenAIResponsesClient:
1654
+ # Requests may continue server-side via previous_response_id. Replaying the
1655
+ # visible messages is a different cache stream and cannot refresh that prefix.
1656
+ cache_keepalive_transport = "response_continuation"
1657
+ usage_contract = UsageContract(
1658
+ response_usage_confidence=UsageConfidence.AUTHORITATIVE,
1659
+ input_token_count_strategy="openai_responses",
1660
+ )
1661
+ supports_tool_calling = True
1662
+ supports_forced_tool_choice = True
1663
+ usage_counts_authoritative = usage_contract.response_usage_authoritative
1664
+
1665
+ def __init__(
1666
+ self,
1667
+ *,
1668
+ base_url: str,
1669
+ api_key: str,
1670
+ model: str,
1671
+ timeout_s: float = 20.0,
1672
+ temperature: float = 1.0,
1673
+ prompt_cache_key: str | None = None,
1674
+ prompt_cache_retention: str | None = None,
1675
+ enable_thinking: bool | None = None,
1676
+ reasoning_effort: str | None = None,
1677
+ transport: httpx.BaseTransport | None = None,
1678
+ extra_headers: dict[str, str] | None = None,
1679
+ provider_key: str | None = None,
1680
+ web_search_mode: str = "off",
1681
+ web_search_adapter: str = AUTO_WEB_SEARCH_ADAPTER,
1682
+ provider_concurrency_caps: dict[str, int] | None = None,
1683
+ provider_retry_settings: ProviderRetrySettings | None = None,
1684
+ provider_sleep_fn: Callable[[float], None] | None = None,
1685
+ provider_random_fn: Callable[[], float] | None = None,
1686
+ prompt_cache_policy_metadata: Mapping[str, Any] | None = None,
1687
+ provider_auth: ProviderAuthAdapter | None = None,
1688
+ session_id: str | None = None,
1689
+ usage_contract: UsageContract | None = None,
1690
+ route_identity: ProviderRouteIdentity | None = None,
1691
+ ) -> None:
1692
+ self.base_url = base_url.rstrip("/")
1693
+ self.api_key = api_key
1694
+ self.model = model
1695
+ self.timeout_s = timeout_s
1696
+ self.temperature = temperature
1697
+ self.prompt_cache_key = str(prompt_cache_key or "").strip() or None
1698
+ self.prompt_cache_retention = str(prompt_cache_retention or "").strip() or None
1699
+ self.enable_thinking = enable_thinking
1700
+ self.reasoning_effort = str(reasoning_effort or "").strip().lower() or None
1701
+ self._transport = transport
1702
+ self.extra_headers = canonicalize_extra_headers(extra_headers)
1703
+ self.provider_key = str(provider_key or "").strip() or None
1704
+ self.route_identity = route_identity or build_provider_route_identity(
1705
+ protocol="openai_responses",
1706
+ base_url=self.base_url,
1707
+ provider_key=self.provider_key,
1708
+ model=self.model,
1709
+ credential_scope=credential_scope_fingerprint(self.api_key),
1710
+ routing_headers=self.extra_headers,
1711
+ session_scope=credential_scope_fingerprint(session_id),
1712
+ )
1713
+ self.web_search_mode = str(web_search_mode or "off").strip().lower()
1714
+ self.web_search_adapter = (
1715
+ str(web_search_adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower()
1716
+ or AUTO_WEB_SEARCH_ADAPTER
1717
+ )
1718
+ self.provider_concurrency_caps = dict(
1719
+ DEFAULT_PROVIDER_CONCURRENCY_CAPS
1720
+ if provider_concurrency_caps is None
1721
+ else provider_concurrency_caps
1722
+ )
1723
+ self.provider_retry_settings = provider_retry_settings or ProviderRetrySettings()
1724
+ self._provider_sleep_fn = provider_sleep_fn
1725
+ self._provider_random_fn = provider_random_fn
1726
+ self.prompt_cache_policy_metadata = (
1727
+ copy.deepcopy(dict(prompt_cache_policy_metadata))
1728
+ if isinstance(prompt_cache_policy_metadata, Mapping)
1729
+ else None
1730
+ )
1731
+ self.provider_auth = provider_auth
1732
+ self.session_id = str(session_id or "").strip() or None
1733
+ self.usage_contract = usage_contract or type(self).usage_contract
1734
+ self.usage_counts_authoritative = self.usage_contract.response_usage_authoritative
1735
+ self._input_token_count_available: bool | None = None
1736
+ self._reasoning_summary_support_by_model: dict[str, bool] = {}
1737
+ self._provider_retry_wall_clock_cap_seconds = _PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS
1738
+
1739
+ def _reasoning_summary_support_key(self) -> str:
1740
+ return _responses_temperature_omit_key(self.base_url, self.model)
1741
+
1742
+ def _should_request_reasoning_summary(self) -> bool:
1743
+ capability = getattr(self, "reasoning_trace_capability", None)
1744
+ if self.enable_thinking is False:
1745
+ return False
1746
+ if not bool(getattr(capability, "requestable", False)):
1747
+ return False
1748
+ if not bool(getattr(capability, "has_safe_summary", False)):
1749
+ return False
1750
+ return (
1751
+ self._reasoning_summary_support_by_model.get(self._reasoning_summary_support_key())
1752
+ is not False
1753
+ )
1754
+
1755
+ def _headers(self, url: str, *, force_refresh: bool = False) -> dict[str, str]:
1756
+ headers = {
1757
+ "Content-Type": "application/json",
1758
+ "User-Agent": "alysis-code/0.1.0",
1759
+ }
1760
+ if self.api_key:
1761
+ headers["Authorization"] = f"Bearer {self.api_key}"
1762
+ headers = merge_canonical_headers(headers, self.extra_headers)
1763
+ if self.provider_auth is not None:
1764
+ for key in tuple(headers):
1765
+ if key.casefold() in {
1766
+ "authorization",
1767
+ "chatgpt-account-id",
1768
+ "originator",
1769
+ "session-id",
1770
+ "x-session-affinity",
1771
+ "x-session-id",
1772
+ }:
1773
+ headers.pop(key, None)
1774
+ headers = merge_canonical_headers(
1775
+ headers,
1776
+ self.provider_auth.authorization_headers(
1777
+ url,
1778
+ force_refresh=force_refresh,
1779
+ session_id=self.session_id,
1780
+ ),
1781
+ )
1782
+ return _headers_with_default_accept_encoding(headers)
1783
+
1784
+ @staticmethod
1785
+ def _error_from_response(response: httpx.Response) -> ResponsesError:
1786
+ try:
1787
+ data = response.json()
1788
+ except Exception:
1789
+ body = response.text
1790
+ if len(body) > 1000:
1791
+ body = body[:1000] + "...(truncated)"
1792
+ return ResponsesError(
1793
+ sanitize_error_text_for_output(f"Responses error {response.status_code}: {body}")
1794
+ )
1795
+ if isinstance(data, dict):
1796
+ error_message = _extract_error_message(data)
1797
+ if error_message:
1798
+ lower = error_message.lower()
1799
+ if "unsupported" in lower or "not support" in lower:
1800
+ return ResponsesError(
1801
+ sanitize_error_text_for_output(
1802
+ f"Responses web_search unsupported: {error_message}"
1803
+ )
1804
+ )
1805
+ return ResponsesError(
1806
+ sanitize_error_text_for_output(
1807
+ f"Responses error {response.status_code}: {error_message}"
1808
+ )
1809
+ )
1810
+ return ResponsesError(
1811
+ sanitize_error_text_for_output(f"Responses error {response.status_code}: {data!r}")
1812
+ )
1813
+
1814
+ @staticmethod
1815
+ def _llm_error_from_response(response: httpx.Response) -> LLMError:
1816
+ try:
1817
+ data = response.json()
1818
+ except Exception:
1819
+ body = response.text
1820
+ if len(body) > 1000:
1821
+ body = body[:1000] + "...(truncated)"
1822
+ return LLMError(
1823
+ sanitize_error_text_for_output(f"LLM error {response.status_code}: {body}")
1824
+ )
1825
+ error_message = _extract_error_message(data)
1826
+ if error_message:
1827
+ return LLMError(
1828
+ sanitize_error_text_for_output(f"LLM error {response.status_code}: {error_message}")
1829
+ )
1830
+ return LLMError(
1831
+ sanitize_error_text_for_output(f"LLM error {response.status_code}: {data!r}")
1832
+ )
1833
+
1834
+ def count_input_tokens(
1835
+ self,
1836
+ *,
1837
+ messages: list[dict[str, Any]],
1838
+ tools: list[dict[str, Any]] | None = None,
1839
+ tool_choice: Any | None = None,
1840
+ ) -> InputTokenCount | None:
1841
+ if self._input_token_count_available is False:
1842
+ return None
1843
+ messages = gate_messages_for_provider_route(messages, self.route_identity)
1844
+ tool_mapping = _responses_tools(
1845
+ tools,
1846
+ mode=self.web_search_mode,
1847
+ adapter=self.web_search_adapter,
1848
+ )
1849
+ payload: dict[str, Any] = {
1850
+ "model": self.model,
1851
+ "input": _responses_input_from_messages(messages),
1852
+ }
1853
+ if tool_mapping.tools:
1854
+ payload["tools"] = tool_mapping.tools
1855
+ payload["tool_choice"] = (
1856
+ "auto"
1857
+ if tool_choice is None
1858
+ else _tool_choice_for_mapped_tools(
1859
+ tool_choice,
1860
+ removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
1861
+ )
1862
+ )
1863
+ if self.provider_auth is not None:
1864
+ adapted = self.provider_auth.adapt_responses_payload(payload)
1865
+ payload = {
1866
+ key: adapted[key]
1867
+ for key in ("model", "input", "instructions", "tools", "tool_choice")
1868
+ if key in adapted
1869
+ }
1870
+ url = f"{self.base_url}/responses/input_tokens"
1871
+
1872
+ def _send_request() -> InputTokenCount | None:
1873
+ auth_refresh_used = False
1874
+ while True:
1875
+ try:
1876
+ with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
1877
+ response = client.post(
1878
+ url,
1879
+ headers=self._headers(url, force_refresh=auth_refresh_used),
1880
+ json=payload,
1881
+ )
1882
+ except httpx.HTTPError as exc:
1883
+ raise LLMError(
1884
+ "OpenAI input token count request failed: "
1885
+ f"{sanitize_error_text_for_output(exc)}"
1886
+ ) from exc
1887
+ if (
1888
+ response.status_code == 401
1889
+ and self.provider_auth is not None
1890
+ and not auth_refresh_used
1891
+ ):
1892
+ auth_refresh_used = True
1893
+ continue
1894
+ if response.status_code in {404, 405, 501}:
1895
+ self._input_token_count_available = False
1896
+ return None
1897
+ if response.status_code >= 400:
1898
+ raise self._llm_error_from_response(response)
1899
+ try:
1900
+ data = response.json()
1901
+ except Exception as exc: # noqa: BLE001
1902
+ raise LLMError("OpenAI input token count returned non-JSON response") from exc
1903
+ count = _non_negative_int(
1904
+ data.get("input_tokens") if isinstance(data, dict) else None
1905
+ )
1906
+ if count is None:
1907
+ raise LLMError("OpenAI input token count response omitted input_tokens")
1908
+ self._input_token_count_available = True
1909
+ return InputTokenCount(
1910
+ input_tokens=count,
1911
+ raw_provider_usage=copy.deepcopy(data),
1912
+ )
1913
+
1914
+ return run_provider_limited_call(
1915
+ call=_send_request,
1916
+ provider_key=self.provider_key,
1917
+ provider_concurrency_caps=self.provider_concurrency_caps,
1918
+ retry_settings=self.provider_retry_settings,
1919
+ operation="responses_count_input_tokens",
1920
+ sleep_fn=self._provider_sleep_fn,
1921
+ random_fn=self._provider_random_fn,
1922
+ retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
1923
+ )
1924
+
1925
+ def chat(
1926
+ self,
1927
+ *,
1928
+ messages: list[dict[str, Any]],
1929
+ tools: list[dict[str, Any]] | None = None,
1930
+ tool_choice: Any | None = None,
1931
+ response_format: dict[str, Any] | None = None,
1932
+ stream: bool = False,
1933
+ on_text_delta: Callable[[str], None] | None = None,
1934
+ on_reasoning_delta: Callable[[str], None] | None = None,
1935
+ temperature: float | None = None,
1936
+ max_tokens: int | None = None,
1937
+ request_plan: LLMRequestPlan | None = None,
1938
+ ) -> LLMResponse:
1939
+ default_cache = RequestCachePlan(
1940
+ strategy=(
1941
+ "openai_prompt_cache"
1942
+ if self.prompt_cache_key or self.prompt_cache_retention
1943
+ else "none"
1944
+ ),
1945
+ mode=(
1946
+ "automatic" if self.prompt_cache_key or self.prompt_cache_retention else "manual"
1947
+ ),
1948
+ prompt_cache_key=self.prompt_cache_key,
1949
+ prompt_cache_retention=self.prompt_cache_retention,
1950
+ )
1951
+ plan = request_plan or LLMRequestPlan.from_chat_args(
1952
+ messages=messages,
1953
+ tools=tools,
1954
+ tool_choice=tool_choice,
1955
+ response_format=response_format,
1956
+ stream=stream,
1957
+ temperature=temperature,
1958
+ max_tokens=max_tokens,
1959
+ cache=default_cache,
1960
+ )
1961
+ if (
1962
+ request_plan is not None
1963
+ and plan.cache.mode != "off"
1964
+ and plan.cache.strategy == "none"
1965
+ and not plan.cache.prompt_cache_key
1966
+ and not plan.cache.prompt_cache_retention
1967
+ and (self.prompt_cache_key or self.prompt_cache_retention)
1968
+ ):
1969
+ plan = plan.with_cache(default_cache)
1970
+ messages = gate_messages_for_provider_route(plan.message_list(), self.route_identity)
1971
+ tools = plan.tool_list()
1972
+ tool_choice = plan.tool_choice
1973
+ response_format = plan.response_format
1974
+ public_stream = plan.stream
1975
+ stream = public_stream or bool(
1976
+ self.provider_auth is not None
1977
+ and getattr(self.provider_auth, "requires_streaming", False)
1978
+ )
1979
+ temperature = plan.temperature
1980
+ max_tokens = plan.max_tokens
1981
+ if max_tokens is not None:
1982
+ max_tokens = _clamp_responses_max_output_tokens(max_tokens)
1983
+ tool_mapping = _responses_tools(
1984
+ tools,
1985
+ mode=self.web_search_mode,
1986
+ adapter=self.web_search_adapter,
1987
+ )
1988
+ mapped_tools = tool_mapping.tools
1989
+ temp_omit_key = _responses_temperature_omit_key(self.base_url, self.model)
1990
+ include_omit_key = _responses_endpoint_model_key(self.base_url, self.model)
1991
+ reasoning_summary_support_key = self._reasoning_summary_support_key()
1992
+ reasoning = _responses_reasoning(
1993
+ enable_thinking=self.enable_thinking,
1994
+ reasoning_effort=self.reasoning_effort,
1995
+ request_summary=self._should_request_reasoning_summary(),
1996
+ )
1997
+ text_config = _responses_text_config(response_format)
1998
+ full_input = _responses_input_from_messages(messages)
1999
+ continuation = _responses_continuation_from_messages(messages)
2000
+ previous_response_id: str | None = None
2001
+ sent_input = full_input
2002
+ input_mode = "full"
2003
+ continuation_anchor_index: int | None = None
2004
+ supports_previous_response_id = self.provider_auth is None or bool(
2005
+ getattr(self.provider_auth, "supports_previous_response_id", True)
2006
+ )
2007
+ if continuation is not None and supports_previous_response_id:
2008
+ # With previous_response_id the API appends the sent input items to the
2009
+ # stored thread; system/developer messages from turn 1 are already
2010
+ # retained server-side, so resending them duplicates the instructions
2011
+ # on every chained turn. Send only the new suffix.
2012
+ continuation_input = _responses_input_from_messages(continuation.suffix_messages)
2013
+ if continuation_input:
2014
+ previous_response_id = continuation.previous_response_id
2015
+ sent_input = continuation_input
2016
+ input_mode = "previous_response_id"
2017
+ continuation_anchor_index = continuation.anchor_index
2018
+
2019
+ cache_policy = merge_cache_policy_metadata(
2020
+ self.prompt_cache_policy_metadata,
2021
+ plan.cache.openai_prompt_cache_policy_metadata(),
2022
+ )
2023
+
2024
+ def _build_payload(
2025
+ input_items: list[dict[str, Any]],
2026
+ *,
2027
+ prior_response_id: str | None,
2028
+ ) -> dict[str, Any]:
2029
+ payload: dict[str, Any] = {
2030
+ "model": self.model,
2031
+ "input": input_items,
2032
+ }
2033
+ if prior_response_id:
2034
+ payload["previous_response_id"] = prior_response_id
2035
+ if temp_omit_key not in _RESPONSES_OMIT_TEMPERATURE_MODELS:
2036
+ payload["temperature"] = (
2037
+ self.temperature if temperature is None else float(temperature)
2038
+ )
2039
+ if plan.cache.prompt_cache_key:
2040
+ payload["prompt_cache_key"] = plan.cache.prompt_cache_key
2041
+ if plan.cache.prompt_cache_retention:
2042
+ payload["prompt_cache_retention"] = plan.cache.prompt_cache_retention
2043
+ if reasoning is not None:
2044
+ payload["reasoning"] = copy.deepcopy(reasoning)
2045
+ if mapped_tools:
2046
+ payload["tools"] = mapped_tools
2047
+ payload["tool_choice"] = (
2048
+ "auto"
2049
+ if tool_choice is None
2050
+ else _tool_choice_for_mapped_tools(
2051
+ tool_choice,
2052
+ removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
2053
+ )
2054
+ )
2055
+ if (
2056
+ tool_mapping.added_builtin_web_search
2057
+ and include_omit_key not in _RESPONSES_OMIT_INCLUDE_ENDPOINTS
2058
+ ):
2059
+ payload["include"] = ["web_search_call.action.sources"]
2060
+ elif tool_choice is not None:
2061
+ payload["tool_choice"] = _tool_choice_for_mapped_tools(
2062
+ tool_choice,
2063
+ removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
2064
+ )
2065
+ if text_config is not None:
2066
+ payload["text"] = text_config
2067
+ if max_tokens is not None:
2068
+ payload["max_output_tokens"] = int(max_tokens)
2069
+ if stream:
2070
+ payload["stream"] = True
2071
+ if self.provider_auth is not None:
2072
+ payload = self.provider_auth.adapt_responses_payload(payload)
2073
+ if self._reasoning_summary_support_by_model.get(reasoning_summary_support_key) is False:
2074
+ _without_responses_reasoning_summary(payload)
2075
+ return payload
2076
+
2077
+ def _prompt_estimation_payload(payload: dict[str, Any]) -> dict[str, Any]:
2078
+ estimation_payload: dict[str, Any] = {
2079
+ "input": payload.get("input", []),
2080
+ }
2081
+ for key in ("tools", "text", "include"):
2082
+ if key in payload:
2083
+ estimation_payload[key] = payload[key]
2084
+ return estimation_payload
2085
+
2086
+ payload = _build_payload(sent_input, prior_response_id=previous_response_id)
2087
+ full_estimate_payload = _build_payload(full_input, prior_response_id=None)
2088
+ full_input_estimate_tokens = estimate_provider_payload_tokens(
2089
+ _prompt_estimation_payload(full_estimate_payload)
2090
+ )
2091
+
2092
+ def _request_plan_metadata(current_payload: dict[str, Any]) -> dict[str, Any]:
2093
+ extra: dict[str, Any] = {
2094
+ "full_input_item_count": len(full_input),
2095
+ "sent_input_item_count": len(sent_input),
2096
+ "previous_response_id_used": previous_response_id is not None,
2097
+ # Continuation never resends stable instructions; kept at 0 so the
2098
+ # telemetry schema stays stable for downstream consumers.
2099
+ "resent_stable_instruction_count": 0,
2100
+ }
2101
+ if continuation_anchor_index is not None:
2102
+ extra["continuation_anchor_index"] = continuation_anchor_index
2103
+ metadata = plan.request_plan_metadata(
2104
+ input_mode=input_mode,
2105
+ continuation_strategy=(
2106
+ "previous_response_id" if previous_response_id else "full_replay"
2107
+ ),
2108
+ provider_payload=_prompt_estimation_payload(full_estimate_payload),
2109
+ sent_provider_payload=_prompt_estimation_payload(current_payload),
2110
+ cache_policy_metadata=cache_policy,
2111
+ extra=extra,
2112
+ )
2113
+ metadata["request_messages_signature"] = _stable_request_signature(messages)
2114
+ return metadata
2115
+
2116
+ request_plan_metadata = _request_plan_metadata(payload)
2117
+
2118
+ def _token_reconciliation_metadata(
2119
+ current_payload: dict[str, Any],
2120
+ ) -> dict[str, Any]:
2121
+ sent_input_estimate_tokens = estimate_provider_payload_tokens(
2122
+ _prompt_estimation_payload(current_payload)
2123
+ )
2124
+ return {
2125
+ "input_estimate_tokens": full_input_estimate_tokens,
2126
+ "sent_input_estimate_tokens": sent_input_estimate_tokens,
2127
+ "estimator": "cl100k_base",
2128
+ "estimate_basis": "provider_prompt_payload",
2129
+ "input_mode": str(request_plan_metadata.get("input_mode") or input_mode),
2130
+ }
2131
+
2132
+ def _request_shape_metadata(current_payload: dict[str, Any]) -> dict[str, Any]:
2133
+ return build_request_shape_report(
2134
+ messages=messages,
2135
+ tools=tools,
2136
+ cache_policy=cache_policy,
2137
+ provider_payload=_prompt_estimation_payload(current_payload),
2138
+ input_mode=str(request_plan_metadata.get("input_mode") or input_mode),
2139
+ )
2140
+
2141
+ provider_key = self.provider_key or best_effort_provider_key(
2142
+ base_url=self.base_url,
2143
+ model=self.model,
2144
+ )
2145
+ telemetry = ProviderCallTelemetryRecorder(
2146
+ provider_key=provider_key,
2147
+ protocol="openai_responses",
2148
+ model=self.model,
2149
+ base_url=self.base_url,
2150
+ stream=stream,
2151
+ tools=tools,
2152
+ web_search_mode=self.web_search_mode,
2153
+ web_search_adapter=self.web_search_adapter,
2154
+ native_web_search=tool_mapping.added_builtin_web_search,
2155
+ cache_policy=cache_policy,
2156
+ request_plan=request_plan_metadata,
2157
+ request_shape=_request_shape_metadata(payload),
2158
+ token_reconciliation=_token_reconciliation_metadata(payload),
2159
+ operation="responses_chat",
2160
+ )
2161
+ telemetry_on_text_delta = telemetry.wrap_text_delta(on_text_delta)
2162
+ telemetry_on_reasoning_delta = telemetry.wrap_reasoning_delta(on_reasoning_delta)
2163
+ public_output_emitted = False
2164
+
2165
+ def _tracked_text_delta(delta: str) -> None:
2166
+ nonlocal public_output_emitted
2167
+ if delta:
2168
+ public_output_emitted = True
2169
+ if telemetry_on_text_delta is not None:
2170
+ telemetry_on_text_delta(delta)
2171
+
2172
+ def _tracked_reasoning_delta(delta: str) -> None:
2173
+ nonlocal public_output_emitted
2174
+ if delta:
2175
+ public_output_emitted = True
2176
+ if telemetry_on_reasoning_delta is not None:
2177
+ telemetry_on_reasoning_delta(delta)
2178
+
2179
+ def _finalize_response(response: LLMResponse) -> LLMResponse:
2180
+ raw_reasoning = payload.get("reasoning")
2181
+ if isinstance(raw_reasoning, dict) and raw_reasoning.get("summary") == "auto":
2182
+ self._reasoning_summary_support_by_model[reasoning_summary_support_key] = True
2183
+ if not public_stream:
2184
+ if on_reasoning_delta is not None and telemetry_on_reasoning_delta is not None:
2185
+ for summary in response.reasoning:
2186
+ if summary.kind == ReasoningOutputKind.SUMMARY:
2187
+ telemetry_on_reasoning_delta(summary.text)
2188
+ if (
2189
+ on_text_delta is not None
2190
+ and telemetry_on_text_delta is not None
2191
+ and response.content
2192
+ ):
2193
+ telemetry_on_text_delta(response.content)
2194
+ telemetry.set_request_plan(request_plan_metadata)
2195
+ telemetry.set_request_shape(_request_shape_metadata(payload))
2196
+ telemetry.set_token_reconciliation(_token_reconciliation_metadata(payload))
2197
+ return _response_with_request_plan_metadata(
2198
+ response,
2199
+ request_plan_metadata,
2200
+ )
2201
+
2202
+ def _send_request() -> LLMResponse:
2203
+ nonlocal input_mode, payload, previous_response_id, reasoning
2204
+ nonlocal request_plan_metadata, sent_input
2205
+ url = f"{self.base_url}/responses"
2206
+ previous_response_fallback_used = False
2207
+ reasoning_summary_fallback_used = False
2208
+ auth_refresh_used = False
2209
+
2210
+ def _refresh_request_metadata() -> None:
2211
+ nonlocal request_plan_metadata
2212
+ request_plan_metadata = _request_plan_metadata(payload)
2213
+ telemetry.set_request_plan(request_plan_metadata)
2214
+ telemetry.set_request_shape(_request_shape_metadata(payload))
2215
+ telemetry.set_token_reconciliation(_token_reconciliation_metadata(payload))
2216
+
2217
+ def _retry_without_include(err: Exception) -> bool:
2218
+ nonlocal payload
2219
+ if "include" not in payload:
2220
+ return False
2221
+ if not _responses_include_unsupported(err):
2222
+ return False
2223
+ # Bounded: the retried payload has no ``include`` entries, so
2224
+ # this branch cannot fire twice for the same request.
2225
+ _RESPONSES_OMIT_INCLUDE_ENDPOINTS.add(include_omit_key)
2226
+ payload = dict(payload)
2227
+ payload.pop("include", None)
2228
+ _refresh_request_metadata()
2229
+ return True
2230
+
2231
+ def _retry_without_reasoning_summary(err: Exception) -> bool:
2232
+ nonlocal payload, reasoning, reasoning_summary_fallback_used
2233
+ if reasoning_summary_fallback_used:
2234
+ return False
2235
+ if not _responses_reasoning_summary_unsupported(err):
2236
+ return False
2237
+ if (
2238
+ not isinstance(payload.get("reasoning"), dict)
2239
+ or "summary" not in payload["reasoning"]
2240
+ ):
2241
+ return False
2242
+ reasoning_summary_fallback_used = True
2243
+ if (
2244
+ self._reasoning_summary_support_by_model.get(reasoning_summary_support_key)
2245
+ is not True
2246
+ ):
2247
+ self._reasoning_summary_support_by_model[reasoning_summary_support_key] = False
2248
+ if isinstance(reasoning, dict):
2249
+ reasoning = copy.deepcopy(reasoning)
2250
+ reasoning.pop("summary", None)
2251
+ reasoning = reasoning or None
2252
+ payload = _build_payload(sent_input, prior_response_id=previous_response_id)
2253
+ _without_responses_reasoning_summary(payload)
2254
+ _refresh_request_metadata()
2255
+ return True
2256
+
2257
+ while True:
2258
+ try:
2259
+ with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
2260
+ if stream:
2261
+ with client.stream(
2262
+ "POST",
2263
+ url,
2264
+ headers=self._headers(url, force_refresh=auth_refresh_used),
2265
+ json=payload,
2266
+ ) as response:
2267
+ if response.status_code >= 400:
2268
+ response.read()
2269
+ if (
2270
+ response.status_code == 401
2271
+ and self.provider_auth is not None
2272
+ and not auth_refresh_used
2273
+ ):
2274
+ auth_refresh_used = True
2275
+ continue
2276
+ err = self._llm_error_from_response(response)
2277
+ if (
2278
+ previous_response_id
2279
+ and not previous_response_fallback_used
2280
+ and _responses_previous_response_rejected(err)
2281
+ ):
2282
+ previous_response_fallback_used = True
2283
+ previous_response_id = None
2284
+ sent_input = full_input
2285
+ input_mode = (
2286
+ "full_retry_after_previous_response_id_rejected"
2287
+ )
2288
+ payload = _build_payload(
2289
+ sent_input,
2290
+ prior_response_id=None,
2291
+ )
2292
+ _refresh_request_metadata()
2293
+ continue
2294
+ if _retry_without_reasoning_summary(err):
2295
+ continue
2296
+ if _retry_without_include(err):
2297
+ continue
2298
+ if (
2299
+ "temperature" in payload
2300
+ and _responses_temperature_unsupported(err)
2301
+ ):
2302
+ payload.pop("temperature", None)
2303
+ _RESPONSES_OMIT_TEMPERATURE_MODELS.add(temp_omit_key)
2304
+ continue
2305
+ raise err
2306
+ return _finalize_response(
2307
+ self._parse_stream_response(
2308
+ response,
2309
+ on_text_delta=(
2310
+ _tracked_text_delta
2311
+ if public_stream and on_text_delta is not None
2312
+ else None
2313
+ ),
2314
+ on_reasoning_delta=(
2315
+ _tracked_reasoning_delta
2316
+ if public_stream and on_reasoning_delta is not None
2317
+ else None
2318
+ ),
2319
+ )
2320
+ )
2321
+ response = client.post(
2322
+ url,
2323
+ headers=self._headers(url, force_refresh=auth_refresh_used),
2324
+ json=payload,
2325
+ )
2326
+ except httpx.DecodingError as e:
2327
+ err = LLMError(
2328
+ "OpenAI Responses decompression failed: "
2329
+ f"{sanitize_error_text_for_output(e)}"
2330
+ )
2331
+ if stream and public_output_emitted:
2332
+ mark_provider_call_non_retryable(err)
2333
+ raise err from e
2334
+ except Exception as e: # noqa: BLE001
2335
+ if isinstance(e, LLMError):
2336
+ if stream and public_output_emitted:
2337
+ mark_provider_call_non_retryable(e)
2338
+ raise
2339
+ err = LLMError(
2340
+ f"OpenAI Responses request failed: {sanitize_error_text_for_output(e)}"
2341
+ )
2342
+ if stream and public_output_emitted:
2343
+ mark_provider_call_non_retryable(err)
2344
+ raise err from e
2345
+ if response.status_code >= 400:
2346
+ if (
2347
+ response.status_code == 401
2348
+ and self.provider_auth is not None
2349
+ and not auth_refresh_used
2350
+ ):
2351
+ auth_refresh_used = True
2352
+ continue
2353
+ err = self._llm_error_from_response(response)
2354
+ if (
2355
+ previous_response_id
2356
+ and not previous_response_fallback_used
2357
+ and _responses_previous_response_rejected(err)
2358
+ ):
2359
+ previous_response_fallback_used = True
2360
+ previous_response_id = None
2361
+ sent_input = full_input
2362
+ input_mode = "full_retry_after_previous_response_id_rejected"
2363
+ payload = _build_payload(sent_input, prior_response_id=None)
2364
+ _refresh_request_metadata()
2365
+ continue
2366
+ if _retry_without_reasoning_summary(err):
2367
+ continue
2368
+ if _retry_without_include(err):
2369
+ continue
2370
+ # The model rejected ``temperature``: drop it (and remember
2371
+ # that for this model) and retry once. Bounded — the second
2372
+ # attempt has no ``temperature`` so it can't loop here.
2373
+ if "temperature" in payload and _responses_temperature_unsupported(err):
2374
+ payload.pop("temperature", None)
2375
+ _RESPONSES_OMIT_TEMPERATURE_MODELS.add(temp_omit_key)
2376
+ continue
2377
+ raise err
2378
+ return _finalize_response(self._parse_chat_response(response))
2379
+
2380
+ return stamp_response_for_route(
2381
+ telemetry.run(
2382
+ lambda: run_provider_limited_call(
2383
+ call=_send_request,
2384
+ provider_key=provider_key,
2385
+ provider_concurrency_caps=self.provider_concurrency_caps,
2386
+ retry_settings=self.provider_retry_settings,
2387
+ operation="responses_chat",
2388
+ sleep_fn=self._provider_sleep_fn,
2389
+ random_fn=self._provider_random_fn,
2390
+ on_retry=telemetry.on_retry,
2391
+ on_retry_event=getattr(
2392
+ self,
2393
+ "_provider_retry_event_observer",
2394
+ None,
2395
+ ),
2396
+ retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
2397
+ retry_wall_clock_cap_seconds=getattr(
2398
+ self,
2399
+ "_provider_retry_wall_clock_cap_seconds",
2400
+ _PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS,
2401
+ ),
2402
+ )
2403
+ ),
2404
+ self.route_identity,
2405
+ )
2406
+
2407
+ @staticmethod
2408
+ def _parse_stream_response(
2409
+ response: httpx.Response,
2410
+ *,
2411
+ on_text_delta: Callable[[str], None] | None,
2412
+ on_reasoning_delta: Callable[[str], None] | None,
2413
+ ) -> LLMResponse:
2414
+ accumulator = _OpenAIResponsesStreamAccumulator(
2415
+ on_text_delta=on_text_delta,
2416
+ on_reasoning_delta=on_reasoning_delta,
2417
+ )
2418
+ for frame in iter_sse_frames(response.iter_lines()):
2419
+ raw_event = parse_sse_json_frame(frame, stream_name="OpenAI Responses stream")
2420
+ if not isinstance(raw_event, dict):
2421
+ raise LLMError("OpenAI Responses stream emitted non-object JSON event")
2422
+ accumulator.handle(frame, raw_event)
2423
+ data = accumulator.finish()
2424
+ return OpenAIResponsesClient._parse_chat_response(_response_from_json(data))
2425
+
2426
+ @staticmethod
2427
+ def _parse_chat_response(response: httpx.Response) -> LLMResponse:
2428
+ try:
2429
+ data = response.json()
2430
+ except Exception as e: # noqa: BLE001
2431
+ raise LLMError("OpenAI Responses returned non-JSON response") from e
2432
+ if not isinstance(data, dict):
2433
+ raise LLMError("Unexpected OpenAI Responses payload: expected JSON object")
2434
+
2435
+ content = _extract_answer_text(data)
2436
+ tool_calls = _parse_response_tool_calls(data)
2437
+ reasoning = _responses_reasoning_outputs(data)
2438
+ if not content and not tool_calls:
2439
+ refusal = _extract_refusal(data)
2440
+ if refusal:
2441
+ raise LLMError(f"OpenAI Responses refusal: {refusal}")
2442
+ if not _has_responses_reasoning_output(data):
2443
+ status = str(data.get("status") or "").strip()
2444
+ # A completed response with no output is a valid provider result,
2445
+ # even though it gives the agent nothing to act on. Preserve that
2446
+ # structure as an empty LLMResponse so the shared turn-level
2447
+ # recovery and stall policy can decide what happens next. Other
2448
+ # statuses remain provider failures and must stay explicit.
2449
+ if status.casefold() != "completed":
2450
+ suffix = f" (status={status})" if status else ""
2451
+ raise LLMError(
2452
+ f"OpenAI Responses returned no assistant text or tool calls{suffix}"
2453
+ )
2454
+
2455
+ response_model = data.get("model") if isinstance(data.get("model"), str) else None
2456
+ return LLMResponse(
2457
+ content=content,
2458
+ tool_calls=tool_calls,
2459
+ raw=data,
2460
+ response_model=response_model,
2461
+ usage=_parse_usage(data.get("usage")),
2462
+ provider_metadata=_responses_provider_metadata(data),
2463
+ reasoning=reasoning,
2464
+ assistant_phase=_assistant_response_phase(data),
2465
+ )
2466
+
2467
+ def web_search(
2468
+ self,
2469
+ *,
2470
+ query: str,
2471
+ allowed_domains: list[str] | None = None,
2472
+ external_web_access: bool | None = None,
2473
+ include_source_details: bool = True,
2474
+ tool_choice: str | dict[str, Any] | None = "required",
2475
+ ) -> WebSearchResponse:
2476
+ url = f"{self.base_url}/responses"
2477
+
2478
+ tool_spec: dict[str, Any] = {"type": "web_search"}
2479
+ if allowed_domains:
2480
+ tool_spec["filters"] = {"allowed_domains": list(allowed_domains)}
2481
+ if external_web_access is not None:
2482
+ tool_spec["external_web_access"] = bool(external_web_access)
2483
+
2484
+ include_omit_key = _responses_endpoint_model_key(self.base_url, self.model)
2485
+ include_omitted = include_omit_key in _RESPONSES_OMIT_INCLUDE_ENDPOINTS
2486
+
2487
+ payload: dict[str, Any] = {
2488
+ "model": self.model,
2489
+ "input": query,
2490
+ "tools": [tool_spec],
2491
+ }
2492
+ if tool_choice is not None:
2493
+ payload["tool_choice"] = tool_choice
2494
+ if include_source_details and not include_omitted:
2495
+ payload["include"] = ["web_search_call.action.sources"]
2496
+ if self.provider_auth is not None:
2497
+ payload = self.provider_auth.adapt_responses_payload(payload)
2498
+
2499
+ provider_key = self.provider_key or best_effort_provider_key(
2500
+ base_url=self.base_url,
2501
+ model=self.model,
2502
+ )
2503
+
2504
+ def _perform_request(request_payload: dict[str, Any]) -> httpx.Response:
2505
+ def _send_request() -> httpx.Response:
2506
+ auth_refresh_used = False
2507
+ try:
2508
+ while True:
2509
+ with httpx.Client(
2510
+ timeout=self.timeout_s, transport=self._transport
2511
+ ) as client:
2512
+ response = client.post(
2513
+ url,
2514
+ headers=self._headers(url, force_refresh=auth_refresh_used),
2515
+ json=request_payload,
2516
+ )
2517
+ if (
2518
+ response.status_code == 401
2519
+ and self.provider_auth is not None
2520
+ and not auth_refresh_used
2521
+ ):
2522
+ auth_refresh_used = True
2523
+ continue
2524
+ break
2525
+ except httpx.DecodingError as e:
2526
+ raise ResponsesError(
2527
+ "Responses response decompression failed: "
2528
+ f"{sanitize_error_text_for_output(e)}"
2529
+ ) from e
2530
+ except Exception as e: # noqa: BLE001
2531
+ raise ResponsesError(
2532
+ f"Responses request failed: {sanitize_error_text_for_output(e)}"
2533
+ ) from e
2534
+ if response.status_code >= 400:
2535
+ raise self._error_from_response(response)
2536
+ return response
2537
+
2538
+ return run_provider_limited_call(
2539
+ call=_send_request,
2540
+ provider_key=provider_key,
2541
+ provider_concurrency_caps=self.provider_concurrency_caps,
2542
+ retry_settings=self.provider_retry_settings,
2543
+ operation="responses_web_search",
2544
+ sleep_fn=self._provider_sleep_fn,
2545
+ random_fn=self._provider_random_fn,
2546
+ retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
2547
+ )
2548
+
2549
+ try:
2550
+ response = _perform_request(payload)
2551
+ except ResponsesError as err:
2552
+ if "include" not in payload or not _responses_include_unsupported(err):
2553
+ raise
2554
+ # The gateway rejected the optional ``include`` entries. Source
2555
+ # metadata is an enhancement, not a requirement: retry once without
2556
+ # it and remember the capability so later calls skip the doomed
2557
+ # variant. Bounded — the retried payload has no ``include``.
2558
+ _RESPONSES_OMIT_INCLUDE_ENDPOINTS.add(include_omit_key)
2559
+ include_omitted = True
2560
+ payload = {key: value for key, value in payload.items() if key != "include"}
2561
+ response = _perform_request(payload)
2562
+
2563
+ try:
2564
+ data = response.json()
2565
+ except Exception as e: # noqa: BLE001
2566
+ raise ResponsesError("Responses API returned non-JSON response") from e
2567
+
2568
+ if not isinstance(data, dict):
2569
+ raise ResponsesError("Unexpected Responses API payload: expected JSON object")
2570
+
2571
+ if response.status_code >= 400:
2572
+ error_message = _extract_error_message(data)
2573
+ if error_message:
2574
+ lower = error_message.lower()
2575
+ if "unsupported" in lower or "not support" in lower:
2576
+ raise ResponsesError(f"Responses web_search unsupported: {error_message}")
2577
+ raise ResponsesError(f"Responses error {response.status_code}: {error_message}")
2578
+ raise ResponsesError(f"Responses error {response.status_code}: {data!r}")
2579
+
2580
+ answer = _extract_answer_text(data)
2581
+ citations = _extract_citations(data)
2582
+ sources, queries = _extract_sources_and_queries(data)
2583
+ sources = _merge_citation_sources(sources, citations)
2584
+ if not sources:
2585
+ # Absent source metadata is tolerated only when it was not
2586
+ # requested (the gateway rejected the optional include) AND the
2587
+ # response still shows a real search happened. A response with no
2588
+ # web_search_call at all never searched — treat it as a failure so
2589
+ # callers (e.g. auto-mode fallback) can engage a working backend
2590
+ # instead of accepting an unsourced answer.
2591
+ if not (include_omitted and _has_web_search_call_output(data)):
2592
+ raise ResponsesError("Responses web_search did not return sources")
2593
+
2594
+ response_id = str(data.get("id") or "").strip() or None
2595
+ response_model = str(data.get("model") or "").strip() or None
2596
+ return WebSearchResponse(
2597
+ answer=answer,
2598
+ citations=citations,
2599
+ sources=sources,
2600
+ queries=queries,
2601
+ raw=data,
2602
+ response_id=response_id,
2603
+ model=response_model,
2604
+ )