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,2012 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+ from hashlib import sha256
7
+ from threading import RLock
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ import httpx
12
+
13
+ from ..branding import env_get
14
+ from ..config import (
15
+ AppConfig,
16
+ ConfigError,
17
+ is_first_party_openai_base_url,
18
+ resolve_web_search_adapter,
19
+ resolve_web_search_api_key,
20
+ resolve_web_search_base_url,
21
+ resolve_web_search_explicit_base_url,
22
+ resolve_web_search_mode,
23
+ resolve_web_search_model,
24
+ resolve_web_search_timeout_s,
25
+ )
26
+ from ..llm.metadata import endpoint_descriptor
27
+ from ..llm.openai_responses import OpenAIResponsesClient, ResponsesError
28
+ from ..llm.protocols import OPENAI_COMPAT_PROTOCOL, OPENAI_RESPONSES_PROTOCOL
29
+ from ..llm.provider_limits import ProviderRetrySettings, resolve_provider_retry_settings
30
+ from ..profiles import active_subscription_selection_ready, get_active_profile
31
+ from ..provider_telemetry import record_web_search_call
32
+ from ..safety.safe_http import Resolver
33
+ from ..web_search_adapters import (
34
+ ANTHROPIC_MESSAGES_ADAPTER,
35
+ AUTO_WEB_SEARCH_ADAPTER,
36
+ COHERE_WEB_SEARCH_ADAPTER,
37
+ DASHSCOPE_CHAT_ADAPTER,
38
+ DDGS_ADAPTER,
39
+ EXTERNAL_WEB_SEARCH_ADAPTERS,
40
+ GEMINI_GROUNDING_ADAPTER,
41
+ GROQ_COMPOUND_ADAPTER,
42
+ MINIMAX_CODING_PLAN_ADAPTER,
43
+ MISTRAL_CONVERSATIONS_ADAPTER,
44
+ MOONSHOT_KIMI_ADAPTER,
45
+ NATIVE_WEB_SEARCH_ADAPTERS,
46
+ OPENAI_RESPONSES_ADAPTER,
47
+ OPENROUTER_WEB_ADAPTER,
48
+ PERPLEXITY_SONAR_ADAPTER,
49
+ TAVILY_ADAPTER,
50
+ VALID_WEB_SEARCH_ADAPTERS,
51
+ VOLCENGINE_WEB_SEARCH_ADAPTER,
52
+ WEB_SEARCH_ADAPTER_CHOICES,
53
+ XAI_RESPONSES_ADAPTER,
54
+ ZHIPU_WEB_SEARCH_ADAPTER,
55
+ normalize_web_search_adapter,
56
+ web_search_adapter_is_external,
57
+ web_search_adapter_is_native,
58
+ )
59
+ from .web_search_dashscope import (
60
+ DashScopeChatSearchError,
61
+ dashscope_chat_search,
62
+ dashscope_model_supports_web_search,
63
+ )
64
+ from .web_search_ddgs import (
65
+ KEYLESS_WEB_SEARCH_ENV,
66
+ DdgsSearchError,
67
+ ddgs_package_available,
68
+ ddgs_search,
69
+ keyless_web_search_enabled,
70
+ )
71
+ from .web_search_provider_adapters import (
72
+ ProviderWebSearchError,
73
+ anthropic_messages_search,
74
+ cohere_web_search,
75
+ gemini_grounding_search,
76
+ groq_compound_search,
77
+ minimax_coding_plan_search,
78
+ mistral_conversations_search,
79
+ moonshot_kimi_search,
80
+ openrouter_web_search,
81
+ perplexity_sonar_search,
82
+ volcengine_web_search,
83
+ zhipu_web_search,
84
+ )
85
+ from .web_search_tavily import TavilySearchError, tavily_search
86
+
87
+
88
+ class WebSearchError(RuntimeError):
89
+ """Raised for any web_search failure.
90
+
91
+ ``recoverable=True`` marks failures the calling model can fix by adjusting its
92
+ own tool arguments (input validation, argument-contract violations). The agent
93
+ loop returns those to the model as plain errors instead of disabling web tools
94
+ for the rest of the turn.
95
+ """
96
+
97
+ def __init__(self, message: str, *, recoverable: bool = False) -> None:
98
+ super().__init__(message)
99
+ self.recoverable = recoverable
100
+
101
+
102
+ _OPENAI_RESPONSES_PROVIDER = OPENAI_RESPONSES_ADAPTER
103
+ _XAI_RESPONSES_PROVIDER = XAI_RESPONSES_ADAPTER
104
+ _ANTHROPIC_MESSAGES_PROVIDER = ANTHROPIC_MESSAGES_ADAPTER
105
+ _GEMINI_GROUNDING_PROVIDER = GEMINI_GROUNDING_ADAPTER
106
+ _OPENROUTER_WEB_PROVIDER = OPENROUTER_WEB_ADAPTER
107
+ _DASHSCOPE_CHAT_PROVIDER = DASHSCOPE_CHAT_ADAPTER
108
+ _MOONSHOT_KIMI_PROVIDER = MOONSHOT_KIMI_ADAPTER
109
+ _ZHIPU_WEB_SEARCH_PROVIDER = ZHIPU_WEB_SEARCH_ADAPTER
110
+ _VOLCENGINE_WEB_SEARCH_PROVIDER = VOLCENGINE_WEB_SEARCH_ADAPTER
111
+ _PERPLEXITY_SONAR_PROVIDER = PERPLEXITY_SONAR_ADAPTER
112
+ _GROQ_COMPOUND_PROVIDER = GROQ_COMPOUND_ADAPTER
113
+ _MISTRAL_CONVERSATIONS_PROVIDER = MISTRAL_CONVERSATIONS_ADAPTER
114
+ _MINIMAX_CODING_PLAN_PROVIDER = MINIMAX_CODING_PLAN_ADAPTER
115
+ _COHERE_WEB_SEARCH_PROVIDER = COHERE_WEB_SEARCH_ADAPTER
116
+ _TAVILY_PROVIDER = TAVILY_ADAPTER
117
+ _DDGS_PROVIDER = DDGS_ADAPTER
118
+ _WEB_SEARCH_PROVIDER_ENV = "ALYSIS_WEB_SEARCH_PROVIDER"
119
+ _WEB_SEARCH_ADAPTER_ENV = "ALYSIS_WEB_SEARCH_ADAPTER"
120
+ _VALID_WEB_SEARCH_PROVIDERS = set(VALID_WEB_SEARCH_ADAPTERS) - {AUTO_WEB_SEARCH_ADAPTER}
121
+ _WEB_SEARCH_MODE_OFF = "off"
122
+ _WEB_SEARCH_MODE_AUTO = "auto"
123
+ _WEB_SEARCH_MODE_NATIVE = "native"
124
+ _WEB_SEARCH_MODE_EXTERNAL = "external"
125
+
126
+ # web_search runs as a one-shot tool call that the agent loop re-issues on its own
127
+ # when needed, so a slow/failed provider call should fail fast rather than burn the
128
+ # full provider retry budget. A ~20s read timeout retried 5x is ~148s of dead air
129
+ # before the agent's own retry even gets a chance. Cap web_search provider retries
130
+ # low; a transient blip still gets one quick retry.
131
+ _WEB_SEARCH_MAX_PROVIDER_RETRIES = 1
132
+
133
+ # OpenRouter web_search runs a full upstream chat round-trip *plus* the web search,
134
+ # and the hosted Alysis Code MiMo trial proxy adds a slow reasoning model on top, so a
135
+ # short web_search_timeout_s starves it (a 20s budget times out mid-read). Floor the
136
+ # OpenRouter web adapter's per-attempt budget so an under-configured timeout still has
137
+ # time to return results; an explicitly higher web_search_timeout_s is preserved.
138
+ _OPENROUTER_WEB_MIN_TIMEOUT_S = 60.0
139
+
140
+
141
+ # After a native backend hard-fails in ``auto`` mode and an external backend
142
+ # serves the result, remember that per session: re-attempting the broken native
143
+ # backend on every call wastes a full failed round-trip before each fallback.
144
+ # The preference is dropped as soon as the remembered backend stops working or
145
+ # the native runtime it shadowed is reconfigured (fingerprint mismatch), so a
146
+ # recovered or fixed native backend is retried on the next call after that.
147
+ _AUTO_FALLBACK_PREFERENCES_LOCK = RLock()
148
+ _AUTO_FALLBACK_PREFERENCE_BY_SESSION: dict[str, tuple[str, str]] = {}
149
+
150
+
151
+ def _web_search_runtime_fingerprint(runtime: WebSearchRuntimeConfig) -> str:
152
+ key_digest = sha256(str(runtime.api_key or "").encode("utf-8")).hexdigest()[:16]
153
+ return "\n".join(
154
+ [
155
+ str(runtime.provider or ""),
156
+ str(runtime.base_url or ""),
157
+ str(runtime.model or ""),
158
+ key_digest,
159
+ ]
160
+ )
161
+
162
+
163
+ def _record_session_fallback_preference(
164
+ session_id: str | None,
165
+ provider: str,
166
+ *,
167
+ failed_runtime_fingerprint: str,
168
+ ) -> None:
169
+ if not session_id:
170
+ return
171
+ with _AUTO_FALLBACK_PREFERENCES_LOCK:
172
+ _AUTO_FALLBACK_PREFERENCE_BY_SESSION[session_id] = (
173
+ provider,
174
+ failed_runtime_fingerprint,
175
+ )
176
+
177
+
178
+ def _session_fallback_preference(
179
+ session_id: str | None,
180
+ *,
181
+ current_runtime_fingerprint: str,
182
+ ) -> str | None:
183
+ """Return the remembered fallback backend, or None when it no longer applies.
184
+
185
+ A preference recorded against a differently-configured native runtime is
186
+ discarded: the user changing the web-search configuration mid-session must
187
+ give the newly configured backend a fresh chance.
188
+ """
189
+
190
+ if not session_id:
191
+ return None
192
+ with _AUTO_FALLBACK_PREFERENCES_LOCK:
193
+ stored = _AUTO_FALLBACK_PREFERENCE_BY_SESSION.get(session_id)
194
+ if stored is None:
195
+ return None
196
+ provider, failed_fingerprint = stored
197
+ if failed_fingerprint != current_runtime_fingerprint:
198
+ _AUTO_FALLBACK_PREFERENCE_BY_SESSION.pop(session_id, None)
199
+ return None
200
+ return provider
201
+
202
+
203
+ def _clear_session_fallback_preference(session_id: str | None) -> None:
204
+ if not session_id:
205
+ return
206
+ with _AUTO_FALLBACK_PREFERENCES_LOCK:
207
+ _AUTO_FALLBACK_PREFERENCE_BY_SESSION.pop(session_id, None)
208
+
209
+
210
+ def _reset_web_search_session_state_for_tests() -> None:
211
+ with _AUTO_FALLBACK_PREFERENCES_LOCK:
212
+ _AUTO_FALLBACK_PREFERENCE_BY_SESSION.clear()
213
+
214
+
215
+ def _web_search_provider_retry_settings(cfg: AppConfig | None) -> ProviderRetrySettings:
216
+ base = resolve_provider_retry_settings(cfg)
217
+ capped = min(base.max_retries, _WEB_SEARCH_MAX_PROVIDER_RETRIES)
218
+ if capped == base.max_retries:
219
+ return base
220
+ return ProviderRetrySettings(
221
+ max_retries=capped,
222
+ base_delay_seconds=base.base_delay_seconds,
223
+ max_delay_seconds=base.max_delay_seconds,
224
+ )
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class WebSearchRuntimeConfig:
229
+ provider: str
230
+ base_url: str | None
231
+ api_key: str
232
+ model: str | None
233
+ timeout_s: float
234
+ auth_provider: str | None = None
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class WebSearchRuntimeStatus:
239
+ mode: str
240
+ provider: str | None
241
+ base_url: str | None
242
+ model: str | None
243
+ api_key_available: bool
244
+ registration_ready: bool
245
+ notes: tuple[str, ...]
246
+
247
+ def to_payload(self) -> dict[str, Any]:
248
+ return {
249
+ "mode": self.mode,
250
+ "provider": self.provider,
251
+ "base_url_descriptor": (endpoint_descriptor(self.base_url) if self.base_url else None),
252
+ "model": self.model,
253
+ "api_key_available": self.api_key_available,
254
+ "registration_ready": self.registration_ready,
255
+ "setup_hint": self.setup_hint,
256
+ "notes": list(self.notes),
257
+ }
258
+
259
+ @property
260
+ def availability_label(self) -> str:
261
+ if self.registration_ready:
262
+ return "available"
263
+ if self.mode == _WEB_SEARCH_MODE_OFF:
264
+ return "disabled"
265
+ return f"{self.mode}-unavailable"
266
+
267
+ @property
268
+ def summary(self) -> str:
269
+ if self.registration_ready and self.provider:
270
+ return f"available via {self.provider}"
271
+ if len(self.notes) > 1 and self.notes[0].startswith("explicit adapter selected "):
272
+ return self.notes[1]
273
+ if self.notes:
274
+ return self.notes[0]
275
+ return "unavailable"
276
+
277
+ @property
278
+ def setup_hint(self) -> str:
279
+ if self.registration_ready:
280
+ if self.provider == _TAVILY_PROVIDER:
281
+ return (
282
+ "Provider-agnostic web search is ready via the external search key; chat and "
283
+ "top-level Plan/readonly sessions can use it."
284
+ )
285
+ if self.provider == _DDGS_PROVIDER:
286
+ return (
287
+ "Keyless web search (DuckDuckGo metasearch) is ready — no API key needed; "
288
+ "set TAVILY_API_KEY for a higher-quality external backend."
289
+ )
290
+ if self.provider == _DASHSCOPE_CHAT_PROVIDER:
291
+ return (
292
+ "Native DashScope/Qwen web search is ready; chat and top-level "
293
+ "Plan/readonly sessions can use it."
294
+ )
295
+ if self.provider == _MOONSHOT_KIMI_PROVIDER:
296
+ return "Native Moonshot/Kimi web_search is ready."
297
+ if self.provider == _ZHIPU_WEB_SEARCH_PROVIDER:
298
+ return "Native Zhipu/GLM web_search is ready."
299
+ if self.provider == _VOLCENGINE_WEB_SEARCH_PROVIDER:
300
+ return "Native Volcengine/Doubao web_search is ready."
301
+ if self.provider == _OPENAI_RESPONSES_PROVIDER:
302
+ return (
303
+ "Native OpenAI Responses web search is ready; chat and top-level "
304
+ "Plan/readonly sessions can use it."
305
+ )
306
+ if self.provider == _XAI_RESPONSES_PROVIDER:
307
+ return "Native xAI Responses web_search is ready."
308
+ if self.provider == _ANTHROPIC_MESSAGES_PROVIDER:
309
+ return "Native Anthropic Messages web_search is ready."
310
+ if self.provider == _GEMINI_GROUNDING_PROVIDER:
311
+ return "Native Gemini Google Search grounding is ready."
312
+ if self.provider == _OPENROUTER_WEB_PROVIDER:
313
+ return "OpenRouter server-side web_search is ready."
314
+ if self.provider == _PERPLEXITY_SONAR_PROVIDER:
315
+ return "Perplexity Sonar web-grounded search is ready."
316
+ if self.provider == _GROQ_COMPOUND_PROVIDER:
317
+ return "Groq Compound web search is ready."
318
+ if self.provider == _MISTRAL_CONVERSATIONS_PROVIDER:
319
+ return "Mistral Conversations web_search is ready."
320
+ if self.provider == _MINIMAX_CODING_PLAN_PROVIDER:
321
+ return "MiniMax Token Plan web_search is ready."
322
+ if self.provider == _COHERE_WEB_SEARCH_PROVIDER:
323
+ return "Cohere hosted web-search connector is ready."
324
+ return "web_search is ready for chat and top-level Plan/readonly sessions."
325
+
326
+ if self.mode == _WEB_SEARCH_MODE_OFF:
327
+ return "Enable with `alysis config set web_search_mode auto`, `native`, or `external`."
328
+
329
+ if self.notes and self.notes[0].startswith("invalid web_search_adapter: "):
330
+ return (
331
+ f"Fix web_search_adapter, {_WEB_SEARCH_ADAPTER_ENV}, or {_WEB_SEARCH_PROVIDER_ENV}."
332
+ )
333
+
334
+ if any(" is incompatible with " in note for note in self.notes):
335
+ return "Choose a web_search_adapter compatible with the selected web_search_mode."
336
+
337
+ if len(self.notes) > 1 and self.notes[0].startswith("explicit adapter selected "):
338
+ return f"The selected web_search_adapter is not ready: {self.notes[1]}"
339
+
340
+ if not self.api_key_available:
341
+ if self.mode == _WEB_SEARCH_MODE_NATIVE:
342
+ return (
343
+ "Set the active provider key with `alysis config set-api-key`, "
344
+ "ALYSIS_API_KEY, or ALYSIS_WEB_SEARCH_API_KEY. Native mode "
345
+ "does not use Tavily or other external fallback search providers."
346
+ )
347
+ if self.mode == _WEB_SEARCH_MODE_EXTERNAL:
348
+ return (
349
+ "Set ALYSIS_WEB_SEARCH_API_KEY or TAVILY_API_KEY for external "
350
+ "web_search, or install the 'ddgs' package for the keyless backend."
351
+ )
352
+ return (
353
+ "Set an API key with `alysis config set-api-key`, "
354
+ "ALYSIS_API_KEY, or set ALYSIS_WEB_SEARCH_API_KEY/TAVILY_API_KEY "
355
+ "for provider-agnostic fallback; installing the 'ddgs' package enables "
356
+ "keyless fallback search."
357
+ )
358
+
359
+ if self.mode == _WEB_SEARCH_MODE_NATIVE:
360
+ return (
361
+ "Use a native search-capable provider/profile (OpenAI, xAI, Anthropic, "
362
+ "Gemini, OpenRouter, DashScope/Qwen, Kimi, Zhipu/GLM, Doubao, "
363
+ "Perplexity, Groq, Mistral, MiniMax Token Plan, or Cohere). Native mode "
364
+ "never falls back to Tavily."
365
+ )
366
+ if self.mode == _WEB_SEARCH_MODE_EXTERNAL:
367
+ return (
368
+ "Use an external web_search adapter: set ALYSIS_WEB_SEARCH_API_KEY or "
369
+ "TAVILY_API_KEY for Tavily, or install the 'ddgs' package for the "
370
+ "keyless backend."
371
+ )
372
+
373
+ return (
374
+ "Use a native search-capable provider/profile (OpenAI, xAI, Anthropic, Gemini, "
375
+ "OpenRouter, DashScope/Qwen, Kimi, Zhipu/GLM, Doubao, Perplexity, Groq, "
376
+ "Mistral, MiniMax Token Plan, or Cohere), set "
377
+ "ALYSIS_WEB_SEARCH_API_KEY/TAVILY_API_KEY, or install the 'ddgs' package "
378
+ "for keyless fallback search."
379
+ )
380
+
381
+
382
+ @dataclass(frozen=True)
383
+ class _BackendReadiness:
384
+ provider: str
385
+ runtime: WebSearchRuntimeConfig | None
386
+ base_url: str | None
387
+ model: str | None
388
+ api_key_available: bool
389
+ notes: tuple[str, ...]
390
+
391
+
392
+ def _append_unique_note(notes: list[str], message: str) -> None:
393
+ normalized = str(message or "").strip()
394
+ if normalized and normalized not in notes:
395
+ notes.append(normalized)
396
+
397
+
398
+ def _combine_notes(*note_groups: tuple[str, ...]) -> tuple[str, ...]:
399
+ merged: list[str] = []
400
+ for group in note_groups:
401
+ for note in group:
402
+ _append_unique_note(merged, note)
403
+ return tuple(merged)
404
+
405
+
406
+ def _resolve_provider_override(
407
+ *,
408
+ cfg: AppConfig | None,
409
+ strict: bool = False,
410
+ ) -> tuple[str | None, str | None]:
411
+ raw_value = str(env_get(_WEB_SEARCH_ADAPTER_ENV) or "").strip().lower()
412
+ if not raw_value:
413
+ raw_value = str(env_get(_WEB_SEARCH_PROVIDER_ENV) or "").strip().lower()
414
+ try:
415
+ adapter = (
416
+ normalize_web_search_adapter(raw_value)
417
+ if raw_value
418
+ else resolve_web_search_adapter(cfg)
419
+ )
420
+ except Exception as exc:
421
+ message = str(exc)
422
+ if strict:
423
+ raise WebSearchError(message) from exc
424
+ return None, message
425
+ if adapter == AUTO_WEB_SEARCH_ADAPTER:
426
+ return None, None
427
+ if adapter in _VALID_WEB_SEARCH_PROVIDERS:
428
+ return adapter, None
429
+ message = (
430
+ f"web_search_adapter must be one of: "
431
+ f"{', '.join(item for item in WEB_SEARCH_ADAPTER_CHOICES if item != AUTO_WEB_SEARCH_ADAPTER)}"
432
+ )
433
+ if strict:
434
+ raise WebSearchError(message)
435
+ return None, message
436
+
437
+
438
+ def _resolve_runtime_base_url(
439
+ cfg: AppConfig | None,
440
+ ) -> tuple[str | None, str | None]:
441
+ explicit_base_url = resolve_web_search_explicit_base_url(cfg)
442
+ if explicit_base_url:
443
+ return explicit_base_url, explicit_base_url
444
+
445
+ candidate_base_url = resolve_web_search_base_url(cfg)
446
+ if is_first_party_openai_base_url(candidate_base_url):
447
+ return candidate_base_url, candidate_base_url
448
+ return candidate_base_url, None
449
+
450
+
451
+ def _responses_auth_provider_for_base_url(
452
+ *,
453
+ cfg: AppConfig | None,
454
+ base_url: str | None,
455
+ ) -> str | None:
456
+ if cfg is None:
457
+ return None
458
+ try:
459
+ profile = get_active_profile(cfg)
460
+ except Exception: # noqa: BLE001
461
+ return None
462
+ provider_id = str(profile.auth_provider or "").strip()
463
+ if not provider_id or str(profile.protocol or "").strip() != OPENAI_RESPONSES_PROTOCOL:
464
+ return None
465
+ try:
466
+ from ..provider_auth import create_provider_auth
467
+
468
+ adapter = create_provider_auth(provider_id)
469
+ except Exception: # noqa: BLE001
470
+ return None
471
+ if str(getattr(adapter, "protocol", "") or "").strip() != OPENAI_RESPONSES_PROTOCOL:
472
+ return None
473
+ normalized_base = str(base_url or "").strip().rstrip("/")
474
+ adapter_base = str(getattr(adapter, "base_url", "") or "").strip().rstrip("/")
475
+ return provider_id if normalized_base and normalized_base == adapter_base else None
476
+
477
+
478
+ def _is_dashscope_base_url(base_url: str | None) -> bool:
479
+ normalized = str(base_url or "").strip()
480
+ if not normalized:
481
+ return False
482
+ try:
483
+ parsed = urlsplit(normalized)
484
+ except ValueError:
485
+ return False
486
+ if parsed.scheme.lower() != "https":
487
+ return False
488
+ hostname = (parsed.hostname or "").rstrip(".").lower()
489
+ return (
490
+ hostname == "dashscope.aliyuncs.com"
491
+ or hostname == "dashscope-intl.aliyuncs.com"
492
+ or hostname == "dashscope-us.aliyuncs.com"
493
+ or hostname.endswith(".dashscope.aliyuncs.com")
494
+ or hostname.endswith(".dashscope-intl.aliyuncs.com")
495
+ or hostname.endswith(".dashscope-us.aliyuncs.com")
496
+ or (hostname.endswith(".aliyuncs.com") and ".dashscope-" in f".{hostname}")
497
+ )
498
+
499
+
500
+ def _dashscope_model_supports_web_search(model: str | None) -> bool:
501
+ return dashscope_model_supports_web_search(model)
502
+
503
+
504
+ def _base_url_host(base_url: str | None) -> str:
505
+ normalized = str(base_url or "").strip()
506
+ if not normalized:
507
+ return ""
508
+ try:
509
+ parsed = urlsplit(normalized)
510
+ except ValueError:
511
+ return ""
512
+ return (parsed.hostname or "").rstrip(".").lower()
513
+
514
+
515
+ def _is_host_or_subdomain(host: str, suffix: str) -> bool:
516
+ return host == suffix or host.endswith(f".{suffix}")
517
+
518
+
519
+ def _is_anthropic_base_url(base_url: str | None) -> bool:
520
+ return _is_host_or_subdomain(_base_url_host(base_url), "api.anthropic.com")
521
+
522
+
523
+ def _is_gemini_base_url(base_url: str | None) -> bool:
524
+ return _is_host_or_subdomain(_base_url_host(base_url), "generativelanguage.googleapis.com")
525
+
526
+
527
+ def _is_xai_base_url(base_url: str | None) -> bool:
528
+ return _is_host_or_subdomain(_base_url_host(base_url), "api.x.ai")
529
+
530
+
531
+ def _is_openrouter_base_url(base_url: str | None) -> bool:
532
+ # NOTE: the Alysis Code hosted proxy (`…supabase.co/functions/v1/llm`) used to
533
+ # be classified as OpenRouter while it forwarded to OpenRouter/Xiaomi during
534
+ # the retired MiMo trial. It now forwards to DeepSeek (no native web
535
+ # search), so no special-case applies anymore.
536
+ return _is_host_or_subdomain(_base_url_host(base_url), "openrouter.ai")
537
+
538
+
539
+ def _is_perplexity_base_url(base_url: str | None) -> bool:
540
+ return _is_host_or_subdomain(_base_url_host(base_url), "api.perplexity.ai")
541
+
542
+
543
+ def _is_groq_base_url(base_url: str | None) -> bool:
544
+ return _is_host_or_subdomain(_base_url_host(base_url), "api.groq.com")
545
+
546
+
547
+ def _is_mistral_base_url(base_url: str | None) -> bool:
548
+ return _is_host_or_subdomain(_base_url_host(base_url), "api.mistral.ai")
549
+
550
+
551
+ def _is_minimax_base_url(base_url: str | None) -> bool:
552
+ host = _base_url_host(base_url)
553
+ return _is_host_or_subdomain(host, "api.minimax.io") or _is_host_or_subdomain(
554
+ host,
555
+ "api.minimaxi.com",
556
+ )
557
+
558
+
559
+ def _is_cohere_base_url(base_url: str | None) -> bool:
560
+ host = _base_url_host(base_url)
561
+ return _is_host_or_subdomain(host, "api.cohere.ai") or _is_host_or_subdomain(
562
+ host,
563
+ "api.cohere.com",
564
+ )
565
+
566
+
567
+ def _is_moonshot_base_url(base_url: str | None) -> bool:
568
+ host = _base_url_host(base_url)
569
+ return _is_host_or_subdomain(host, "api.moonshot.cn") or _is_host_or_subdomain(
570
+ host,
571
+ "api.moonshot.ai",
572
+ )
573
+
574
+
575
+ def _is_zhipu_base_url(base_url: str | None) -> bool:
576
+ return _is_host_or_subdomain(_base_url_host(base_url), "open.bigmodel.cn")
577
+
578
+
579
+ def _is_volcengine_base_url(base_url: str | None) -> bool:
580
+ host = _base_url_host(base_url)
581
+ return _is_host_or_subdomain(host, "volces.com") or _is_host_or_subdomain(
582
+ host,
583
+ "volcengine.com",
584
+ )
585
+
586
+
587
+ def _resolve_search_model_or_default(cfg: AppConfig | None, default_model: str) -> str | None:
588
+ model = resolve_web_search_model(cfg)
589
+ if model:
590
+ return model
591
+ return default_model or None
592
+
593
+
594
+ def _groq_search_model(cfg: AppConfig | None) -> str | None:
595
+ model = resolve_web_search_model(cfg)
596
+ if not model:
597
+ return "groq/compound-mini"
598
+ normalized = model.strip().lower()
599
+ if normalized in {"groq/compound", "groq/compound-mini"}:
600
+ return model
601
+ explicit = str(getattr(cfg, "web_search_model", "") or "").strip()
602
+ if explicit:
603
+ return model
604
+ return "groq/compound-mini"
605
+
606
+
607
+ def _resolve_tavily_api_key(cfg: AppConfig | None) -> str | None:
608
+ value = str(env_get("TAVILY_API_KEY") or "").strip()
609
+ if value:
610
+ return value
611
+ return resolve_web_search_api_key(cfg)
612
+
613
+
614
+ def _validate_query(raw_query: Any) -> str:
615
+ query = str(raw_query or "").strip()
616
+ if not query:
617
+ raise WebSearchError("query must be a non-empty string.", recoverable=True)
618
+ return query
619
+
620
+
621
+ def _validate_allowed_domains(raw_allowed_domains: Any) -> list[str] | None:
622
+ if raw_allowed_domains is None:
623
+ return None
624
+ if not isinstance(raw_allowed_domains, list):
625
+ raise WebSearchError(
626
+ "allowed_domains must be an array of non-empty domain strings.",
627
+ recoverable=True,
628
+ )
629
+ cleaned: list[str] = []
630
+ for item in raw_allowed_domains:
631
+ domain = str(item or "").strip().lower()
632
+ if not domain:
633
+ raise WebSearchError(
634
+ "allowed_domains must contain only non-empty domain strings.",
635
+ recoverable=True,
636
+ )
637
+ cleaned.append(domain)
638
+ return cleaned
639
+
640
+
641
+ def _validate_max_sources(raw_max_sources: Any) -> int:
642
+ try:
643
+ max_sources = int(raw_max_sources if raw_max_sources is not None else 8)
644
+ except (TypeError, ValueError) as e:
645
+ raise WebSearchError(
646
+ "max_sources must be an integer between 1 and 20.",
647
+ recoverable=True,
648
+ ) from e
649
+ if max_sources < 1 or max_sources > 20:
650
+ raise WebSearchError("max_sources must be an integer between 1 and 20.", recoverable=True)
651
+ return max_sources
652
+
653
+
654
+ def _validate_external_web_access(raw_external_web_access: Any) -> bool:
655
+ if raw_external_web_access is None:
656
+ return True
657
+ if not isinstance(raw_external_web_access, bool):
658
+ raise WebSearchError("external_web_access must be a boolean.", recoverable=True)
659
+ return raw_external_web_access
660
+
661
+
662
+ def _enforce_external_web_access_contract(*, provider: str, external_web_access: bool) -> None:
663
+ if external_web_access or provider == _OPENAI_RESPONSES_PROVIDER:
664
+ return
665
+ raise WebSearchError(
666
+ "external_web_access=false is supported only by the openai_responses web_search backend; "
667
+ f"{provider} always uses external web access.",
668
+ recoverable=True,
669
+ )
670
+
671
+
672
+ def _resolve_openai_readiness(
673
+ *,
674
+ cfg: AppConfig | None,
675
+ api_key: str | None,
676
+ ) -> _BackendReadiness:
677
+ base_url, registration_base_url = _resolve_runtime_base_url(cfg)
678
+ model = resolve_web_search_model(cfg)
679
+ resolved_api_key = resolve_web_search_api_key(cfg, api_key_fallback=api_key)
680
+ auth_provider = _responses_auth_provider_for_base_url(cfg=cfg, base_url=base_url)
681
+ subscription_selection_ready = True
682
+ if auth_provider:
683
+ registration_base_url = base_url
684
+ try:
685
+ profile = get_active_profile(cfg) # type: ignore[arg-type]
686
+ model = profile.default_model
687
+ subscription_selection_ready = active_subscription_selection_ready(cfg) # type: ignore[arg-type]
688
+ except Exception: # noqa: BLE001
689
+ subscription_selection_ready = False
690
+ notes: list[str] = []
691
+ timeout_s: float | None = None
692
+
693
+ try:
694
+ timeout_s = resolve_web_search_timeout_s(cfg)
695
+ except ConfigError as e:
696
+ _append_unique_note(notes, str(e))
697
+
698
+ if not base_url:
699
+ _append_unique_note(
700
+ notes,
701
+ "missing OpenAI search base URL: set web_search_base_url or use a first-party OpenAI base_url",
702
+ )
703
+ elif not registration_base_url:
704
+ _append_unique_note(
705
+ notes,
706
+ "OpenAI auto readiness requires explicit web_search_base_url or a first-party OpenAI base_url",
707
+ )
708
+ if not model:
709
+ _append_unique_note(
710
+ notes,
711
+ "missing model: set model or web_search_model (ALYSIS_WEB_SEARCH_MODEL is an advanced override)",
712
+ )
713
+ if not resolved_api_key and not auth_provider:
714
+ _append_unique_note(
715
+ notes,
716
+ "missing API key: set ALYSIS_API_KEY or ALYSIS_WEB_SEARCH_API_KEY",
717
+ )
718
+ if auth_provider and not subscription_selection_ready:
719
+ _append_unique_note(
720
+ notes,
721
+ "choose the subscription model and reasoning effort in /config → Default Model",
722
+ )
723
+
724
+ runtime: WebSearchRuntimeConfig | None = None
725
+ credentials_available = bool(resolved_api_key or auth_provider)
726
+ if (
727
+ registration_base_url
728
+ and model
729
+ and credentials_available
730
+ and timeout_s is not None
731
+ and subscription_selection_ready
732
+ ):
733
+ runtime = WebSearchRuntimeConfig(
734
+ provider=_OPENAI_RESPONSES_PROVIDER,
735
+ base_url=registration_base_url,
736
+ api_key=resolved_api_key or "",
737
+ model=model,
738
+ timeout_s=timeout_s,
739
+ auth_provider=auth_provider,
740
+ )
741
+
742
+ return _BackendReadiness(
743
+ provider=_OPENAI_RESPONSES_PROVIDER,
744
+ runtime=runtime,
745
+ base_url=base_url or None,
746
+ model=model or None,
747
+ api_key_available=credentials_available,
748
+ notes=tuple(notes),
749
+ )
750
+
751
+
752
+ def _resolve_dashscope_chat_readiness(
753
+ *,
754
+ cfg: AppConfig | None,
755
+ api_key: str | None,
756
+ ) -> _BackendReadiness:
757
+ base_url = resolve_web_search_base_url(cfg)
758
+ model = resolve_web_search_model(cfg)
759
+ resolved_api_key = resolve_web_search_api_key(cfg, api_key_fallback=api_key)
760
+ notes: list[str] = []
761
+ timeout_s: float | None = None
762
+
763
+ try:
764
+ timeout_s = resolve_web_search_timeout_s(cfg)
765
+ except ConfigError as e:
766
+ _append_unique_note(notes, str(e))
767
+
768
+ if not base_url:
769
+ _append_unique_note(notes, "DashScope chat search requires a DashScope base_url")
770
+ elif not _is_dashscope_base_url(base_url):
771
+ _append_unique_note(notes, "DashScope chat search requires a DashScope base_url")
772
+ if not model:
773
+ _append_unique_note(
774
+ notes,
775
+ "missing model: set model or web_search_model (ALYSIS_WEB_SEARCH_MODEL is an advanced override)",
776
+ )
777
+ elif not _dashscope_model_supports_web_search(model):
778
+ _append_unique_note(
779
+ notes,
780
+ "DashScope web search requires a supported Qwen model such as qwen3.7-plus, "
781
+ "qwen3.6-flash, or qwen3.5-plus",
782
+ )
783
+ if not resolved_api_key:
784
+ _append_unique_note(
785
+ notes,
786
+ "missing API key: set ALYSIS_API_KEY or ALYSIS_WEB_SEARCH_API_KEY",
787
+ )
788
+
789
+ runtime: WebSearchRuntimeConfig | None = None
790
+ if (
791
+ base_url
792
+ and _is_dashscope_base_url(base_url)
793
+ and model
794
+ and _dashscope_model_supports_web_search(model)
795
+ and resolved_api_key
796
+ and timeout_s is not None
797
+ ):
798
+ runtime = WebSearchRuntimeConfig(
799
+ provider=_DASHSCOPE_CHAT_PROVIDER,
800
+ base_url=base_url,
801
+ api_key=resolved_api_key,
802
+ model=model,
803
+ timeout_s=timeout_s,
804
+ )
805
+
806
+ return _BackendReadiness(
807
+ provider=_DASHSCOPE_CHAT_PROVIDER,
808
+ runtime=runtime,
809
+ base_url=base_url or None,
810
+ model=model or None,
811
+ api_key_available=bool(resolved_api_key),
812
+ notes=tuple(notes),
813
+ )
814
+
815
+
816
+ def _resolve_native_provider_readiness(
817
+ *,
818
+ cfg: AppConfig | None,
819
+ api_key: str | None,
820
+ provider: str,
821
+ base_url_label: str,
822
+ base_url_predicate: Callable[[str | None], bool],
823
+ default_model: str = "",
824
+ model_resolver: Callable[[AppConfig | None], str | None] | None = None,
825
+ model_validator: Callable[[str | None], bool] | None = None,
826
+ model_note: str | None = None,
827
+ min_timeout_s: float | None = None,
828
+ ) -> _BackendReadiness:
829
+ base_url = resolve_web_search_base_url(cfg)
830
+ model = (
831
+ model_resolver(cfg)
832
+ if model_resolver is not None
833
+ else _resolve_search_model_or_default(
834
+ cfg,
835
+ default_model,
836
+ )
837
+ )
838
+ resolved_api_key = resolve_web_search_api_key(cfg, api_key_fallback=api_key)
839
+ notes: list[str] = []
840
+ timeout_s: float | None = None
841
+
842
+ try:
843
+ timeout_s = resolve_web_search_timeout_s(cfg)
844
+ except ConfigError as e:
845
+ _append_unique_note(notes, str(e))
846
+
847
+ if timeout_s is not None and min_timeout_s is not None and timeout_s < min_timeout_s:
848
+ timeout_s = min_timeout_s
849
+
850
+ if not base_url:
851
+ _append_unique_note(
852
+ notes, f"{base_url_label} web search requires {base_url_label} base_url"
853
+ )
854
+ elif not base_url_predicate(base_url):
855
+ _append_unique_note(
856
+ notes, f"{base_url_label} web search requires {base_url_label} base_url"
857
+ )
858
+ if not model:
859
+ _append_unique_note(
860
+ notes,
861
+ "missing model: set model, web_search_model, or profile.web_search_model",
862
+ )
863
+ elif model_validator is not None and not model_validator(model):
864
+ _append_unique_note(notes, model_note or f"{base_url_label} model is not search-capable")
865
+ if not resolved_api_key:
866
+ _append_unique_note(
867
+ notes,
868
+ "missing API key: set ALYSIS_API_KEY or ALYSIS_WEB_SEARCH_API_KEY",
869
+ )
870
+
871
+ runtime: WebSearchRuntimeConfig | None = None
872
+ if (
873
+ base_url
874
+ and base_url_predicate(base_url)
875
+ and model
876
+ and (model_validator is None or model_validator(model))
877
+ and resolved_api_key
878
+ and timeout_s is not None
879
+ ):
880
+ runtime = WebSearchRuntimeConfig(
881
+ provider=provider,
882
+ base_url=base_url,
883
+ api_key=resolved_api_key,
884
+ model=model,
885
+ timeout_s=timeout_s,
886
+ )
887
+
888
+ return _BackendReadiness(
889
+ provider=provider,
890
+ runtime=runtime,
891
+ base_url=base_url or None,
892
+ model=model or None,
893
+ api_key_available=bool(resolved_api_key),
894
+ notes=tuple(notes),
895
+ )
896
+
897
+
898
+ def _resolve_xai_responses_readiness(
899
+ *,
900
+ cfg: AppConfig | None,
901
+ api_key: str | None,
902
+ ) -> _BackendReadiness:
903
+ return _resolve_native_provider_readiness(
904
+ cfg=cfg,
905
+ api_key=api_key,
906
+ provider=_XAI_RESPONSES_PROVIDER,
907
+ base_url_label="xAI",
908
+ base_url_predicate=_is_xai_base_url,
909
+ )
910
+
911
+
912
+ def _resolve_anthropic_messages_readiness(
913
+ *,
914
+ cfg: AppConfig | None,
915
+ api_key: str | None,
916
+ ) -> _BackendReadiness:
917
+ return _resolve_native_provider_readiness(
918
+ cfg=cfg,
919
+ api_key=api_key,
920
+ provider=_ANTHROPIC_MESSAGES_PROVIDER,
921
+ base_url_label="Anthropic",
922
+ base_url_predicate=_is_anthropic_base_url,
923
+ )
924
+
925
+
926
+ def _resolve_gemini_grounding_readiness(
927
+ *,
928
+ cfg: AppConfig | None,
929
+ api_key: str | None,
930
+ ) -> _BackendReadiness:
931
+ return _resolve_native_provider_readiness(
932
+ cfg=cfg,
933
+ api_key=api_key,
934
+ provider=_GEMINI_GROUNDING_PROVIDER,
935
+ base_url_label="Gemini",
936
+ base_url_predicate=_is_gemini_base_url,
937
+ )
938
+
939
+
940
+ def _resolve_openrouter_web_readiness(
941
+ *,
942
+ cfg: AppConfig | None,
943
+ api_key: str | None,
944
+ ) -> _BackendReadiness:
945
+ return _resolve_native_provider_readiness(
946
+ cfg=cfg,
947
+ api_key=api_key,
948
+ provider=_OPENROUTER_WEB_PROVIDER,
949
+ base_url_label="OpenRouter",
950
+ base_url_predicate=_is_openrouter_base_url,
951
+ default_model="openrouter/auto",
952
+ min_timeout_s=_OPENROUTER_WEB_MIN_TIMEOUT_S,
953
+ )
954
+
955
+
956
+ def _resolve_perplexity_sonar_readiness(
957
+ *,
958
+ cfg: AppConfig | None,
959
+ api_key: str | None,
960
+ ) -> _BackendReadiness:
961
+ return _resolve_native_provider_readiness(
962
+ cfg=cfg,
963
+ api_key=api_key,
964
+ provider=_PERPLEXITY_SONAR_PROVIDER,
965
+ base_url_label="Perplexity",
966
+ base_url_predicate=_is_perplexity_base_url,
967
+ default_model="sonar",
968
+ )
969
+
970
+
971
+ def _resolve_groq_compound_readiness(
972
+ *,
973
+ cfg: AppConfig | None,
974
+ api_key: str | None,
975
+ ) -> _BackendReadiness:
976
+ return _resolve_native_provider_readiness(
977
+ cfg=cfg,
978
+ api_key=api_key,
979
+ provider=_GROQ_COMPOUND_PROVIDER,
980
+ base_url_label="Groq",
981
+ base_url_predicate=_is_groq_base_url,
982
+ model_resolver=_groq_search_model,
983
+ model_validator=lambda model: (
984
+ str(model or "").strip().lower() in {"groq/compound", "groq/compound-mini"}
985
+ ),
986
+ model_note="Groq web search requires groq/compound or groq/compound-mini",
987
+ )
988
+
989
+
990
+ def _resolve_mistral_conversations_readiness(
991
+ *,
992
+ cfg: AppConfig | None,
993
+ api_key: str | None,
994
+ ) -> _BackendReadiness:
995
+ return _resolve_native_provider_readiness(
996
+ cfg=cfg,
997
+ api_key=api_key,
998
+ provider=_MISTRAL_CONVERSATIONS_PROVIDER,
999
+ base_url_label="Mistral",
1000
+ base_url_predicate=_is_mistral_base_url,
1001
+ default_model="mistral-medium-latest",
1002
+ )
1003
+
1004
+
1005
+ def _resolve_minimax_coding_plan_readiness(
1006
+ *,
1007
+ cfg: AppConfig | None,
1008
+ api_key: str | None,
1009
+ ) -> _BackendReadiness:
1010
+ return _resolve_native_provider_readiness(
1011
+ cfg=cfg,
1012
+ api_key=api_key,
1013
+ provider=_MINIMAX_CODING_PLAN_PROVIDER,
1014
+ base_url_label="MiniMax",
1015
+ base_url_predicate=_is_minimax_base_url,
1016
+ default_model="MiniMax-M2.7",
1017
+ )
1018
+
1019
+
1020
+ def _resolve_cohere_web_search_readiness(
1021
+ *,
1022
+ cfg: AppConfig | None,
1023
+ api_key: str | None,
1024
+ ) -> _BackendReadiness:
1025
+ return _resolve_native_provider_readiness(
1026
+ cfg=cfg,
1027
+ api_key=api_key,
1028
+ provider=_COHERE_WEB_SEARCH_PROVIDER,
1029
+ base_url_label="Cohere",
1030
+ base_url_predicate=_is_cohere_base_url,
1031
+ default_model="command-a-plus-05-2026",
1032
+ )
1033
+
1034
+
1035
+ def _resolve_moonshot_kimi_readiness(
1036
+ *,
1037
+ cfg: AppConfig | None,
1038
+ api_key: str | None,
1039
+ ) -> _BackendReadiness:
1040
+ return _resolve_native_provider_readiness(
1041
+ cfg=cfg,
1042
+ api_key=api_key,
1043
+ provider=_MOONSHOT_KIMI_PROVIDER,
1044
+ base_url_label="Moonshot/Kimi",
1045
+ base_url_predicate=_is_moonshot_base_url,
1046
+ default_model="kimi-k2.6",
1047
+ )
1048
+
1049
+
1050
+ def _resolve_zhipu_web_search_readiness(
1051
+ *,
1052
+ cfg: AppConfig | None,
1053
+ api_key: str | None,
1054
+ ) -> _BackendReadiness:
1055
+ return _resolve_native_provider_readiness(
1056
+ cfg=cfg,
1057
+ api_key=api_key,
1058
+ provider=_ZHIPU_WEB_SEARCH_PROVIDER,
1059
+ base_url_label="Zhipu/GLM",
1060
+ base_url_predicate=_is_zhipu_base_url,
1061
+ default_model="glm-4.6",
1062
+ )
1063
+
1064
+
1065
+ def _resolve_volcengine_web_search_readiness(
1066
+ *,
1067
+ cfg: AppConfig | None,
1068
+ api_key: str | None,
1069
+ ) -> _BackendReadiness:
1070
+ return _resolve_native_provider_readiness(
1071
+ cfg=cfg,
1072
+ api_key=api_key,
1073
+ provider=_VOLCENGINE_WEB_SEARCH_PROVIDER,
1074
+ base_url_label="Volcengine/Doubao",
1075
+ base_url_predicate=_is_volcengine_base_url,
1076
+ default_model="doubao-seed-1-6-250615",
1077
+ )
1078
+
1079
+
1080
+ def _resolve_tavily_readiness(*, cfg: AppConfig | None) -> _BackendReadiness:
1081
+ notes: list[str] = []
1082
+ timeout_s: float | None = None
1083
+ resolved_api_key = _resolve_tavily_api_key(cfg)
1084
+
1085
+ try:
1086
+ timeout_s = resolve_web_search_timeout_s(cfg)
1087
+ except ConfigError as e:
1088
+ _append_unique_note(notes, str(e))
1089
+
1090
+ if not resolved_api_key:
1091
+ _append_unique_note(
1092
+ notes,
1093
+ "missing TAVILY_API_KEY or ALYSIS_WEB_SEARCH_API_KEY for Tavily search",
1094
+ )
1095
+
1096
+ runtime: WebSearchRuntimeConfig | None = None
1097
+ if resolved_api_key and timeout_s is not None:
1098
+ runtime = WebSearchRuntimeConfig(
1099
+ provider=_TAVILY_PROVIDER,
1100
+ base_url=None,
1101
+ api_key=resolved_api_key,
1102
+ model=None,
1103
+ timeout_s=timeout_s,
1104
+ )
1105
+
1106
+ return _BackendReadiness(
1107
+ provider=_TAVILY_PROVIDER,
1108
+ runtime=runtime,
1109
+ base_url=None,
1110
+ model=None,
1111
+ api_key_available=bool(resolved_api_key),
1112
+ notes=tuple(notes),
1113
+ )
1114
+
1115
+
1116
+ def _resolve_ddgs_readiness(*, cfg: AppConfig | None) -> _BackendReadiness:
1117
+ notes: list[str] = []
1118
+ timeout_s: float | None = None
1119
+
1120
+ try:
1121
+ timeout_s = resolve_web_search_timeout_s(cfg)
1122
+ except ConfigError as e:
1123
+ _append_unique_note(notes, str(e))
1124
+
1125
+ enabled = keyless_web_search_enabled()
1126
+ package_available = ddgs_package_available()
1127
+ if not enabled:
1128
+ _append_unique_note(
1129
+ notes,
1130
+ f"keyless web search is disabled ({KEYLESS_WEB_SEARCH_ENV}=0)",
1131
+ )
1132
+ elif not package_available:
1133
+ _append_unique_note(
1134
+ notes,
1135
+ "keyless web search requires the 'ddgs' package (pip install ddgs)",
1136
+ )
1137
+
1138
+ runtime: WebSearchRuntimeConfig | None = None
1139
+ if enabled and package_available and timeout_s is not None:
1140
+ runtime = WebSearchRuntimeConfig(
1141
+ provider=_DDGS_PROVIDER,
1142
+ base_url=None,
1143
+ api_key="",
1144
+ model=None,
1145
+ timeout_s=timeout_s,
1146
+ )
1147
+
1148
+ return _BackendReadiness(
1149
+ provider=_DDGS_PROVIDER,
1150
+ runtime=runtime,
1151
+ base_url=None,
1152
+ model=None,
1153
+ api_key_available=runtime is not None,
1154
+ notes=tuple(notes),
1155
+ )
1156
+
1157
+
1158
+ def _readiness_order() -> tuple[str, ...]:
1159
+ return (
1160
+ _OPENAI_RESPONSES_PROVIDER,
1161
+ _XAI_RESPONSES_PROVIDER,
1162
+ _ANTHROPIC_MESSAGES_PROVIDER,
1163
+ _GEMINI_GROUNDING_PROVIDER,
1164
+ _OPENROUTER_WEB_PROVIDER,
1165
+ _DASHSCOPE_CHAT_PROVIDER,
1166
+ _MOONSHOT_KIMI_PROVIDER,
1167
+ _ZHIPU_WEB_SEARCH_PROVIDER,
1168
+ _VOLCENGINE_WEB_SEARCH_PROVIDER,
1169
+ _PERPLEXITY_SONAR_PROVIDER,
1170
+ _GROQ_COMPOUND_PROVIDER,
1171
+ _MISTRAL_CONVERSATIONS_PROVIDER,
1172
+ _MINIMAX_CODING_PLAN_PROVIDER,
1173
+ _COHERE_WEB_SEARCH_PROVIDER,
1174
+ _TAVILY_PROVIDER,
1175
+ _DDGS_PROVIDER,
1176
+ )
1177
+
1178
+
1179
+ def _readiness_order_for_mode(mode: str) -> tuple[str, ...]:
1180
+ if mode == _WEB_SEARCH_MODE_NATIVE:
1181
+ return tuple(
1182
+ provider for provider in _readiness_order() if provider in NATIVE_WEB_SEARCH_ADAPTERS
1183
+ )
1184
+ if mode == _WEB_SEARCH_MODE_EXTERNAL:
1185
+ return tuple(
1186
+ provider for provider in _readiness_order() if provider in EXTERNAL_WEB_SEARCH_ADAPTERS
1187
+ )
1188
+ return _readiness_order()
1189
+
1190
+
1191
+ def _adapter_mode_incompatibility(*, mode: str, provider: str) -> str | None:
1192
+ if mode == _WEB_SEARCH_MODE_NATIVE and web_search_adapter_is_external(provider):
1193
+ return (
1194
+ f"web_search_mode=native is incompatible with web_search_adapter={provider}: "
1195
+ "native mode allows only provider-hosted/native web search adapters and never "
1196
+ "uses Tavily or other external search providers."
1197
+ )
1198
+ if mode == _WEB_SEARCH_MODE_EXTERNAL and web_search_adapter_is_native(provider):
1199
+ return (
1200
+ f"web_search_mode=external is incompatible with web_search_adapter={provider}: "
1201
+ "external mode allows only external search providers such as Tavily and does "
1202
+ "not use provider-hosted/native adapters."
1203
+ )
1204
+ return None
1205
+
1206
+
1207
+ def _resolve_backend_readiness_map(
1208
+ *,
1209
+ cfg: AppConfig | None,
1210
+ api_key: str | None,
1211
+ ) -> dict[str, _BackendReadiness]:
1212
+ return {
1213
+ _OPENAI_RESPONSES_PROVIDER: _resolve_openai_readiness(cfg=cfg, api_key=api_key),
1214
+ _XAI_RESPONSES_PROVIDER: _resolve_xai_responses_readiness(cfg=cfg, api_key=api_key),
1215
+ _ANTHROPIC_MESSAGES_PROVIDER: _resolve_anthropic_messages_readiness(
1216
+ cfg=cfg,
1217
+ api_key=api_key,
1218
+ ),
1219
+ _GEMINI_GROUNDING_PROVIDER: _resolve_gemini_grounding_readiness(
1220
+ cfg=cfg,
1221
+ api_key=api_key,
1222
+ ),
1223
+ _OPENROUTER_WEB_PROVIDER: _resolve_openrouter_web_readiness(cfg=cfg, api_key=api_key),
1224
+ _DASHSCOPE_CHAT_PROVIDER: _resolve_dashscope_chat_readiness(cfg=cfg, api_key=api_key),
1225
+ _MOONSHOT_KIMI_PROVIDER: _resolve_moonshot_kimi_readiness(cfg=cfg, api_key=api_key),
1226
+ _ZHIPU_WEB_SEARCH_PROVIDER: _resolve_zhipu_web_search_readiness(
1227
+ cfg=cfg,
1228
+ api_key=api_key,
1229
+ ),
1230
+ _VOLCENGINE_WEB_SEARCH_PROVIDER: _resolve_volcengine_web_search_readiness(
1231
+ cfg=cfg,
1232
+ api_key=api_key,
1233
+ ),
1234
+ _PERPLEXITY_SONAR_PROVIDER: _resolve_perplexity_sonar_readiness(
1235
+ cfg=cfg,
1236
+ api_key=api_key,
1237
+ ),
1238
+ _GROQ_COMPOUND_PROVIDER: _resolve_groq_compound_readiness(cfg=cfg, api_key=api_key),
1239
+ _MISTRAL_CONVERSATIONS_PROVIDER: _resolve_mistral_conversations_readiness(
1240
+ cfg=cfg,
1241
+ api_key=api_key,
1242
+ ),
1243
+ _MINIMAX_CODING_PLAN_PROVIDER: _resolve_minimax_coding_plan_readiness(
1244
+ cfg=cfg,
1245
+ api_key=api_key,
1246
+ ),
1247
+ _COHERE_WEB_SEARCH_PROVIDER: _resolve_cohere_web_search_readiness(
1248
+ cfg=cfg,
1249
+ api_key=api_key,
1250
+ ),
1251
+ _TAVILY_PROVIDER: _resolve_tavily_readiness(cfg=cfg),
1252
+ _DDGS_PROVIDER: _resolve_ddgs_readiness(cfg=cfg),
1253
+ }
1254
+
1255
+
1256
+ def _combined_api_key_available(
1257
+ readiness: dict[str, _BackendReadiness],
1258
+ providers: tuple[str, ...] | None = None,
1259
+ ) -> bool:
1260
+ order = providers or _readiness_order()
1261
+ return any(readiness[provider].api_key_available for provider in order)
1262
+
1263
+
1264
+ def _first_context_backend(
1265
+ readiness: dict[str, _BackendReadiness],
1266
+ providers: tuple[str, ...] | None = None,
1267
+ ) -> _BackendReadiness:
1268
+ order = providers or _readiness_order()
1269
+ for provider in order:
1270
+ item = readiness[provider]
1271
+ if item.base_url or item.model:
1272
+ return item
1273
+ if order:
1274
+ return readiness[order[0]]
1275
+ return readiness[_OPENAI_RESPONSES_PROVIDER]
1276
+
1277
+
1278
+ def _format_runtime_context(item: _BackendReadiness) -> str:
1279
+ base_url = item.base_url or "(missing)"
1280
+ model = item.model or "(missing)"
1281
+ return f"base_url={base_url}, model={model}"
1282
+
1283
+
1284
+ def _mode_unavailable_notes(
1285
+ *,
1286
+ mode: str,
1287
+ readiness: dict[str, _BackendReadiness],
1288
+ providers: tuple[str, ...],
1289
+ ) -> tuple[str, ...]:
1290
+ combined = _combine_notes(*(readiness[provider].notes for provider in providers))
1291
+ if mode == _WEB_SEARCH_MODE_NATIVE:
1292
+ context = _first_context_backend(readiness, providers)
1293
+ return _combine_notes(
1294
+ (
1295
+ "web_search_mode=native found no ready provider-hosted/native adapter for "
1296
+ f"the active provider/profile ({_format_runtime_context(context)}). "
1297
+ "Native mode never falls back to Tavily or other external search providers.",
1298
+ ),
1299
+ combined,
1300
+ )
1301
+ if mode == _WEB_SEARCH_MODE_EXTERNAL:
1302
+ return _combine_notes(
1303
+ (
1304
+ "web_search_mode=external found no ready external web search adapter. "
1305
+ "Configure ALYSIS_WEB_SEARCH_API_KEY/TAVILY_API_KEY or install the "
1306
+ "'ddgs' package for the keyless backend.",
1307
+ ),
1308
+ combined,
1309
+ )
1310
+ return combined
1311
+
1312
+
1313
+ def _select_runtime_status(
1314
+ *,
1315
+ mode: str,
1316
+ provider_override: str | None,
1317
+ override_error: str | None,
1318
+ readiness: dict[str, _BackendReadiness],
1319
+ ) -> WebSearchRuntimeStatus:
1320
+ if mode == _WEB_SEARCH_MODE_OFF:
1321
+ return WebSearchRuntimeStatus(
1322
+ mode=mode,
1323
+ provider=None,
1324
+ base_url=None,
1325
+ model=None,
1326
+ api_key_available=False,
1327
+ registration_ready=False,
1328
+ notes=("disabled by policy: set web_search_mode=auto, native, or external",),
1329
+ )
1330
+
1331
+ mode_providers = _readiness_order_for_mode(mode)
1332
+
1333
+ if override_error:
1334
+ context = _first_context_backend(readiness, mode_providers)
1335
+ return WebSearchRuntimeStatus(
1336
+ mode=mode,
1337
+ provider=None,
1338
+ base_url=context.base_url,
1339
+ model=context.model,
1340
+ api_key_available=_combined_api_key_available(readiness, mode_providers),
1341
+ registration_ready=False,
1342
+ notes=(f"invalid web_search_adapter: {override_error}",),
1343
+ )
1344
+
1345
+ if provider_override is not None:
1346
+ selected = readiness[provider_override]
1347
+ incompatibility = _adapter_mode_incompatibility(mode=mode, provider=provider_override)
1348
+ if incompatibility is not None:
1349
+ context = _first_context_backend(readiness, mode_providers)
1350
+ return WebSearchRuntimeStatus(
1351
+ mode=mode,
1352
+ provider=None,
1353
+ base_url=selected.base_url or context.base_url,
1354
+ model=selected.model or context.model,
1355
+ api_key_available=selected.api_key_available,
1356
+ registration_ready=False,
1357
+ notes=_combine_notes(
1358
+ (f"explicit adapter selected {selected.provider}",),
1359
+ (incompatibility,),
1360
+ ),
1361
+ )
1362
+ if selected.runtime is not None:
1363
+ return WebSearchRuntimeStatus(
1364
+ mode=mode,
1365
+ provider=selected.provider,
1366
+ base_url=selected.base_url,
1367
+ model=selected.model,
1368
+ api_key_available=selected.api_key_available,
1369
+ registration_ready=True,
1370
+ notes=(f"explicit adapter selected {selected.provider}",),
1371
+ )
1372
+ return WebSearchRuntimeStatus(
1373
+ mode=mode,
1374
+ provider=None,
1375
+ base_url=selected.base_url,
1376
+ model=selected.model,
1377
+ api_key_available=selected.api_key_available,
1378
+ registration_ready=False,
1379
+ notes=_combine_notes(
1380
+ (f"explicit adapter selected {selected.provider}",),
1381
+ selected.notes,
1382
+ ),
1383
+ )
1384
+
1385
+ for provider in mode_providers:
1386
+ item = readiness[provider]
1387
+ if item.runtime is not None:
1388
+ selection_prefix = "auto" if mode == _WEB_SEARCH_MODE_AUTO else mode
1389
+ return WebSearchRuntimeStatus(
1390
+ mode=mode,
1391
+ provider=item.provider,
1392
+ base_url=item.base_url,
1393
+ model=item.model,
1394
+ api_key_available=item.api_key_available,
1395
+ registration_ready=True,
1396
+ notes=(f"{selection_prefix} selected {item.provider}",),
1397
+ )
1398
+
1399
+ context = _first_context_backend(readiness, mode_providers)
1400
+ return WebSearchRuntimeStatus(
1401
+ mode=mode,
1402
+ provider=None,
1403
+ base_url=context.base_url,
1404
+ model=context.model,
1405
+ api_key_available=_combined_api_key_available(readiness, mode_providers),
1406
+ registration_ready=False,
1407
+ notes=_mode_unavailable_notes(
1408
+ mode=mode,
1409
+ readiness=readiness,
1410
+ providers=mode_providers,
1411
+ ),
1412
+ )
1413
+
1414
+
1415
+ def resolve_web_search_runtime_status(
1416
+ *,
1417
+ cfg: AppConfig | None,
1418
+ api_key: str | None = None,
1419
+ ) -> WebSearchRuntimeStatus:
1420
+ mode = resolve_web_search_mode(cfg)
1421
+ provider_override, override_error = _resolve_provider_override(cfg=cfg, strict=False)
1422
+ readiness = _resolve_backend_readiness_map(cfg=cfg, api_key=api_key)
1423
+ return _select_runtime_status(
1424
+ mode=mode,
1425
+ provider_override=provider_override,
1426
+ override_error=override_error,
1427
+ readiness=readiness,
1428
+ )
1429
+
1430
+
1431
+ def _strict_runtime_error_message(
1432
+ *,
1433
+ mode: str,
1434
+ provider_override: str | None,
1435
+ override_error: str | None,
1436
+ readiness: dict[str, _BackendReadiness],
1437
+ ) -> str:
1438
+ if mode == _WEB_SEARCH_MODE_OFF:
1439
+ return "web_search is disabled by policy (web_search_mode=off)."
1440
+ if override_error:
1441
+ return override_error
1442
+ mode_providers = _readiness_order_for_mode(mode)
1443
+ if provider_override is not None:
1444
+ incompatibility = _adapter_mode_incompatibility(mode=mode, provider=provider_override)
1445
+ if incompatibility is not None:
1446
+ return incompatibility
1447
+ selected = readiness[provider_override]
1448
+ reason = (
1449
+ selected.notes[0] if selected.notes else f"{provider_override} runtime is not ready"
1450
+ )
1451
+ mode_suffix = "" if mode == _WEB_SEARCH_MODE_AUTO else f" for web_search_mode={mode}"
1452
+ return f"web_search adapter {provider_override} is not ready{mode_suffix}: {reason}"
1453
+ combined = _combine_notes(*(readiness[provider].notes for provider in mode_providers))
1454
+ if mode == _WEB_SEARCH_MODE_NATIVE:
1455
+ context = _first_context_backend(readiness, mode_providers)
1456
+ details = "; ".join(combined) if combined else "no native/provider-hosted adapter is ready"
1457
+ return (
1458
+ "web_search is not available in native mode for active provider/profile "
1459
+ f"({_format_runtime_context(context)}): {details}. Native mode never falls "
1460
+ "back to Tavily or other external search providers."
1461
+ )
1462
+ if mode == _WEB_SEARCH_MODE_EXTERNAL:
1463
+ details = "; ".join(combined) if combined else "no external adapter is ready"
1464
+ return (
1465
+ f"web_search is not available in external mode: {details}. "
1466
+ "Configure ALYSIS_WEB_SEARCH_API_KEY or TAVILY_API_KEY for Tavily external search."
1467
+ )
1468
+ if combined:
1469
+ return "web_search is not available in auto mode: " + "; ".join(combined)
1470
+ return "web_search is not enabled or configured."
1471
+
1472
+
1473
+ def resolve_web_search_runtime(
1474
+ *,
1475
+ cfg: AppConfig | None,
1476
+ api_key: str | None = None,
1477
+ strict: bool = False,
1478
+ ) -> WebSearchRuntimeConfig | None:
1479
+ mode = resolve_web_search_mode(cfg)
1480
+ provider_override, override_error = _resolve_provider_override(cfg=cfg, strict=False)
1481
+ readiness = _resolve_backend_readiness_map(cfg=cfg, api_key=api_key)
1482
+
1483
+ if mode == _WEB_SEARCH_MODE_OFF:
1484
+ return None
1485
+
1486
+ if override_error:
1487
+ if strict:
1488
+ raise WebSearchError(
1489
+ _strict_runtime_error_message(
1490
+ mode=mode,
1491
+ provider_override=provider_override,
1492
+ override_error=override_error,
1493
+ readiness=readiness,
1494
+ )
1495
+ )
1496
+ return None
1497
+
1498
+ selected_runtime: WebSearchRuntimeConfig | None = None
1499
+ if provider_override is not None:
1500
+ if _adapter_mode_incompatibility(mode=mode, provider=provider_override) is None:
1501
+ selected_runtime = readiness[provider_override].runtime
1502
+ else:
1503
+ for provider in _readiness_order_for_mode(mode):
1504
+ selected_runtime = readiness[provider].runtime
1505
+ if selected_runtime is not None:
1506
+ break
1507
+
1508
+ if selected_runtime is not None:
1509
+ return selected_runtime
1510
+
1511
+ if strict:
1512
+ raise WebSearchError(
1513
+ _strict_runtime_error_message(
1514
+ mode=mode,
1515
+ provider_override=provider_override,
1516
+ override_error=override_error,
1517
+ readiness=readiness,
1518
+ )
1519
+ )
1520
+ return None
1521
+
1522
+
1523
+ def _dedupe_sources(sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
1524
+ deduped: list[dict[str, Any]] = []
1525
+ seen: set[str] = set()
1526
+ for source in sources:
1527
+ url = str(source.get("url") or "").strip()
1528
+ if not url or url in seen:
1529
+ continue
1530
+ seen.add(url)
1531
+ deduped.append(source)
1532
+ return deduped
1533
+
1534
+
1535
+ def _with_web_search_observability(
1536
+ *,
1537
+ result: dict[str, Any],
1538
+ runtime: WebSearchRuntimeConfig,
1539
+ cfg: AppConfig | None,
1540
+ fallback_occurred: bool = False,
1541
+ ) -> dict[str, Any]:
1542
+ enriched = dict(result)
1543
+ # Host wall-clock provenance: when this search actually ran. Lets the model
1544
+ # anchor "today"/"current" reasoning to real time instead of its training
1545
+ # prior (which otherwise poisons follow-up queries with a stale year).
1546
+ enriched["retrieved_at"] = datetime.now(UTC).isoformat(timespec="seconds")
1547
+ citations = enriched.get("citations")
1548
+ sources = enriched.get("sources")
1549
+ queries = enriched.get("queries")
1550
+ citation_count = len(citations) if isinstance(citations, list) else 0
1551
+ source_count = len(sources) if isinstance(sources, list) else 0
1552
+ query_count = len(queries) if isinstance(queries, list) else 0
1553
+ provider_hosted = web_search_adapter_is_native(runtime.provider)
1554
+ chat_protocol = _observability_chat_protocol(cfg)
1555
+ enriched["protocol"] = chat_protocol or runtime.provider
1556
+ enriched["chat_protocol"] = chat_protocol
1557
+ enriched["search_protocol"] = runtime.provider
1558
+ enriched["backend_adapter"] = runtime.provider
1559
+ enriched["provider_hosted_search"] = provider_hosted
1560
+ enriched["external_search_provider"] = (
1561
+ runtime.provider if web_search_adapter_is_external(runtime.provider) else None
1562
+ )
1563
+ enriched["citation_count"] = citation_count
1564
+ enriched["source_count"] = source_count
1565
+ record_web_search_call(
1566
+ protocol=chat_protocol or runtime.provider,
1567
+ provider_key=runtime.provider,
1568
+ model=runtime.model,
1569
+ web_search_mode=resolve_web_search_mode(cfg),
1570
+ web_search_adapter=resolve_web_search_adapter(cfg),
1571
+ provider_hosted_search=provider_hosted,
1572
+ external_provider_name=enriched["external_search_provider"],
1573
+ source_count=source_count,
1574
+ citation_count=citation_count,
1575
+ query_count=query_count,
1576
+ fallback_occurred=fallback_occurred,
1577
+ )
1578
+ return enriched
1579
+
1580
+
1581
+ def _observability_chat_protocol(cfg: AppConfig | None) -> str:
1582
+ if cfg is None:
1583
+ return ""
1584
+ try:
1585
+ profile = get_active_profile(cfg)
1586
+ except Exception: # noqa: BLE001
1587
+ return OPENAI_COMPAT_PROTOCOL
1588
+ protocol = str(profile.protocol or OPENAI_COMPAT_PROTOCOL).strip()
1589
+ return protocol or OPENAI_COMPAT_PROTOCOL
1590
+
1591
+
1592
+ def _openai_responses_search(
1593
+ *,
1594
+ query: str,
1595
+ runtime: WebSearchRuntimeConfig,
1596
+ cfg: AppConfig | None,
1597
+ allowed_domains: list[str] | None,
1598
+ max_sources: int,
1599
+ external_web_access: bool,
1600
+ transport: httpx.BaseTransport | None,
1601
+ client_factory: Callable[..., OpenAIResponsesClient],
1602
+ session_id: str | None,
1603
+ ) -> dict[str, Any]:
1604
+ provider_auth = None
1605
+ if runtime.auth_provider:
1606
+ from ..provider_auth import create_provider_auth
1607
+
1608
+ provider_auth = create_provider_auth(runtime.auth_provider, transport=transport)
1609
+ client = client_factory(
1610
+ base_url=str(runtime.base_url or ""),
1611
+ api_key=runtime.api_key,
1612
+ model=str(runtime.model or ""),
1613
+ timeout_s=runtime.timeout_s,
1614
+ transport=transport,
1615
+ provider_concurrency_caps=getattr(cfg, "provider_concurrency_caps", None),
1616
+ provider_retry_settings=_web_search_provider_retry_settings(cfg),
1617
+ provider_auth=provider_auth,
1618
+ session_id=session_id,
1619
+ )
1620
+
1621
+ try:
1622
+ is_xai_runtime = runtime.provider == _XAI_RESPONSES_PROVIDER
1623
+ response = client.web_search(
1624
+ query=query,
1625
+ allowed_domains=allowed_domains,
1626
+ external_web_access=None if is_xai_runtime else external_web_access,
1627
+ include_source_details=not is_xai_runtime,
1628
+ )
1629
+ except ResponsesError as e:
1630
+ raise WebSearchError(str(e)) from e
1631
+
1632
+ raw_sources = [
1633
+ {
1634
+ "url": str(source.url or "").strip(),
1635
+ "title": str(source.title or "").strip(),
1636
+ }
1637
+ for source in response.sources
1638
+ if str(source.url or "").strip()
1639
+ ]
1640
+ deduped_sources = _dedupe_sources(raw_sources)
1641
+ sources_truncated = len(deduped_sources) > max_sources
1642
+ final_sources = deduped_sources[:max_sources]
1643
+
1644
+ citations = [
1645
+ {
1646
+ "title": str(citation.title or "").strip(),
1647
+ "url": str(citation.url or "").strip(),
1648
+ "start_index": citation.start_index,
1649
+ "end_index": citation.end_index,
1650
+ }
1651
+ for citation in response.citations
1652
+ if str(citation.url or "").strip()
1653
+ ]
1654
+
1655
+ queries: list[str] = []
1656
+ for raw_query in response.queries:
1657
+ query_text = str(raw_query or "").strip()
1658
+ if query_text:
1659
+ queries.append(query_text)
1660
+
1661
+ return {
1662
+ "query": query,
1663
+ "answer": str(response.answer or "").strip(),
1664
+ "citations": citations,
1665
+ "sources": final_sources,
1666
+ "queries": queries,
1667
+ "model": response.model or runtime.model,
1668
+ "backend": runtime.provider,
1669
+ "allowed_domains": allowed_domains or [],
1670
+ "external_web_access": external_web_access,
1671
+ "response_id": response.response_id,
1672
+ "sources_truncated": sources_truncated,
1673
+ }
1674
+
1675
+
1676
+ def web_search(
1677
+ *,
1678
+ query: str,
1679
+ cfg: AppConfig | None,
1680
+ api_key: str | None = None,
1681
+ allowed_domains: list[str] | None = None,
1682
+ max_sources: int = 8,
1683
+ external_web_access: bool = True,
1684
+ transport: httpx.BaseTransport | None = None,
1685
+ resolver: Resolver | None = None,
1686
+ client_factory: Callable[..., OpenAIResponsesClient] = OpenAIResponsesClient,
1687
+ session_id: str | None = None,
1688
+ ) -> dict[str, Any]:
1689
+ # Defense-in-depth runtime guard mirroring web_fetch: the master web-tools
1690
+ # switch blocks execution even if registration gating was bypassed.
1691
+ from ..config import resolve_web_tools_enabled
1692
+
1693
+ if not resolve_web_tools_enabled(cfg):
1694
+ raise WebSearchError(
1695
+ "web_search is disabled by the web-tools policy "
1696
+ "(ALYSIS_WEB_TOOLS=off / web_tools_enabled=false). "
1697
+ "No network request was performed.",
1698
+ recoverable=False,
1699
+ )
1700
+ validated_query = _validate_query(query)
1701
+ validated_allowed_domains = _validate_allowed_domains(allowed_domains)
1702
+ validated_max_sources = _validate_max_sources(max_sources)
1703
+ validated_external_web_access = _validate_external_web_access(external_web_access)
1704
+
1705
+ provider_override, _override_error = _resolve_provider_override(cfg=cfg, strict=True)
1706
+ runtime = resolve_web_search_runtime(cfg=cfg, api_key=api_key, strict=True)
1707
+ if runtime is None:
1708
+ raise WebSearchError("web_search is not enabled or configured.")
1709
+ _enforce_external_web_access_contract(
1710
+ provider=runtime.provider,
1711
+ external_web_access=validated_external_web_access,
1712
+ )
1713
+
1714
+ def _run_tavily(tavily_runtime: WebSearchRuntimeConfig) -> dict[str, Any]:
1715
+ return tavily_search(
1716
+ query=validated_query,
1717
+ api_key=tavily_runtime.api_key,
1718
+ max_results=validated_max_sources,
1719
+ include_domains=validated_allowed_domains,
1720
+ timeout_s=tavily_runtime.timeout_s,
1721
+ transport=transport,
1722
+ resolver=resolver,
1723
+ )
1724
+
1725
+ def _run_ddgs(ddgs_runtime: WebSearchRuntimeConfig) -> dict[str, Any]:
1726
+ return ddgs_search(
1727
+ query=validated_query,
1728
+ max_results=validated_max_sources,
1729
+ include_domains=validated_allowed_domains,
1730
+ timeout_s=ddgs_runtime.timeout_s,
1731
+ )
1732
+
1733
+ def _fallback_to_external_or_raise(provider: str, error: Exception) -> dict[str, Any]:
1734
+ if provider_override is not None:
1735
+ raise WebSearchError(str(error)) from error
1736
+ mode = resolve_web_search_mode(cfg)
1737
+ if mode == _WEB_SEARCH_MODE_NATIVE:
1738
+ raise WebSearchError(
1739
+ f"native web search via {provider} failed and web_search_mode="
1740
+ "native disables external fallback backends (set web_search_mode "
1741
+ f"to 'auto' or 'external' to allow them): {error}"
1742
+ ) from error
1743
+ if mode != _WEB_SEARCH_MODE_AUTO or not validated_external_web_access:
1744
+ raise WebSearchError(str(error)) from error
1745
+ failures = [f"{provider}: {error}"]
1746
+ last_error: Exception = error
1747
+ if provider != _TAVILY_PROVIDER:
1748
+ tavily_runtime = _resolve_tavily_readiness(cfg=cfg).runtime
1749
+ if tavily_runtime is not None:
1750
+ try:
1751
+ result = _with_web_search_observability(
1752
+ result=_run_tavily(tavily_runtime),
1753
+ runtime=tavily_runtime,
1754
+ cfg=cfg,
1755
+ fallback_occurred=True,
1756
+ )
1757
+ except TavilySearchError as tavily_error:
1758
+ failures.append(f"tavily: {tavily_error}")
1759
+ last_error = tavily_error
1760
+ else:
1761
+ _record_session_fallback_preference(
1762
+ session_id,
1763
+ _TAVILY_PROVIDER,
1764
+ failed_runtime_fingerprint=_web_search_runtime_fingerprint(runtime),
1765
+ )
1766
+ return result
1767
+ if provider != _DDGS_PROVIDER:
1768
+ ddgs_runtime = _resolve_ddgs_readiness(cfg=cfg).runtime
1769
+ if ddgs_runtime is not None:
1770
+ try:
1771
+ result = _with_web_search_observability(
1772
+ result=_run_ddgs(ddgs_runtime),
1773
+ runtime=ddgs_runtime,
1774
+ cfg=cfg,
1775
+ fallback_occurred=True,
1776
+ )
1777
+ except DdgsSearchError as ddgs_error:
1778
+ failures.append(f"ddgs: {ddgs_error}")
1779
+ last_error = ddgs_error
1780
+ else:
1781
+ _record_session_fallback_preference(
1782
+ session_id,
1783
+ _DDGS_PROVIDER,
1784
+ failed_runtime_fingerprint=_web_search_runtime_fingerprint(runtime),
1785
+ )
1786
+ return result
1787
+ if len(failures) == 1:
1788
+ raise WebSearchError(str(error)) from error
1789
+ raise WebSearchError(
1790
+ "web_search failed across auto backends: " + "; ".join(failures)
1791
+ ) from last_error
1792
+
1793
+ preferred_fallback = _session_fallback_preference(
1794
+ session_id,
1795
+ current_runtime_fingerprint=_web_search_runtime_fingerprint(runtime),
1796
+ )
1797
+ if (
1798
+ preferred_fallback in (_TAVILY_PROVIDER, _DDGS_PROVIDER)
1799
+ and preferred_fallback != runtime.provider
1800
+ and provider_override is None
1801
+ and resolve_web_search_mode(cfg) == _WEB_SEARCH_MODE_AUTO
1802
+ and validated_external_web_access
1803
+ ):
1804
+ preferred_runtime = (
1805
+ _resolve_tavily_readiness(cfg=cfg).runtime
1806
+ if preferred_fallback == _TAVILY_PROVIDER
1807
+ else _resolve_ddgs_readiness(cfg=cfg).runtime
1808
+ )
1809
+ if preferred_runtime is None:
1810
+ _clear_session_fallback_preference(session_id)
1811
+ else:
1812
+ try:
1813
+ return _with_web_search_observability(
1814
+ result=(
1815
+ _run_tavily(preferred_runtime)
1816
+ if preferred_fallback == _TAVILY_PROVIDER
1817
+ else _run_ddgs(preferred_runtime)
1818
+ ),
1819
+ runtime=preferred_runtime,
1820
+ cfg=cfg,
1821
+ fallback_occurred=True,
1822
+ )
1823
+ except (TavilySearchError, DdgsSearchError):
1824
+ # The remembered fallback stopped working; forget it and give
1825
+ # the configured backend another chance on this call.
1826
+ _clear_session_fallback_preference(session_id)
1827
+
1828
+ if runtime.provider == _TAVILY_PROVIDER:
1829
+ try:
1830
+ return _with_web_search_observability(
1831
+ result=_run_tavily(runtime),
1832
+ runtime=runtime,
1833
+ cfg=cfg,
1834
+ )
1835
+ except TavilySearchError as e:
1836
+ return _fallback_to_external_or_raise(_TAVILY_PROVIDER, e)
1837
+
1838
+ if runtime.provider == _DDGS_PROVIDER:
1839
+ try:
1840
+ return _with_web_search_observability(
1841
+ result=_run_ddgs(runtime),
1842
+ runtime=runtime,
1843
+ cfg=cfg,
1844
+ )
1845
+ except DdgsSearchError as e:
1846
+ raise WebSearchError(str(e)) from e
1847
+
1848
+ if runtime.provider == _DASHSCOPE_CHAT_PROVIDER:
1849
+ try:
1850
+ return _with_web_search_observability(
1851
+ result=dashscope_chat_search(
1852
+ query=validated_query,
1853
+ base_url=str(runtime.base_url or ""),
1854
+ api_key=runtime.api_key,
1855
+ model=str(runtime.model or ""),
1856
+ max_results=validated_max_sources,
1857
+ include_domains=validated_allowed_domains,
1858
+ timeout_s=runtime.timeout_s,
1859
+ transport=transport,
1860
+ resolver=resolver,
1861
+ provider_concurrency_caps=getattr(cfg, "provider_concurrency_caps", None),
1862
+ provider_retry_settings=_web_search_provider_retry_settings(cfg),
1863
+ ),
1864
+ runtime=runtime,
1865
+ cfg=cfg,
1866
+ )
1867
+ except DashScopeChatSearchError as e:
1868
+ return _fallback_to_external_or_raise(_DASHSCOPE_CHAT_PROVIDER, e)
1869
+
1870
+ provider_retry_settings = _web_search_provider_retry_settings(cfg)
1871
+ native_kwargs = {
1872
+ "query": validated_query,
1873
+ "base_url": str(runtime.base_url or ""),
1874
+ "api_key": runtime.api_key,
1875
+ "model": str(runtime.model or ""),
1876
+ "max_results": validated_max_sources,
1877
+ "allowed_domains": validated_allowed_domains,
1878
+ "timeout_s": runtime.timeout_s,
1879
+ "transport": transport,
1880
+ "resolver": resolver,
1881
+ "provider_concurrency_caps": getattr(cfg, "provider_concurrency_caps", None),
1882
+ "provider_retry_settings": provider_retry_settings,
1883
+ }
1884
+
1885
+ if runtime.provider == _ANTHROPIC_MESSAGES_PROVIDER:
1886
+ try:
1887
+ return _with_web_search_observability(
1888
+ result=anthropic_messages_search(**native_kwargs),
1889
+ runtime=runtime,
1890
+ cfg=cfg,
1891
+ )
1892
+ except ProviderWebSearchError as e:
1893
+ return _fallback_to_external_or_raise(_ANTHROPIC_MESSAGES_PROVIDER, e)
1894
+
1895
+ if runtime.provider == _GEMINI_GROUNDING_PROVIDER:
1896
+ try:
1897
+ return _with_web_search_observability(
1898
+ result=gemini_grounding_search(**native_kwargs),
1899
+ runtime=runtime,
1900
+ cfg=cfg,
1901
+ )
1902
+ except ProviderWebSearchError as e:
1903
+ return _fallback_to_external_or_raise(_GEMINI_GROUNDING_PROVIDER, e)
1904
+
1905
+ if runtime.provider == _OPENROUTER_WEB_PROVIDER:
1906
+ try:
1907
+ return _with_web_search_observability(
1908
+ result=openrouter_web_search(**native_kwargs),
1909
+ runtime=runtime,
1910
+ cfg=cfg,
1911
+ )
1912
+ except ProviderWebSearchError as e:
1913
+ return _fallback_to_external_or_raise(_OPENROUTER_WEB_PROVIDER, e)
1914
+
1915
+ if runtime.provider == _MOONSHOT_KIMI_PROVIDER:
1916
+ try:
1917
+ return _with_web_search_observability(
1918
+ result=moonshot_kimi_search(**native_kwargs),
1919
+ runtime=runtime,
1920
+ cfg=cfg,
1921
+ )
1922
+ except ProviderWebSearchError as e:
1923
+ return _fallback_to_external_or_raise(_MOONSHOT_KIMI_PROVIDER, e)
1924
+
1925
+ if runtime.provider == _ZHIPU_WEB_SEARCH_PROVIDER:
1926
+ try:
1927
+ return _with_web_search_observability(
1928
+ result=zhipu_web_search(**native_kwargs),
1929
+ runtime=runtime,
1930
+ cfg=cfg,
1931
+ )
1932
+ except ProviderWebSearchError as e:
1933
+ return _fallback_to_external_or_raise(_ZHIPU_WEB_SEARCH_PROVIDER, e)
1934
+
1935
+ if runtime.provider == _VOLCENGINE_WEB_SEARCH_PROVIDER:
1936
+ try:
1937
+ return _with_web_search_observability(
1938
+ result=volcengine_web_search(**native_kwargs),
1939
+ runtime=runtime,
1940
+ cfg=cfg,
1941
+ )
1942
+ except ProviderWebSearchError as e:
1943
+ return _fallback_to_external_or_raise(_VOLCENGINE_WEB_SEARCH_PROVIDER, e)
1944
+
1945
+ if runtime.provider == _PERPLEXITY_SONAR_PROVIDER:
1946
+ try:
1947
+ return _with_web_search_observability(
1948
+ result=perplexity_sonar_search(**native_kwargs),
1949
+ runtime=runtime,
1950
+ cfg=cfg,
1951
+ )
1952
+ except ProviderWebSearchError as e:
1953
+ return _fallback_to_external_or_raise(_PERPLEXITY_SONAR_PROVIDER, e)
1954
+
1955
+ if runtime.provider == _GROQ_COMPOUND_PROVIDER:
1956
+ try:
1957
+ return _with_web_search_observability(
1958
+ result=groq_compound_search(**native_kwargs),
1959
+ runtime=runtime,
1960
+ cfg=cfg,
1961
+ )
1962
+ except ProviderWebSearchError as e:
1963
+ return _fallback_to_external_or_raise(_GROQ_COMPOUND_PROVIDER, e)
1964
+
1965
+ if runtime.provider == _MISTRAL_CONVERSATIONS_PROVIDER:
1966
+ try:
1967
+ return _with_web_search_observability(
1968
+ result=mistral_conversations_search(**native_kwargs),
1969
+ runtime=runtime,
1970
+ cfg=cfg,
1971
+ )
1972
+ except ProviderWebSearchError as e:
1973
+ return _fallback_to_external_or_raise(_MISTRAL_CONVERSATIONS_PROVIDER, e)
1974
+
1975
+ if runtime.provider == _MINIMAX_CODING_PLAN_PROVIDER:
1976
+ try:
1977
+ return _with_web_search_observability(
1978
+ result=minimax_coding_plan_search(**native_kwargs),
1979
+ runtime=runtime,
1980
+ cfg=cfg,
1981
+ )
1982
+ except ProviderWebSearchError as e:
1983
+ return _fallback_to_external_or_raise(_MINIMAX_CODING_PLAN_PROVIDER, e)
1984
+
1985
+ if runtime.provider == _COHERE_WEB_SEARCH_PROVIDER:
1986
+ try:
1987
+ return _with_web_search_observability(
1988
+ result=cohere_web_search(**native_kwargs),
1989
+ runtime=runtime,
1990
+ cfg=cfg,
1991
+ )
1992
+ except ProviderWebSearchError as e:
1993
+ return _fallback_to_external_or_raise(_COHERE_WEB_SEARCH_PROVIDER, e)
1994
+
1995
+ try:
1996
+ return _with_web_search_observability(
1997
+ result=_openai_responses_search(
1998
+ query=validated_query,
1999
+ runtime=runtime,
2000
+ cfg=cfg,
2001
+ allowed_domains=validated_allowed_domains,
2002
+ max_sources=validated_max_sources,
2003
+ external_web_access=validated_external_web_access,
2004
+ transport=transport,
2005
+ client_factory=client_factory,
2006
+ session_id=session_id,
2007
+ ),
2008
+ runtime=runtime,
2009
+ cfg=cfg,
2010
+ )
2011
+ except WebSearchError as openai_error:
2012
+ return _fallback_to_external_or_raise(runtime.provider, openai_error)