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,1429 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any
6
+ from urllib.parse import quote, urlsplit, urlunsplit
7
+
8
+ import httpx
9
+
10
+ from ..llm.provider_limits import (
11
+ ProviderRetrySettings,
12
+ best_effort_provider_key,
13
+ mark_provider_call_non_retryable,
14
+ run_provider_limited_call,
15
+ )
16
+ from ..safety import SafeHttpError, safe_http_request
17
+ from ..safety.safe_http import Resolver
18
+ from ..web_research import extract_public_web_urls, normalize_web_url
19
+ from .http_timeout import build_http_timeout_budget, format_http_timeout_error
20
+
21
+
22
+ class ProviderWebSearchError(RuntimeError):
23
+ pass
24
+
25
+
26
+ _MAX_SNIPPET_CHARS = 500
27
+
28
+
29
+ def _truncate_snippet(raw_value: Any) -> str | None:
30
+ text = str(raw_value or "").strip()
31
+ if not text:
32
+ return None
33
+ if len(text) <= _MAX_SNIPPET_CHARS:
34
+ return text
35
+ return text[: _MAX_SNIPPET_CHARS - 3].rstrip() + "..."
36
+
37
+
38
+ def _extract_error_message(data: Any) -> str | None:
39
+ if not isinstance(data, dict):
40
+ return None
41
+ error_obj = data.get("error")
42
+ if isinstance(error_obj, dict):
43
+ message = str(error_obj.get("message") or error_obj.get("detail") or "").strip()
44
+ if message:
45
+ code = str(error_obj.get("code") or "").strip()
46
+ return f"{code}: {message}" if code else message
47
+ for key in ("message", "detail", "error"):
48
+ value = data.get(key)
49
+ if isinstance(value, str) and value.strip():
50
+ return value.strip()
51
+ return None
52
+
53
+
54
+ def _response_error(provider_label: str, response: httpx.Response) -> ProviderWebSearchError:
55
+ try:
56
+ data = response.json()
57
+ except Exception:
58
+ body = response.text
59
+ if len(body) > 1000:
60
+ body = body[:1000] + "...(truncated)"
61
+ return ProviderWebSearchError(f"{provider_label} error {response.status_code}: {body}")
62
+ message = _extract_error_message(data)
63
+ if message:
64
+ return ProviderWebSearchError(f"{provider_label} error {response.status_code}: {message}")
65
+ return ProviderWebSearchError(f"{provider_label} error {response.status_code}: {data!r}")
66
+
67
+
68
+ def _post_json(
69
+ *,
70
+ provider_label: str,
71
+ url: str,
72
+ headers: dict[str, str],
73
+ payload: dict[str, Any],
74
+ timeout_s: float,
75
+ transport: httpx.BaseTransport | None,
76
+ resolver: Resolver | None,
77
+ provider_key: str,
78
+ provider_concurrency_caps: dict[str, int] | None,
79
+ provider_retry_settings: ProviderRetrySettings | None,
80
+ operation: str,
81
+ ) -> dict[str, Any]:
82
+ timeout_budget = build_http_timeout_budget(timeout_s, profile="search")
83
+
84
+ def _send_request() -> httpx.Response:
85
+ try:
86
+ response = asyncio.run(
87
+ safe_http_request(
88
+ "POST",
89
+ url,
90
+ timeout=timeout_s,
91
+ headers=headers,
92
+ json=payload,
93
+ _transport=transport, # type: ignore[arg-type]
94
+ _resolver=resolver,
95
+ )
96
+ )
97
+ except httpx.TimeoutException as e:
98
+ error = ProviderWebSearchError(
99
+ format_http_timeout_error(
100
+ operation=f"{provider_label} web_search",
101
+ budget=timeout_budget,
102
+ error=e,
103
+ )
104
+ )
105
+ # A read timeout means the provider accepted the request but did not
106
+ # answer within the budget; retrying immediately with the same budget
107
+ # just times out again and doubles the dead air. Fail fast on read
108
+ # timeouts while leaving connect/transient timeouts retryable.
109
+ if isinstance(e, httpx.ReadTimeout):
110
+ mark_provider_call_non_retryable(error)
111
+ raise error from e
112
+ except SafeHttpError as e:
113
+ raise ProviderWebSearchError(f"{provider_label} request blocked: {e}") from e
114
+ except Exception as e: # noqa: BLE001
115
+ raise ProviderWebSearchError(f"{provider_label} request failed: {e}") from e
116
+ if response.status_code >= 400:
117
+ raise _response_error(provider_label, response)
118
+ return response
119
+
120
+ response = run_provider_limited_call(
121
+ call=_send_request,
122
+ provider_key=provider_key,
123
+ provider_concurrency_caps=provider_concurrency_caps,
124
+ retry_settings=provider_retry_settings,
125
+ operation=operation,
126
+ )
127
+ try:
128
+ data = response.json()
129
+ except Exception as e: # noqa: BLE001
130
+ raise ProviderWebSearchError(f"{provider_label} returned non-JSON response") from e
131
+ if not isinstance(data, dict):
132
+ raise ProviderWebSearchError(f"Unexpected {provider_label} payload: expected JSON object")
133
+ return data
134
+
135
+
136
+ def _host_matches_allowed_domains(url: str, allowed_domains: list[str] | None) -> bool:
137
+ if not allowed_domains:
138
+ return True
139
+ try:
140
+ host = (urlsplit(url).hostname or "").rstrip(".").lower()
141
+ except ValueError:
142
+ return False
143
+ for domain in allowed_domains:
144
+ normalized = str(domain or "").strip().rstrip(".").lower()
145
+ if host == normalized or host.endswith(f".{normalized}"):
146
+ return True
147
+ return False
148
+
149
+
150
+ def _normalize_source(raw: Any) -> dict[str, Any] | None:
151
+ if not isinstance(raw, dict):
152
+ return None
153
+ nested = raw.get("url_citation")
154
+ if isinstance(nested, dict):
155
+ raw = nested
156
+ web = raw.get("web")
157
+ if isinstance(web, dict):
158
+ raw = web
159
+ url = str(
160
+ raw.get("url") or raw.get("uri") or raw.get("link") or raw.get("origin_url") or ""
161
+ ).strip()
162
+ if not url:
163
+ return None
164
+ normalized_url = normalize_web_url(url) or url
165
+ source: dict[str, Any] = {
166
+ "url": normalized_url,
167
+ "title": str(raw.get("title") or raw.get("name") or raw.get("source") or "").strip(),
168
+ }
169
+ snippet = _truncate_snippet(
170
+ raw.get("snippet")
171
+ or raw.get("content")
172
+ or raw.get("summary")
173
+ or raw.get("text")
174
+ or raw.get("cited_text")
175
+ )
176
+ if snippet:
177
+ source["snippet"] = snippet
178
+ return source
179
+
180
+
181
+ def _dedupe_sources(
182
+ sources: list[dict[str, Any]],
183
+ *,
184
+ allowed_domains: list[str] | None = None,
185
+ max_sources: int,
186
+ ) -> tuple[list[dict[str, Any]], bool]:
187
+ deduped: list[dict[str, Any]] = []
188
+ seen: set[str] = set()
189
+ for source in sources:
190
+ normalized = _normalize_source(source)
191
+ if normalized is None:
192
+ continue
193
+ url = str(normalized.get("url") or "").strip()
194
+ if not url or url in seen:
195
+ continue
196
+ if not _host_matches_allowed_domains(url, allowed_domains):
197
+ continue
198
+ seen.add(url)
199
+ deduped.append(normalized)
200
+ return deduped[:max_sources], len(deduped) > max_sources
201
+
202
+
203
+ def _citations_from_sources(sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
204
+ citations: list[dict[str, Any]] = []
205
+ for source in sources:
206
+ url = str(source.get("url") or "").strip()
207
+ if not url:
208
+ continue
209
+ citations.append(
210
+ {
211
+ "title": str(source.get("title") or "").strip(),
212
+ "url": url,
213
+ "start_index": source.get("start_index"),
214
+ "end_index": source.get("end_index"),
215
+ }
216
+ )
217
+ return citations
218
+
219
+
220
+ def _finalize_citations(
221
+ citations: list[dict[str, Any]],
222
+ sources: list[dict[str, Any]],
223
+ *,
224
+ max_results: int,
225
+ ) -> list[dict[str, Any]]:
226
+ source_urls = {str(source.get("url") or "").strip() for source in sources}
227
+ filtered: list[dict[str, Any]] = []
228
+ seen: set[str] = set()
229
+ for citation in citations:
230
+ url = (
231
+ normalize_web_url(str(citation.get("url") or "").strip())
232
+ or str(citation.get("url") or "").strip()
233
+ )
234
+ if not url or url not in source_urls or url in seen:
235
+ continue
236
+ seen.add(url)
237
+ filtered.append(
238
+ {
239
+ "title": str(citation.get("title") or "").strip(),
240
+ "url": url,
241
+ "start_index": citation.get("start_index"),
242
+ "end_index": citation.get("end_index"),
243
+ }
244
+ )
245
+ if filtered:
246
+ return filtered[:max_results]
247
+ return _citations_from_sources(sources)[:max_results]
248
+
249
+
250
+ def _sources_from_answer_urls(answer: str) -> list[dict[str, Any]]:
251
+ return [
252
+ {"url": entry["normalized_url"], "title": ""} for entry in extract_public_web_urls(answer)
253
+ ]
254
+
255
+
256
+ def _collect_source_dicts(value: Any) -> list[dict[str, Any]]:
257
+ sources: list[dict[str, Any]] = []
258
+
259
+ def _walk(node: Any) -> None:
260
+ if isinstance(node, dict):
261
+ if node.get("url") or node.get("uri") or node.get("link") or node.get("origin_url"):
262
+ sources.append(node)
263
+ for nested_key in (
264
+ "choices",
265
+ "message",
266
+ "sources",
267
+ "citations",
268
+ "annotations",
269
+ "url_citation",
270
+ "search_results",
271
+ "search_result",
272
+ "results",
273
+ "web_search",
274
+ "action",
275
+ "data",
276
+ "items",
277
+ "output",
278
+ "content",
279
+ ):
280
+ nested = node.get(nested_key)
281
+ if isinstance(nested, (list, dict)):
282
+ _walk(nested)
283
+ elif isinstance(node, list):
284
+ for item in node:
285
+ _walk(item)
286
+
287
+ _walk(value)
288
+ return sources
289
+
290
+
291
+ def _collect_queries(value: Any) -> list[str]:
292
+ queries: list[str] = []
293
+ seen: set[str] = set()
294
+
295
+ def _append(raw: Any) -> None:
296
+ query_text = str(raw or "").strip()
297
+ if query_text and query_text not in seen:
298
+ seen.add(query_text)
299
+ queries.append(query_text)
300
+
301
+ def _walk(node: Any) -> None:
302
+ if isinstance(node, dict):
303
+ for key in ("query", "search_query", "keywords"):
304
+ if key in node:
305
+ _append(node.get(key))
306
+ for nested_key in (
307
+ "choices",
308
+ "message",
309
+ "queries",
310
+ "search_intent",
311
+ "search_results",
312
+ "search_result",
313
+ "web_search",
314
+ "action",
315
+ "tool_calls",
316
+ "arguments",
317
+ "input",
318
+ "output",
319
+ "content",
320
+ ):
321
+ nested = node.get(nested_key)
322
+ if isinstance(nested, (list, dict)):
323
+ _walk(nested)
324
+ elif isinstance(node, list):
325
+ for item in node:
326
+ if isinstance(item, str):
327
+ _append(item)
328
+ else:
329
+ _walk(item)
330
+
331
+ _walk(value)
332
+ return queries
333
+
334
+
335
+ def _responses_answer(data: dict[str, Any]) -> str:
336
+ output_text = data.get("output_text")
337
+ if isinstance(output_text, str) and output_text.strip():
338
+ return output_text.strip()
339
+ output = data.get("output")
340
+ if not isinstance(output, list):
341
+ return ""
342
+ parts: list[str] = []
343
+ for item in output:
344
+ if not isinstance(item, dict):
345
+ continue
346
+ content = item.get("content")
347
+ if isinstance(content, str):
348
+ parts.append(content)
349
+ elif isinstance(content, list):
350
+ for chunk in content:
351
+ if not isinstance(chunk, dict):
352
+ continue
353
+ text = chunk.get("text") or chunk.get("content")
354
+ if isinstance(text, str):
355
+ parts.append(text)
356
+ return "".join(parts).strip()
357
+
358
+
359
+ def _domain_prompt_suffix(allowed_domains: list[str] | None) -> str:
360
+ if not allowed_domains:
361
+ return ""
362
+ return "\nOnly use sources from these domains: " + ", ".join(allowed_domains) + "."
363
+
364
+
365
+ def _chat_completion_answer(data: dict[str, Any]) -> str:
366
+ choices = data.get("choices")
367
+ if not isinstance(choices, list):
368
+ return ""
369
+ for choice in choices:
370
+ if not isinstance(choice, dict):
371
+ continue
372
+ message = choice.get("message")
373
+ if not isinstance(message, dict):
374
+ continue
375
+ content = message.get("content")
376
+ if isinstance(content, str):
377
+ return content.strip()
378
+ if isinstance(content, list):
379
+ parts: list[str] = []
380
+ for part in content:
381
+ if isinstance(part, dict):
382
+ text = part.get("text") or part.get("content")
383
+ if isinstance(text, str):
384
+ parts.append(text)
385
+ if parts:
386
+ return "".join(parts).strip()
387
+ return ""
388
+
389
+
390
+ def _chat_message(data: dict[str, Any]) -> dict[str, Any]:
391
+ choices = data.get("choices")
392
+ if not isinstance(choices, list):
393
+ return {}
394
+ for choice in choices:
395
+ if isinstance(choice, dict) and isinstance(choice.get("message"), dict):
396
+ return choice["message"]
397
+ return {}
398
+
399
+
400
+ def _extract_chat_annotations(
401
+ data: dict[str, Any],
402
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
403
+ message = _chat_message(data)
404
+ annotations = message.get("annotations")
405
+ sources: list[dict[str, Any]] = []
406
+ citations: list[dict[str, Any]] = []
407
+ if not isinstance(annotations, list):
408
+ return sources, citations
409
+ for annotation in annotations:
410
+ source = _normalize_source(annotation)
411
+ if source is None:
412
+ continue
413
+ url_citation = annotation.get("url_citation") if isinstance(annotation, dict) else None
414
+ citation_payload = url_citation if isinstance(url_citation, dict) else annotation
415
+ source["start_index"] = citation_payload.get("start_index")
416
+ source["end_index"] = citation_payload.get("end_index")
417
+ sources.append(source)
418
+ citations.append(
419
+ {
420
+ "title": str(source.get("title") or "").strip(),
421
+ "url": str(source.get("url") or "").strip(),
422
+ "start_index": source.get("start_index"),
423
+ "end_index": source.get("end_index"),
424
+ }
425
+ )
426
+ return sources, citations
427
+
428
+
429
+ def _minimax_coding_plan_search_url(base_url: str) -> str:
430
+ normalized = normalize_provider_base_url(base_url)
431
+ if normalized.endswith("/v1"):
432
+ return f"{normalized}/coding_plan/search"
433
+ return f"{normalized}/v1/coding_plan/search"
434
+
435
+
436
+ def minimax_coding_plan_search(
437
+ *,
438
+ query: str,
439
+ base_url: str,
440
+ api_key: str,
441
+ model: str,
442
+ max_results: int,
443
+ allowed_domains: list[str] | None,
444
+ timeout_s: float,
445
+ transport: httpx.BaseTransport | None = None,
446
+ resolver: Resolver | None = None,
447
+ provider_concurrency_caps: dict[str, int] | None = None,
448
+ provider_retry_settings: ProviderRetrySettings | None = None,
449
+ ) -> dict[str, Any]:
450
+ data = _post_json(
451
+ provider_label="MiniMax Token Plan",
452
+ url=_minimax_coding_plan_search_url(base_url),
453
+ headers={
454
+ "Authorization": f"Bearer {api_key}",
455
+ "MM-API-Source": "Minimax-MCP",
456
+ "Content-Type": "application/json",
457
+ "User-Agent": "alysis-code/0.1.0",
458
+ },
459
+ payload={"q": query},
460
+ timeout_s=timeout_s,
461
+ transport=transport,
462
+ resolver=resolver,
463
+ provider_key="minimax",
464
+ provider_concurrency_caps=provider_concurrency_caps,
465
+ provider_retry_settings=provider_retry_settings,
466
+ operation="minimax_coding_plan_search",
467
+ )
468
+ base_response = data.get("base_resp")
469
+ if isinstance(base_response, dict):
470
+ status_code = base_response.get("status_code")
471
+ if status_code not in (None, 0, "0"):
472
+ status_message = str(base_response.get("status_msg") or "search failed").strip()
473
+ raise ProviderWebSearchError(
474
+ f"MiniMax Token Plan error {status_code}: {status_message}"
475
+ )
476
+
477
+ organic = data.get("organic")
478
+ if not isinstance(organic, list) and isinstance(data.get("data"), dict):
479
+ organic = data["data"].get("organic")
480
+ raw_sources = [item for item in organic or [] if isinstance(item, dict)]
481
+ sources, sources_truncated = _dedupe_sources(
482
+ raw_sources,
483
+ allowed_domains=allowed_domains,
484
+ max_sources=max_results,
485
+ )
486
+ if not sources:
487
+ raise ProviderWebSearchError("MiniMax Token Plan web_search did not return sources")
488
+
489
+ related = data.get("related_searches")
490
+ if not isinstance(related, list) and isinstance(data.get("data"), dict):
491
+ related = data["data"].get("related_searches")
492
+ queries = [query]
493
+ for item in related or []:
494
+ related_query = (
495
+ str(item.get("query") or "").strip()
496
+ if isinstance(item, dict)
497
+ else str(item or "").strip()
498
+ )
499
+ if related_query and related_query not in queries:
500
+ queries.append(related_query)
501
+
502
+ return {
503
+ "query": query,
504
+ "answer": str(data.get("answer") or "").strip(),
505
+ "citations": _citations_from_sources(sources),
506
+ "sources": sources,
507
+ "queries": queries,
508
+ "model": model,
509
+ "backend": "minimax_coding_plan",
510
+ "allowed_domains": allowed_domains or [],
511
+ "external_web_access": True,
512
+ "response_id": str(data.get("id") or data.get("request_id") or "").strip() or None,
513
+ "sources_truncated": sources_truncated,
514
+ }
515
+
516
+
517
+ def _cohere_v1_chat_url(base_url: str) -> str:
518
+ split = urlsplit(str(base_url or "").strip())
519
+ host = (split.hostname or "").rstrip(".").lower()
520
+ if host in {"api.cohere.ai", "api.cohere.com"}:
521
+ return urlunsplit(("https", "api.cohere.com", "/v1/chat", "", ""))
522
+ normalized = normalize_provider_base_url(base_url)
523
+ if normalized.endswith("/compatibility/v1"):
524
+ normalized = normalized.removesuffix("/compatibility/v1")
525
+ elif normalized.endswith("/v1"):
526
+ normalized = normalized.removesuffix("/v1")
527
+ return f"{normalized}/v1/chat"
528
+
529
+
530
+ def cohere_web_search(
531
+ *,
532
+ query: str,
533
+ base_url: str,
534
+ api_key: str,
535
+ model: str,
536
+ max_results: int,
537
+ allowed_domains: list[str] | None,
538
+ timeout_s: float,
539
+ transport: httpx.BaseTransport | None = None,
540
+ resolver: Resolver | None = None,
541
+ provider_concurrency_caps: dict[str, int] | None = None,
542
+ provider_retry_settings: ProviderRetrySettings | None = None,
543
+ ) -> dict[str, Any]:
544
+ data = _post_json(
545
+ provider_label="Cohere",
546
+ url=_cohere_v1_chat_url(base_url),
547
+ headers={
548
+ "Authorization": f"Bearer {api_key}",
549
+ "Content-Type": "application/json",
550
+ "User-Agent": "alysis-code/0.1.0",
551
+ },
552
+ payload={
553
+ "model": model,
554
+ "message": f"{query}{_domain_prompt_suffix(allowed_domains)}",
555
+ "connectors": [{"id": "web-search"}],
556
+ "prompt_truncation": "AUTO",
557
+ },
558
+ timeout_s=timeout_s,
559
+ transport=transport,
560
+ resolver=resolver,
561
+ provider_key="cohere",
562
+ provider_concurrency_caps=provider_concurrency_caps,
563
+ provider_retry_settings=provider_retry_settings,
564
+ operation="cohere_web_search",
565
+ )
566
+
567
+ documents = data.get("documents")
568
+ raw_documents = [item for item in documents or [] if isinstance(item, dict)]
569
+ source_by_document_id: dict[str, dict[str, Any]] = {}
570
+ for document in raw_documents:
571
+ source = _normalize_source(document)
572
+ document_id = str(document.get("id") or "").strip()
573
+ if source is not None and document_id:
574
+ source_by_document_id[document_id] = source
575
+ sources, sources_truncated = _dedupe_sources(
576
+ raw_documents,
577
+ allowed_domains=allowed_domains,
578
+ max_sources=max_results,
579
+ )
580
+ if not sources:
581
+ raise ProviderWebSearchError("Cohere web-search connector did not return sources")
582
+
583
+ citations: list[dict[str, Any]] = []
584
+ raw_citations = data.get("citations")
585
+ for citation in raw_citations or []:
586
+ if not isinstance(citation, dict):
587
+ continue
588
+ document_ids = citation.get("document_ids")
589
+ for document_id in document_ids if isinstance(document_ids, list) else []:
590
+ source = source_by_document_id.get(str(document_id or "").strip())
591
+ if source is None:
592
+ continue
593
+ citations.append(
594
+ {
595
+ "title": str(source.get("title") or "").strip(),
596
+ "url": str(source.get("url") or "").strip(),
597
+ "start_index": citation.get("start"),
598
+ "end_index": citation.get("end"),
599
+ }
600
+ )
601
+ citations = _finalize_citations(citations, sources, max_results=max_results)
602
+
603
+ queries: list[str] = []
604
+ raw_queries = data.get("search_queries")
605
+ for item in raw_queries or []:
606
+ raw_query = item.get("text") if isinstance(item, dict) else item
607
+ query_text = str(raw_query or "").strip()
608
+ if query_text and query_text not in queries:
609
+ queries.append(query_text)
610
+
611
+ return {
612
+ "query": query,
613
+ "answer": str(data.get("text") or "").strip(),
614
+ "citations": citations,
615
+ "sources": sources,
616
+ "queries": queries or [query],
617
+ "model": model,
618
+ "backend": "cohere_web_search",
619
+ "allowed_domains": allowed_domains or [],
620
+ "external_web_access": True,
621
+ "response_id": (
622
+ str(data.get("response_id") or data.get("generation_id") or "").strip() or None
623
+ ),
624
+ "sources_truncated": sources_truncated,
625
+ }
626
+
627
+
628
+ def moonshot_kimi_search(
629
+ *,
630
+ query: str,
631
+ base_url: str,
632
+ api_key: str,
633
+ model: str,
634
+ max_results: int,
635
+ allowed_domains: list[str] | None,
636
+ timeout_s: float,
637
+ transport: httpx.BaseTransport | None = None,
638
+ resolver: Resolver | None = None,
639
+ provider_concurrency_caps: dict[str, int] | None = None,
640
+ provider_retry_settings: ProviderRetrySettings | None = None,
641
+ ) -> dict[str, Any]:
642
+ url = f"{base_url.rstrip('/')}/chat/completions"
643
+ prompt = f"Use Kimi built-in web search and answer with source URLs.\n\n{query}"
644
+ prompt += _domain_prompt_suffix(allowed_domains)
645
+ messages: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
646
+ tools = [{"type": "builtin_function", "function": {"name": "$web_search"}}]
647
+
648
+ last_data: dict[str, Any] | None = None
649
+ raw_sources: list[dict[str, Any]] = []
650
+ queries: list[str] = []
651
+ for _attempt in range(4):
652
+ payload: dict[str, Any] = {
653
+ "model": model,
654
+ "messages": messages,
655
+ "tools": tools,
656
+ "thinking": {"type": "disabled"},
657
+ }
658
+ data = _post_json(
659
+ provider_label="Kimi",
660
+ url=url,
661
+ headers={
662
+ "Authorization": f"Bearer {api_key}",
663
+ "Content-Type": "application/json",
664
+ "User-Agent": "alysis-code/0.1.0",
665
+ },
666
+ payload=payload,
667
+ timeout_s=timeout_s,
668
+ transport=transport,
669
+ resolver=resolver,
670
+ provider_key="moonshot",
671
+ provider_concurrency_caps=provider_concurrency_caps,
672
+ provider_retry_settings=provider_retry_settings,
673
+ operation="moonshot_kimi_search",
674
+ )
675
+ last_data = data
676
+ raw_sources.extend(_collect_source_dicts(data))
677
+ queries.extend(_collect_queries(data))
678
+
679
+ choices = data.get("choices")
680
+ choice = (
681
+ next((item for item in choices if isinstance(item, dict)), None)
682
+ if isinstance(choices, list)
683
+ else None
684
+ )
685
+ message = (
686
+ choice.get("message")
687
+ if isinstance(choice, dict) and isinstance(choice.get("message"), dict)
688
+ else None
689
+ )
690
+ finish_reason = str(choice.get("finish_reason") or "") if isinstance(choice, dict) else ""
691
+ tool_calls = message.get("tool_calls") if isinstance(message, dict) else None
692
+ if finish_reason != "tool_calls" or not isinstance(tool_calls, list):
693
+ break
694
+
695
+ assistant_message = dict(message)
696
+ messages.append(assistant_message)
697
+ for tool_call in tool_calls:
698
+ if not isinstance(tool_call, dict):
699
+ continue
700
+ function = tool_call.get("function")
701
+ tool_name = ""
702
+ raw_arguments = "{}"
703
+ if isinstance(function, dict):
704
+ tool_name = str(function.get("name") or "").strip()
705
+ raw_arguments = str(function.get("arguments") or "{}")
706
+ try:
707
+ arguments = json.loads(raw_arguments)
708
+ except json.JSONDecodeError:
709
+ arguments = {"raw_arguments": raw_arguments}
710
+ if isinstance(arguments, dict):
711
+ raw_sources.extend(_collect_source_dicts(arguments))
712
+ queries.extend(_collect_queries(arguments))
713
+ tool_result: Any = (
714
+ arguments if tool_name == "$web_search" else {"error": "unknown tool"}
715
+ )
716
+ messages.append(
717
+ {
718
+ "role": "tool",
719
+ "tool_call_id": str(tool_call.get("id") or "").strip(),
720
+ "name": tool_name or "$web_search",
721
+ "content": json.dumps(tool_result, ensure_ascii=False),
722
+ }
723
+ )
724
+ else:
725
+ raise ProviderWebSearchError("Kimi web_search did not finish after 4 tool-call rounds")
726
+
727
+ if last_data is None:
728
+ raise ProviderWebSearchError("Kimi web_search did not return a response")
729
+ answer = _chat_completion_answer(last_data)
730
+ sources, sources_truncated = _dedupe_sources(
731
+ [*raw_sources, *_sources_from_answer_urls(answer)],
732
+ allowed_domains=allowed_domains,
733
+ max_sources=max_results,
734
+ )
735
+ if not sources:
736
+ raise ProviderWebSearchError("Kimi web_search did not return sources")
737
+ return {
738
+ "query": query,
739
+ "answer": answer,
740
+ "citations": _citations_from_sources(sources),
741
+ "sources": sources,
742
+ "queries": queries or [query],
743
+ "model": str(last_data.get("model") or model),
744
+ "backend": "moonshot_kimi",
745
+ "allowed_domains": allowed_domains or [],
746
+ "external_web_access": True,
747
+ "response_id": str(last_data.get("id") or "").strip() or None,
748
+ "sources_truncated": sources_truncated,
749
+ }
750
+
751
+
752
+ def zhipu_web_search(
753
+ *,
754
+ query: str,
755
+ base_url: str,
756
+ api_key: str,
757
+ model: str,
758
+ max_results: int,
759
+ allowed_domains: list[str] | None,
760
+ timeout_s: float,
761
+ transport: httpx.BaseTransport | None = None,
762
+ resolver: Resolver | None = None,
763
+ provider_concurrency_caps: dict[str, int] | None = None,
764
+ provider_retry_settings: ProviderRetrySettings | None = None,
765
+ ) -> dict[str, Any]:
766
+ provider_query = query[:70].rsplit(" ", 1)[0].strip() if len(query) > 70 else query
767
+ if not provider_query:
768
+ provider_query = query[:70]
769
+ payload: dict[str, Any] = {
770
+ "search_query": provider_query,
771
+ "search_engine": "search_pro",
772
+ "search_intent": False,
773
+ "count": min(max_results, 50),
774
+ "search_recency_filter": "noLimit",
775
+ "content_size": "medium",
776
+ }
777
+ if allowed_domains and len(allowed_domains) == 1:
778
+ payload["search_domain_filter"] = allowed_domains[0]
779
+ data = _post_json(
780
+ provider_label="Zhipu",
781
+ url=f"{base_url.rstrip('/')}/web_search",
782
+ headers={
783
+ "Authorization": f"Bearer {api_key}",
784
+ "Content-Type": "application/json",
785
+ "User-Agent": "alysis-code/0.1.0",
786
+ },
787
+ payload=payload,
788
+ timeout_s=timeout_s,
789
+ transport=transport,
790
+ resolver=resolver,
791
+ provider_key="zhipu",
792
+ provider_concurrency_caps=provider_concurrency_caps,
793
+ provider_retry_settings=provider_retry_settings,
794
+ operation="zhipu_web_search",
795
+ )
796
+ raw_results = data.get("search_result")
797
+ raw_sources = (
798
+ [item for item in raw_results if isinstance(item, dict)]
799
+ if isinstance(raw_results, list)
800
+ else []
801
+ )
802
+ sources, sources_truncated = _dedupe_sources(
803
+ raw_sources,
804
+ allowed_domains=allowed_domains,
805
+ max_sources=max_results,
806
+ )
807
+ if not sources:
808
+ raise ProviderWebSearchError("Zhipu web_search did not return sources")
809
+ return {
810
+ "query": query,
811
+ "answer": "",
812
+ "citations": _citations_from_sources(sources),
813
+ "sources": sources,
814
+ "queries": _collect_queries(data) or [provider_query],
815
+ "model": model,
816
+ "backend": "zhipu_web_search",
817
+ "allowed_domains": allowed_domains or [],
818
+ "external_web_access": True,
819
+ "response_id": str(data.get("id") or data.get("request_id") or "").strip() or None,
820
+ "sources_truncated": sources_truncated,
821
+ }
822
+
823
+
824
+ def volcengine_web_search(
825
+ *,
826
+ query: str,
827
+ base_url: str,
828
+ api_key: str,
829
+ model: str,
830
+ max_results: int,
831
+ allowed_domains: list[str] | None,
832
+ timeout_s: float,
833
+ transport: httpx.BaseTransport | None = None,
834
+ resolver: Resolver | None = None,
835
+ provider_concurrency_caps: dict[str, int] | None = None,
836
+ provider_retry_settings: ProviderRetrySettings | None = None,
837
+ ) -> dict[str, Any]:
838
+ prompt = f"{query}{_domain_prompt_suffix(allowed_domains)}"
839
+ data = _post_json(
840
+ provider_label="Volcengine",
841
+ url=f"{base_url.rstrip('/')}/responses",
842
+ headers={
843
+ "Authorization": f"Bearer {api_key}",
844
+ "Content-Type": "application/json",
845
+ "User-Agent": "alysis-code/0.1.0",
846
+ },
847
+ payload={
848
+ "model": model,
849
+ "input": [{"role": "user", "content": prompt}],
850
+ "tools": [{"type": "web_search"}],
851
+ "stream": False,
852
+ },
853
+ timeout_s=timeout_s,
854
+ transport=transport,
855
+ resolver=resolver,
856
+ provider_key="volcengine",
857
+ provider_concurrency_caps=provider_concurrency_caps,
858
+ provider_retry_settings=provider_retry_settings,
859
+ operation="volcengine_web_search",
860
+ )
861
+ answer = _responses_answer(data)
862
+ sources, sources_truncated = _dedupe_sources(
863
+ [*_collect_source_dicts(data), *_sources_from_answer_urls(answer)],
864
+ allowed_domains=allowed_domains,
865
+ max_sources=max_results,
866
+ )
867
+ if not sources:
868
+ raise ProviderWebSearchError("Volcengine web_search did not return sources")
869
+ return {
870
+ "query": query,
871
+ "answer": answer,
872
+ "citations": _citations_from_sources(sources),
873
+ "sources": sources,
874
+ "queries": _collect_queries(data) or [query],
875
+ "model": str(data.get("model") or model),
876
+ "backend": "volcengine_web_search",
877
+ "allowed_domains": allowed_domains or [],
878
+ "external_web_access": True,
879
+ "response_id": str(data.get("id") or "").strip() or None,
880
+ "sources_truncated": sources_truncated,
881
+ }
882
+
883
+
884
+ def anthropic_messages_search(
885
+ *,
886
+ query: str,
887
+ base_url: str,
888
+ api_key: str,
889
+ model: str,
890
+ max_results: int,
891
+ allowed_domains: list[str] | None,
892
+ timeout_s: float,
893
+ transport: httpx.BaseTransport | None = None,
894
+ resolver: Resolver | None = None,
895
+ provider_concurrency_caps: dict[str, int] | None = None,
896
+ provider_retry_settings: ProviderRetrySettings | None = None,
897
+ ) -> dict[str, Any]:
898
+ url = f"{base_url.rstrip('/')}/messages"
899
+ tool: dict[str, Any] = {
900
+ "type": "web_search_20260209",
901
+ "name": "web_search",
902
+ "max_uses": 3,
903
+ }
904
+ if allowed_domains:
905
+ tool["allowed_domains"] = list(allowed_domains)
906
+ payload: dict[str, Any] = {
907
+ "model": model,
908
+ "max_tokens": 2048,
909
+ "messages": [
910
+ {
911
+ "role": "user",
912
+ "content": f"Use web search to answer with citations.\n\n{query}",
913
+ }
914
+ ],
915
+ "tools": [tool],
916
+ }
917
+ data = _post_json(
918
+ provider_label="Anthropic",
919
+ url=url,
920
+ headers={
921
+ "x-api-key": api_key,
922
+ "anthropic-version": "2023-06-01",
923
+ "Content-Type": "application/json",
924
+ "User-Agent": "alysis-code/0.1.0",
925
+ },
926
+ payload=payload,
927
+ timeout_s=timeout_s,
928
+ transport=transport,
929
+ resolver=resolver,
930
+ provider_key="anthropic",
931
+ provider_concurrency_caps=provider_concurrency_caps,
932
+ provider_retry_settings=provider_retry_settings,
933
+ operation="anthropic_web_search",
934
+ )
935
+ content = data.get("content")
936
+ if not isinstance(content, list):
937
+ raise ProviderWebSearchError("Unexpected Anthropic payload: missing content list")
938
+
939
+ text_parts: list[str] = []
940
+ raw_sources: list[dict[str, Any]] = []
941
+ citations: list[dict[str, Any]] = []
942
+ queries: list[str] = []
943
+ for block in content:
944
+ if not isinstance(block, dict):
945
+ continue
946
+ block_type = str(block.get("type") or "")
947
+ if block_type == "text":
948
+ text = block.get("text")
949
+ if isinstance(text, str):
950
+ text_parts.append(text)
951
+ for citation in (
952
+ block.get("citations") if isinstance(block.get("citations"), list) else []
953
+ ):
954
+ source = _normalize_source(citation)
955
+ if source is None:
956
+ continue
957
+ source["start_index"] = citation.get("start_index")
958
+ source["end_index"] = citation.get("end_index")
959
+ raw_sources.append(source)
960
+ citations.append(
961
+ {
962
+ "title": str(source.get("title") or "").strip(),
963
+ "url": str(source.get("url") or "").strip(),
964
+ "start_index": source.get("start_index"),
965
+ "end_index": source.get("end_index"),
966
+ }
967
+ )
968
+ elif block_type == "server_tool_use":
969
+ raw_input = block.get("input")
970
+ if isinstance(raw_input, dict):
971
+ raw_query = str(raw_input.get("query") or "").strip()
972
+ if raw_query:
973
+ queries.append(raw_query)
974
+ elif block_type == "web_search_tool_result":
975
+ results = block.get("content")
976
+ if isinstance(results, list):
977
+ raw_sources.extend(result for result in results if isinstance(result, dict))
978
+
979
+ answer = "".join(text_parts).strip()
980
+ sources, sources_truncated = _dedupe_sources(
981
+ raw_sources,
982
+ allowed_domains=allowed_domains,
983
+ max_sources=max_results,
984
+ )
985
+ if not sources:
986
+ raise ProviderWebSearchError("Anthropic web_search did not return sources")
987
+ citations = _finalize_citations(citations, sources, max_results=max_results)
988
+ return {
989
+ "query": query,
990
+ "answer": answer,
991
+ "citations": citations,
992
+ "sources": sources,
993
+ "queries": queries or [query],
994
+ "model": str(data.get("model") or model),
995
+ "backend": "anthropic_messages",
996
+ "allowed_domains": allowed_domains or [],
997
+ "external_web_access": True,
998
+ "response_id": str(data.get("id") or "").strip() or None,
999
+ "sources_truncated": sources_truncated,
1000
+ }
1001
+
1002
+
1003
+ def _gemini_native_base_url(base_url: str) -> str:
1004
+ normalized = str(base_url or "").strip().rstrip("/")
1005
+ if normalized.endswith("/openai"):
1006
+ return normalized.removesuffix("/openai")
1007
+ return normalized
1008
+
1009
+
1010
+ def gemini_grounding_search(
1011
+ *,
1012
+ query: str,
1013
+ base_url: str,
1014
+ api_key: str,
1015
+ model: str,
1016
+ max_results: int,
1017
+ allowed_domains: list[str] | None,
1018
+ timeout_s: float,
1019
+ transport: httpx.BaseTransport | None = None,
1020
+ resolver: Resolver | None = None,
1021
+ provider_concurrency_caps: dict[str, int] | None = None,
1022
+ provider_retry_settings: ProviderRetrySettings | None = None,
1023
+ ) -> dict[str, Any]:
1024
+ encoded_model = quote(model, safe="")
1025
+ url = f"{_gemini_native_base_url(base_url)}/models/{encoded_model}:generateContent"
1026
+ prompt = f"{query}{_domain_prompt_suffix(allowed_domains)}"
1027
+ payload: dict[str, Any] = {
1028
+ "contents": [{"parts": [{"text": prompt}]}],
1029
+ "tools": [{"google_search": {}}],
1030
+ }
1031
+ data = _post_json(
1032
+ provider_label="Gemini",
1033
+ url=url,
1034
+ headers={
1035
+ "x-goog-api-key": api_key,
1036
+ "Content-Type": "application/json",
1037
+ "User-Agent": "alysis-code/0.1.0",
1038
+ },
1039
+ payload=payload,
1040
+ timeout_s=timeout_s,
1041
+ transport=transport,
1042
+ resolver=resolver,
1043
+ provider_key="gemini",
1044
+ provider_concurrency_caps=provider_concurrency_caps,
1045
+ provider_retry_settings=provider_retry_settings,
1046
+ operation="gemini_grounding_search",
1047
+ )
1048
+ candidates = data.get("candidates")
1049
+ if not isinstance(candidates, list) or not candidates:
1050
+ raise ProviderWebSearchError("Gemini grounding payload missing candidates")
1051
+ candidate = next((item for item in candidates if isinstance(item, dict)), {})
1052
+ content = candidate.get("content") if isinstance(candidate, dict) else None
1053
+ parts = content.get("parts") if isinstance(content, dict) else None
1054
+ text_parts: list[str] = []
1055
+ if isinstance(parts, list):
1056
+ for part in parts:
1057
+ if isinstance(part, dict) and isinstance(part.get("text"), str):
1058
+ text_parts.append(part["text"])
1059
+ answer = "".join(text_parts).strip()
1060
+ grounding = candidate.get("groundingMetadata") if isinstance(candidate, dict) else None
1061
+ raw_chunks = grounding.get("groundingChunks") if isinstance(grounding, dict) else None
1062
+ raw_sources = (
1063
+ [chunk for chunk in raw_chunks if isinstance(chunk, dict)]
1064
+ if isinstance(raw_chunks, list)
1065
+ else []
1066
+ )
1067
+ sources, sources_truncated = _dedupe_sources(
1068
+ raw_sources,
1069
+ allowed_domains=allowed_domains,
1070
+ max_sources=max_results,
1071
+ )
1072
+ citations: list[dict[str, Any]] = []
1073
+ supports = grounding.get("groundingSupports") if isinstance(grounding, dict) else None
1074
+ if isinstance(supports, list) and isinstance(raw_chunks, list):
1075
+ for support in supports:
1076
+ if not isinstance(support, dict):
1077
+ continue
1078
+ segment = support.get("segment") if isinstance(support.get("segment"), dict) else {}
1079
+ for raw_index in support.get("groundingChunkIndices", []):
1080
+ try:
1081
+ chunk = raw_chunks[int(raw_index)]
1082
+ except (TypeError, ValueError, IndexError):
1083
+ continue
1084
+ source = _normalize_source(chunk)
1085
+ if source is None:
1086
+ continue
1087
+ if not _host_matches_allowed_domains(str(source.get("url") or ""), allowed_domains):
1088
+ continue
1089
+ citations.append(
1090
+ {
1091
+ "title": str(source.get("title") or "").strip(),
1092
+ "url": str(source.get("url") or "").strip(),
1093
+ "start_index": segment.get("startIndex"),
1094
+ "end_index": segment.get("endIndex"),
1095
+ }
1096
+ )
1097
+ queries = []
1098
+ raw_queries = grounding.get("webSearchQueries") if isinstance(grounding, dict) else None
1099
+ if isinstance(raw_queries, list):
1100
+ queries = [str(item).strip() for item in raw_queries if str(item).strip()]
1101
+ if not sources:
1102
+ raise ProviderWebSearchError("Gemini grounding did not return sources")
1103
+ citations = _finalize_citations(citations, sources, max_results=max_results)
1104
+ return {
1105
+ "query": query,
1106
+ "answer": answer,
1107
+ "citations": citations,
1108
+ "sources": sources,
1109
+ "queries": queries or [query],
1110
+ "model": model,
1111
+ "backend": "gemini_grounding",
1112
+ "allowed_domains": allowed_domains or [],
1113
+ "external_web_access": True,
1114
+ "response_id": str(data.get("responseId") or "").strip() or None,
1115
+ "sources_truncated": sources_truncated,
1116
+ }
1117
+
1118
+
1119
+ def openrouter_web_search(
1120
+ *,
1121
+ query: str,
1122
+ base_url: str,
1123
+ api_key: str,
1124
+ model: str,
1125
+ max_results: int,
1126
+ allowed_domains: list[str] | None,
1127
+ timeout_s: float,
1128
+ transport: httpx.BaseTransport | None = None,
1129
+ resolver: Resolver | None = None,
1130
+ provider_concurrency_caps: dict[str, int] | None = None,
1131
+ provider_retry_settings: ProviderRetrySettings | None = None,
1132
+ ) -> dict[str, Any]:
1133
+ tool: dict[str, Any] = {
1134
+ "type": "openrouter:web_search",
1135
+ "parameters": {"engine": "auto", "max_results": max_results},
1136
+ }
1137
+ if allowed_domains:
1138
+ tool["parameters"]["allowed_domains"] = list(allowed_domains)
1139
+ data = _post_json(
1140
+ provider_label="OpenRouter",
1141
+ url=f"{base_url.rstrip('/')}/chat/completions",
1142
+ headers={
1143
+ "Authorization": f"Bearer {api_key}",
1144
+ "Content-Type": "application/json",
1145
+ "User-Agent": "alysis-code/0.1.0",
1146
+ },
1147
+ payload={
1148
+ "model": model,
1149
+ "messages": [
1150
+ {
1151
+ "role": "user",
1152
+ "content": f"Use web search when useful and cite sources.\n\n{query}",
1153
+ }
1154
+ ],
1155
+ "tools": [tool],
1156
+ },
1157
+ timeout_s=timeout_s,
1158
+ transport=transport,
1159
+ resolver=resolver,
1160
+ provider_key="openrouter",
1161
+ provider_concurrency_caps=provider_concurrency_caps,
1162
+ provider_retry_settings=provider_retry_settings,
1163
+ operation="openrouter_web_search",
1164
+ )
1165
+ answer = _chat_completion_answer(data)
1166
+ annotation_sources, citations = _extract_chat_annotations(data)
1167
+ sources, sources_truncated = _dedupe_sources(
1168
+ [*annotation_sources, *_sources_from_answer_urls(answer)],
1169
+ allowed_domains=allowed_domains,
1170
+ max_sources=max_results,
1171
+ )
1172
+ if not sources:
1173
+ raise ProviderWebSearchError("OpenRouter web_search did not return sources")
1174
+ citations = _finalize_citations(citations, sources, max_results=max_results)
1175
+ return {
1176
+ "query": query,
1177
+ "answer": answer,
1178
+ "citations": citations,
1179
+ "sources": sources,
1180
+ "queries": [query],
1181
+ "model": str(data.get("model") or model),
1182
+ "backend": "openrouter_web",
1183
+ "allowed_domains": allowed_domains or [],
1184
+ "external_web_access": True,
1185
+ "response_id": str(data.get("id") or "").strip() or None,
1186
+ "sources_truncated": sources_truncated,
1187
+ }
1188
+
1189
+
1190
+ def perplexity_sonar_search(
1191
+ *,
1192
+ query: str,
1193
+ base_url: str,
1194
+ api_key: str,
1195
+ model: str,
1196
+ max_results: int,
1197
+ allowed_domains: list[str] | None,
1198
+ timeout_s: float,
1199
+ transport: httpx.BaseTransport | None = None,
1200
+ resolver: Resolver | None = None,
1201
+ provider_concurrency_caps: dict[str, int] | None = None,
1202
+ provider_retry_settings: ProviderRetrySettings | None = None,
1203
+ ) -> dict[str, Any]:
1204
+ payload: dict[str, Any] = {
1205
+ "model": model,
1206
+ "messages": [{"role": "user", "content": query}],
1207
+ "web_search_options": {"search_context_size": "low"},
1208
+ }
1209
+ if allowed_domains:
1210
+ payload["search_domain_filter"] = list(allowed_domains)
1211
+ data = _post_json(
1212
+ provider_label="Perplexity",
1213
+ url=f"{base_url.rstrip('/')}/v1/sonar",
1214
+ headers={
1215
+ "Authorization": f"Bearer {api_key}",
1216
+ "Content-Type": "application/json",
1217
+ "User-Agent": "alysis-code/0.1.0",
1218
+ },
1219
+ payload=payload,
1220
+ timeout_s=timeout_s,
1221
+ transport=transport,
1222
+ resolver=resolver,
1223
+ provider_key="perplexity",
1224
+ provider_concurrency_caps=provider_concurrency_caps,
1225
+ provider_retry_settings=provider_retry_settings,
1226
+ operation="perplexity_sonar_search",
1227
+ )
1228
+ answer = _chat_completion_answer(data)
1229
+ raw_results = data.get("search_results")
1230
+ raw_sources = (
1231
+ [item for item in raw_results if isinstance(item, dict)]
1232
+ if isinstance(raw_results, list)
1233
+ else []
1234
+ )
1235
+ raw_citations = data.get("citations")
1236
+ if isinstance(raw_citations, list):
1237
+ raw_sources.extend(
1238
+ {"url": str(item), "title": ""} for item in raw_citations if str(item).strip()
1239
+ )
1240
+ sources, sources_truncated = _dedupe_sources(
1241
+ raw_sources,
1242
+ allowed_domains=allowed_domains,
1243
+ max_sources=max_results,
1244
+ )
1245
+ if not sources:
1246
+ raise ProviderWebSearchError("Perplexity did not return sources")
1247
+ return {
1248
+ "query": query,
1249
+ "answer": answer,
1250
+ "citations": _citations_from_sources(sources),
1251
+ "sources": sources,
1252
+ "queries": [query],
1253
+ "model": str(data.get("model") or model),
1254
+ "backend": "perplexity_sonar",
1255
+ "allowed_domains": allowed_domains or [],
1256
+ "external_web_access": True,
1257
+ "response_id": str(data.get("id") or "").strip() or None,
1258
+ "sources_truncated": sources_truncated,
1259
+ }
1260
+
1261
+
1262
+ def groq_compound_search(
1263
+ *,
1264
+ query: str,
1265
+ base_url: str,
1266
+ api_key: str,
1267
+ model: str,
1268
+ max_results: int,
1269
+ allowed_domains: list[str] | None,
1270
+ timeout_s: float,
1271
+ transport: httpx.BaseTransport | None = None,
1272
+ resolver: Resolver | None = None,
1273
+ provider_concurrency_caps: dict[str, int] | None = None,
1274
+ provider_retry_settings: ProviderRetrySettings | None = None,
1275
+ ) -> dict[str, Any]:
1276
+ payload: dict[str, Any] = {
1277
+ "model": model,
1278
+ "messages": [{"role": "user", "content": query}],
1279
+ }
1280
+ if allowed_domains:
1281
+ payload["search_settings"] = {"include_domains": list(allowed_domains)}
1282
+ data = _post_json(
1283
+ provider_label="Groq",
1284
+ url=f"{base_url.rstrip('/')}/chat/completions",
1285
+ headers={
1286
+ "Authorization": f"Bearer {api_key}",
1287
+ "Content-Type": "application/json",
1288
+ "User-Agent": "alysis-code/0.1.0",
1289
+ },
1290
+ payload=payload,
1291
+ timeout_s=timeout_s,
1292
+ transport=transport,
1293
+ resolver=resolver,
1294
+ provider_key="groq",
1295
+ provider_concurrency_caps=provider_concurrency_caps,
1296
+ provider_retry_settings=provider_retry_settings,
1297
+ operation="groq_compound_search",
1298
+ )
1299
+ answer = _chat_completion_answer(data)
1300
+ message = _chat_message(data)
1301
+ raw_sources: list[dict[str, Any]] = []
1302
+ executed_tools = message.get("executed_tools")
1303
+ if isinstance(executed_tools, list):
1304
+ for tool in executed_tools:
1305
+ if not isinstance(tool, dict):
1306
+ continue
1307
+ search_results = tool.get("search_results")
1308
+ if isinstance(search_results, dict):
1309
+ results = search_results.get("results")
1310
+ if isinstance(results, list):
1311
+ raw_sources.extend(item for item in results if isinstance(item, dict))
1312
+ elif isinstance(search_results, list):
1313
+ raw_sources.extend(item for item in search_results if isinstance(item, dict))
1314
+ annotation_sources, citations = _extract_chat_annotations(data)
1315
+ sources, sources_truncated = _dedupe_sources(
1316
+ [*raw_sources, *annotation_sources, *_sources_from_answer_urls(answer)],
1317
+ allowed_domains=allowed_domains,
1318
+ max_sources=max_results,
1319
+ )
1320
+ if not sources:
1321
+ raise ProviderWebSearchError("Groq Compound did not return sources")
1322
+ citations = _finalize_citations(citations, sources, max_results=max_results)
1323
+ return {
1324
+ "query": query,
1325
+ "answer": answer,
1326
+ "citations": citations,
1327
+ "sources": sources,
1328
+ "queries": [query],
1329
+ "model": str(data.get("model") or model),
1330
+ "backend": "groq_compound",
1331
+ "allowed_domains": allowed_domains or [],
1332
+ "external_web_access": True,
1333
+ "response_id": str(data.get("id") or "").strip() or None,
1334
+ "sources_truncated": sources_truncated,
1335
+ }
1336
+
1337
+
1338
+ def mistral_conversations_search(
1339
+ *,
1340
+ query: str,
1341
+ base_url: str,
1342
+ api_key: str,
1343
+ model: str,
1344
+ max_results: int,
1345
+ allowed_domains: list[str] | None,
1346
+ timeout_s: float,
1347
+ transport: httpx.BaseTransport | None = None,
1348
+ resolver: Resolver | None = None,
1349
+ provider_concurrency_caps: dict[str, int] | None = None,
1350
+ provider_retry_settings: ProviderRetrySettings | None = None,
1351
+ ) -> dict[str, Any]:
1352
+ prompt = f"{query}{_domain_prompt_suffix(allowed_domains)}"
1353
+ payload: dict[str, Any] = {
1354
+ "model": model,
1355
+ "inputs": prompt,
1356
+ "tools": [{"type": "web_search"}],
1357
+ "store": False,
1358
+ }
1359
+ data = _post_json(
1360
+ provider_label="Mistral",
1361
+ url=f"{base_url.rstrip('/')}/conversations",
1362
+ headers={
1363
+ "Authorization": f"Bearer {api_key}",
1364
+ "Content-Type": "application/json",
1365
+ "User-Agent": "alysis-code/0.1.0",
1366
+ },
1367
+ payload=payload,
1368
+ timeout_s=timeout_s,
1369
+ transport=transport,
1370
+ resolver=resolver,
1371
+ provider_key="mistral",
1372
+ provider_concurrency_caps=provider_concurrency_caps,
1373
+ provider_retry_settings=provider_retry_settings,
1374
+ operation="mistral_conversations_search",
1375
+ )
1376
+ outputs = data.get("outputs")
1377
+ if not isinstance(outputs, list):
1378
+ raise ProviderWebSearchError("Mistral conversations payload missing outputs")
1379
+ text_parts: list[str] = []
1380
+ raw_sources: list[dict[str, Any]] = []
1381
+ for output in outputs:
1382
+ if not isinstance(output, dict):
1383
+ continue
1384
+ content = output.get("content")
1385
+ if isinstance(content, str):
1386
+ text_parts.append(content)
1387
+ elif isinstance(content, list):
1388
+ for chunk in content:
1389
+ if not isinstance(chunk, dict):
1390
+ continue
1391
+ if isinstance(chunk.get("text"), str):
1392
+ text_parts.append(chunk["text"])
1393
+ if chunk.get("url") or chunk.get("uri") or chunk.get("web"):
1394
+ raw_sources.append(chunk)
1395
+ if output.get("url") or output.get("uri") or output.get("web"):
1396
+ raw_sources.append(output)
1397
+ answer = "".join(text_parts).strip()
1398
+ sources, sources_truncated = _dedupe_sources(
1399
+ [*raw_sources, *_sources_from_answer_urls(answer)],
1400
+ allowed_domains=allowed_domains,
1401
+ max_sources=max_results,
1402
+ )
1403
+ if not sources:
1404
+ raise ProviderWebSearchError("Mistral web_search did not return sources")
1405
+ return {
1406
+ "query": query,
1407
+ "answer": answer,
1408
+ "citations": _citations_from_sources(sources),
1409
+ "sources": sources,
1410
+ "queries": [query],
1411
+ "model": model,
1412
+ "backend": "mistral_conversations",
1413
+ "allowed_domains": allowed_domains or [],
1414
+ "external_web_access": True,
1415
+ "response_id": str(data.get("conversation_id") or "").strip() or None,
1416
+ "sources_truncated": sources_truncated,
1417
+ }
1418
+
1419
+
1420
+ def normalize_provider_base_url(base_url: str) -> str:
1421
+ split = urlsplit(str(base_url or "").strip())
1422
+ if not split.scheme or not split.netloc:
1423
+ return str(base_url or "").strip().rstrip("/")
1424
+ path = split.path.rstrip("/")
1425
+ return urlunsplit((split.scheme, split.netloc, path, "", "")).rstrip("/")
1426
+
1427
+
1428
+ def best_effort_search_provider_key(base_url: str, model: str) -> str:
1429
+ return best_effort_provider_key(base_url=base_url, model=model)