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,1209 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any, Protocol
6
+ from urllib.parse import urlsplit
7
+
8
+ from .config import (
9
+ ApiKeyResolution,
10
+ AppConfig,
11
+ resolve_api_key,
12
+ resolve_profile_api_key,
13
+ resolve_prompt_cache_key,
14
+ resolve_prompt_cache_retention,
15
+ resolve_web_search_adapter,
16
+ resolve_web_search_mode,
17
+ resolve_web_search_policy,
18
+ )
19
+ from .llm.cache_capabilities import (
20
+ EffectiveCacheCapability,
21
+ resolve_effective_cache_capability,
22
+ )
23
+ from .llm.cache_policy import (
24
+ ResolvedPromptCachePolicy,
25
+ prompt_cache_policy_diagnostic_rows,
26
+ resolve_prompt_cache_policy,
27
+ )
28
+ from .llm.gemini_interactions import (
29
+ gemini_interactions_disabled_message,
30
+ gemini_interactions_enabled,
31
+ )
32
+ from .llm.protocols import (
33
+ ANTHROPIC_MESSAGES_PROTOCOL,
34
+ GEMINI_GENERATE_CONTENT_PROTOCOL,
35
+ GEMINI_INTERACTIONS_PROTOCOL,
36
+ OPENAI_COMPAT_PROTOCOL,
37
+ OPENAI_RESPONSES_PROTOCOL,
38
+ get_provider_protocol_capabilities,
39
+ )
40
+ from .model_registry import resolve_model_provider_key
41
+ from .profile_presets import (
42
+ NATIVE_PROFILE_PROTOCOLS,
43
+ canonical_model_alias_for_preset,
44
+ find_preset_for_profile,
45
+ known_model_family,
46
+ model_known_incompatible_with_family,
47
+ profile_provider_family,
48
+ )
49
+ from .profiles import ProfileSpec, get_active_profile, resolve_effective_base_url
50
+ from .tools.web_search import resolve_web_search_runtime_status
51
+ from .web_search_adapters import (
52
+ AUTO_WEB_SEARCH_ADAPTER,
53
+ EXTERNAL_WEB_SEARCH_ADAPTERS,
54
+ NATIVE_WEB_SEARCH_ADAPTERS,
55
+ web_search_adapter_is_external,
56
+ web_search_adapter_is_native,
57
+ )
58
+
59
+
60
+ class _ChatClientFactory(Protocol):
61
+ def __call__(self, **kwargs: Any) -> Any: ...
62
+
63
+
64
+ class _WebSearchProbe(Protocol):
65
+ def __call__(self, **kwargs: Any) -> dict[str, Any]: ...
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ProviderDiagnostics:
70
+ profile_name: str
71
+ provider_key: str
72
+ protocol: str
73
+ protocol_kind: str
74
+ base_url_host: str
75
+ model: str
76
+ api_key_source: str
77
+ api_key_present: bool
78
+ web_search_policy: str
79
+ web_search_mode: str
80
+ web_search_configured_adapter: str
81
+ web_search_runtime_adapter: str
82
+ web_search_registration_ready: bool
83
+ web_search_backend_kind: str
84
+ streaming_supported: bool
85
+ stream_enabled: bool
86
+ unsupported_parameters: tuple[str, ...]
87
+ cache_capability: EffectiveCacheCapability
88
+ cache_policy: ResolvedPromptCachePolicy
89
+ quirks: tuple[str, ...]
90
+ notes: tuple[str, ...]
91
+ issues: tuple[str, ...]
92
+
93
+ def rows(self) -> tuple[tuple[str, str], ...]:
94
+ return (
95
+ ("profile", self.profile_name),
96
+ ("provider_key", self.provider_key),
97
+ ("protocol", self.protocol),
98
+ ("protocol_kind", self.protocol_kind),
99
+ ("base_url_host", self.base_url_host or "(missing)"),
100
+ ("model", self.model or "(missing)"),
101
+ ("api_key_source", self.api_key_source),
102
+ ("api_key_present", "yes" if self.api_key_present else "no"),
103
+ ("web_search_policy", self.web_search_policy),
104
+ ("web_search_mode", self.web_search_mode),
105
+ ("web_search_configured_adapter", self.web_search_configured_adapter),
106
+ ("web_search_runtime_adapter", self.web_search_runtime_adapter or "(none)"),
107
+ (
108
+ "web_search_ready",
109
+ "yes" if self.web_search_registration_ready else "no",
110
+ ),
111
+ ("web_search_backend_kind", self.web_search_backend_kind),
112
+ ("stream_enabled", "yes" if self.stream_enabled else "no"),
113
+ ("streaming_supported", "yes" if self.streaming_supported else "no"),
114
+ (
115
+ "unsupported_parameters",
116
+ ", ".join(self.unsupported_parameters) if self.unsupported_parameters else "none",
117
+ ),
118
+ *prompt_cache_policy_diagnostic_rows(self.cache_policy),
119
+ ("known_quirks", "; ".join(self.quirks) if self.quirks else "none"),
120
+ ("notes", "; ".join(self.notes) if self.notes else "none"),
121
+ ("issues", "; ".join(self.issues) if self.issues else "none"),
122
+ )
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class ProviderLiveValidation:
127
+ profile_name: str
128
+ provider_key: str
129
+ protocol: str
130
+ model: str
131
+ status: str
132
+ message: str
133
+
134
+ @property
135
+ def ok(self) -> bool:
136
+ return self.status == "passed"
137
+
138
+ def rows(self) -> tuple[tuple[str, str], ...]:
139
+ return (
140
+ ("profile", self.profile_name),
141
+ ("provider_key", self.provider_key),
142
+ ("protocol", self.protocol),
143
+ ("model", self.model or "(missing)"),
144
+ ("status", self.status),
145
+ ("message", self.message),
146
+ )
147
+
148
+
149
+ def build_provider_diagnostics(cfg: AppConfig) -> ProviderDiagnostics:
150
+ profile = get_active_profile(cfg)
151
+ base_url = resolve_effective_base_url(cfg=cfg, profile=profile)
152
+ model = str(profile.default_model or getattr(cfg, "model", "") or "").strip()
153
+ provider_key = (
154
+ resolve_model_provider_key(
155
+ cfg=cfg,
156
+ model_name=model,
157
+ base_url=base_url,
158
+ profile_name=profile.name,
159
+ )
160
+ or profile.name
161
+ )
162
+ api_key = _resolve_active_profile_api_key(cfg, profile.name)
163
+ protocol = str(profile.protocol or OPENAI_COMPAT_PROTOCOL).strip()
164
+ capabilities = get_provider_protocol_capabilities(
165
+ provider_key=provider_key,
166
+ protocol=protocol,
167
+ )
168
+ preset = find_preset_for_profile(profile)
169
+ cache_capability = resolve_effective_cache_capability(
170
+ provider_key=provider_key,
171
+ protocol=protocol,
172
+ model=model,
173
+ base_url=base_url,
174
+ transport_capabilities=capabilities,
175
+ preset_cache_capability=(preset.cache_capability if preset is not None else None),
176
+ profile_cache_capability=profile.cache_capability,
177
+ )
178
+ cache_policy = resolve_prompt_cache_policy(
179
+ cfg=cfg,
180
+ capabilities=capabilities,
181
+ provider_key=provider_key,
182
+ protocol=protocol,
183
+ model=model,
184
+ prompt_cache_key=resolve_prompt_cache_key(cfg),
185
+ prompt_cache_retention=resolve_prompt_cache_retention(cfg),
186
+ prompt_cache_namespace=None,
187
+ cache_capability=cache_capability,
188
+ )
189
+ # Mirrors _disable_unsupported_native_streaming: unknown provider capabilities
190
+ # assume streaming works; only a known supports_streaming=False entry disables it.
191
+ streaming_supported = capabilities.supports_streaming if capabilities is not None else True
192
+ protocol_kind = "native" if protocol in NATIVE_PROFILE_PROTOCOLS else "compatibility"
193
+
194
+ policy = resolve_web_search_policy(cfg)
195
+ mode = resolve_web_search_mode(cfg)
196
+ configured_adapter = resolve_web_search_adapter(cfg)
197
+ search_status = resolve_web_search_runtime_status(cfg=cfg, api_key=api_key.key)
198
+ runtime_adapter = search_status.provider or ""
199
+ backend_kind = _web_search_backend_kind(runtime_adapter, configured_adapter)
200
+ effective_search_ready = search_status.registration_ready and policy != "off"
201
+
202
+ notes = [_redact_url_text(note) for note in search_status.notes]
203
+ if policy == "off":
204
+ notes.append("web_search_policy=off prevents web_search tool registration.")
205
+ if capabilities is None:
206
+ notes.append(
207
+ "No bundled capability record for this provider/protocol; treating it as a custom profile."
208
+ )
209
+ notes.extend(capabilities.quirks if capabilities is not None else ())
210
+
211
+ issues = _provider_diagnostic_issues(
212
+ cfg=cfg,
213
+ profile=profile,
214
+ provider_key=provider_key,
215
+ protocol=protocol,
216
+ base_url=base_url,
217
+ api_key=api_key,
218
+ model=model,
219
+ streaming_supported=streaming_supported,
220
+ web_search_mode=mode,
221
+ configured_adapter=configured_adapter,
222
+ runtime_adapter=runtime_adapter,
223
+ search_ready=search_status.registration_ready,
224
+ search_notes=search_status.notes,
225
+ )
226
+
227
+ unsupported = capabilities.unsupported_parameters if capabilities is not None else ()
228
+ quirks = (
229
+ capabilities.quirks
230
+ if capabilities is not None
231
+ else ("Custom provider profile; validate gateway-specific behavior with a smoke run.",)
232
+ )
233
+ return ProviderDiagnostics(
234
+ profile_name=profile.name,
235
+ provider_key=str(provider_key or ""),
236
+ protocol=protocol,
237
+ protocol_kind=protocol_kind,
238
+ base_url_host=_base_url_host(base_url),
239
+ model=model,
240
+ api_key_source=_redacted_api_key_source(api_key),
241
+ api_key_present=bool(api_key.key),
242
+ web_search_policy=policy,
243
+ web_search_mode=mode,
244
+ web_search_configured_adapter=configured_adapter,
245
+ web_search_runtime_adapter=runtime_adapter,
246
+ web_search_registration_ready=effective_search_ready,
247
+ web_search_backend_kind=backend_kind,
248
+ streaming_supported=streaming_supported,
249
+ stream_enabled=bool(getattr(cfg, "stream", False)),
250
+ unsupported_parameters=unsupported,
251
+ cache_capability=cache_capability,
252
+ cache_policy=cache_policy,
253
+ quirks=quirks,
254
+ notes=tuple(_dedupe(notes)),
255
+ issues=tuple(_dedupe([_redact_url_text(issue) for issue in issues])),
256
+ )
257
+
258
+
259
+ def provider_diagnostic_warning_lines(cfg: AppConfig) -> tuple[str, ...]:
260
+ """Return redacted active-provider setup issues suitable for setup/config UX."""
261
+ return build_provider_diagnostics(cfg).issues
262
+
263
+
264
+ def validate_active_provider_live(
265
+ cfg: AppConfig,
266
+ *,
267
+ timeout_s: float = 15.0,
268
+ client_factory: _ChatClientFactory | None = None,
269
+ ) -> ProviderLiveValidation:
270
+ """Run a minimal active-profile text request after explicit user opt-in.
271
+
272
+ The caller is responsible for confirmation. This function keeps output redacted and classifies
273
+ common provider failures without making any live requests from unit tests unless a real factory
274
+ is supplied intentionally.
275
+ """
276
+ profile = get_active_profile(cfg)
277
+ base_url = resolve_effective_base_url(cfg=cfg, profile=profile)
278
+ model = str(profile.default_model or getattr(cfg, "model", "") or "").strip()
279
+ protocol = str(profile.protocol or OPENAI_COMPAT_PROTOCOL).strip()
280
+ provider_key = (
281
+ resolve_model_provider_key(
282
+ cfg=cfg,
283
+ model_name=model,
284
+ base_url=base_url,
285
+ profile_name=profile.name,
286
+ )
287
+ or profile_provider_family(profile)
288
+ or profile.name
289
+ )
290
+ if not model:
291
+ return ProviderLiveValidation(
292
+ profile_name=profile.name,
293
+ provider_key=str(provider_key or ""),
294
+ protocol=protocol,
295
+ model=model,
296
+ status="failed",
297
+ message="Active profile has no model configured. Set a model before live validation.",
298
+ )
299
+ api_key = _resolve_active_profile_api_key(cfg, profile.name)
300
+ if not api_key.key and not profile.auth_provider:
301
+ return ProviderLiveValidation(
302
+ profile_name=profile.name,
303
+ provider_key=str(provider_key or ""),
304
+ protocol=protocol,
305
+ model=model,
306
+ status="failed",
307
+ message=(
308
+ "Active profile has no API key. Export the profile API key environment variable "
309
+ "or store a profile key before live validation."
310
+ ),
311
+ )
312
+ try:
313
+ if client_factory is None:
314
+ from .llm.factory import make_llm_client
315
+
316
+ client_factory = make_llm_client
317
+ client = client_factory(
318
+ cfg=cfg,
319
+ api_key=api_key.key or "",
320
+ model=model,
321
+ timeout_s=timeout_s,
322
+ temperature=0.0,
323
+ profile=profile,
324
+ )
325
+ if getattr(client, "supports_tool_calling", True) is False:
326
+ return ProviderLiveValidation(
327
+ profile_name=profile.name,
328
+ provider_key=str(provider_key or ""),
329
+ protocol=protocol,
330
+ model=model,
331
+ status="failed",
332
+ message=(
333
+ "Active profile chat client does not support tool calling; choose a "
334
+ "tool-capable profile before using coding-agent modes."
335
+ ),
336
+ )
337
+ response = client.chat(
338
+ messages=[{"role": "user", "content": "Reply with only: ok"}],
339
+ tools=_live_validation_tool_schema(),
340
+ max_tokens=20,
341
+ )
342
+ except Exception as exc: # pragma: no cover - exercised with fake clients in tests.
343
+ return ProviderLiveValidation(
344
+ profile_name=profile.name,
345
+ provider_key=str(provider_key or ""),
346
+ protocol=protocol,
347
+ model=model,
348
+ status="failed",
349
+ message=_classify_live_validation_error(str(exc), model=model),
350
+ )
351
+ if getattr(
352
+ client, "supports_tool_calling", True
353
+ ) is False or _response_reports_tool_calling_disabled(response):
354
+ return ProviderLiveValidation(
355
+ profile_name=profile.name,
356
+ provider_key=str(provider_key or ""),
357
+ protocol=protocol,
358
+ model=model,
359
+ status="failed",
360
+ message=(
361
+ "Provider accepted chat but rejected tool calling; choose a tool-capable "
362
+ "model/profile before using coding-agent modes."
363
+ ),
364
+ )
365
+ if str(getattr(response, "content", "") or "").strip() or getattr(response, "tool_calls", None):
366
+ return ProviderLiveValidation(
367
+ profile_name=profile.name,
368
+ provider_key=str(provider_key or ""),
369
+ protocol=protocol,
370
+ model=model,
371
+ status="passed",
372
+ message="Minimal tool-capability request completed successfully.",
373
+ )
374
+ return ProviderLiveValidation(
375
+ profile_name=profile.name,
376
+ provider_key=str(provider_key or ""),
377
+ protocol=protocol,
378
+ model=model,
379
+ status="failed",
380
+ message="Provider returned an empty response to the live validation prompt.",
381
+ )
382
+
383
+
384
+ @dataclass(frozen=True)
385
+ class WebSearchLiveValidation:
386
+ """Result of exercising the enabled web_search tool end-to-end once.
387
+
388
+ Static readiness checks only prove the configuration parses; they cannot
389
+ catch a gateway that rejects real search requests. This probe issues one
390
+ minimal call through the resolved adapter and reports what actually served
391
+ it, so "ready" always means "a real call worked".
392
+ """
393
+
394
+ mode: str
395
+ configured_adapter: str
396
+ resolved_adapter: str
397
+ backend_used: str
398
+ fallback_used: bool
399
+ status: str
400
+ message: str
401
+
402
+ @property
403
+ def ok(self) -> bool:
404
+ return self.status == "passed"
405
+
406
+ def rows(self) -> tuple[tuple[str, str], ...]:
407
+ return (
408
+ ("web_search_mode", self.mode),
409
+ ("configured_adapter", self.configured_adapter),
410
+ ("resolved_adapter", self.resolved_adapter or "(none)"),
411
+ ("backend_used", self.backend_used or "(none)"),
412
+ ("fallback_used", "yes" if self.fallback_used else "no"),
413
+ ("status", self.status),
414
+ ("message", self.message),
415
+ )
416
+
417
+
418
+ _WEB_SEARCH_PROBE_QUERY = "current UTC date"
419
+
420
+
421
+ def _cfg_with_web_search_probe_overrides(
422
+ cfg: AppConfig,
423
+ *,
424
+ timeout_s: float,
425
+ adapter: str | None = None,
426
+ ) -> AppConfig:
427
+ update: dict[str, Any] = {"web_search_timeout_s": float(timeout_s)}
428
+ if adapter:
429
+ update["web_search_adapter"] = adapter
430
+ return cfg.model_copy(update=update)
431
+
432
+
433
+ def validate_web_search_live(
434
+ cfg: AppConfig,
435
+ *,
436
+ timeout_s: float = 15.0,
437
+ search_fn: _WebSearchProbe | None = None,
438
+ ) -> WebSearchLiveValidation:
439
+ """Issue one real, minimal web_search call through the resolved adapter.
440
+
441
+ The caller is responsible for confirmation (the probe makes live requests
442
+ and may incur provider cost). Reports the adapter that was chosen, whether
443
+ an external fallback served the call, and the failing adapter's error
444
+ verbatim (URL-redacted) when something breaks.
445
+ """
446
+
447
+ mode = resolve_web_search_mode(cfg)
448
+ configured_adapter = resolve_web_search_adapter(cfg)
449
+ policy = resolve_web_search_policy(cfg)
450
+ profile = get_active_profile(cfg)
451
+ api_key = _resolve_active_profile_api_key(cfg, profile.name)
452
+ search_status = resolve_web_search_runtime_status(cfg=cfg, api_key=api_key.key)
453
+ resolved_adapter = search_status.provider or ""
454
+ if policy == "off" or mode == "off" or not search_status.registration_ready:
455
+ return WebSearchLiveValidation(
456
+ mode=mode,
457
+ configured_adapter=configured_adapter,
458
+ resolved_adapter=resolved_adapter,
459
+ backend_used="",
460
+ fallback_used=False,
461
+ status="skipped",
462
+ message=(
463
+ "web_search is not enabled or not configured: "
464
+ f"{_redact_url_text(search_status.summary)}"
465
+ ),
466
+ )
467
+ if search_fn is None:
468
+ from .tools.web_search import web_search
469
+
470
+ search_fn = web_search
471
+ # Mirror web_search()'s own override resolution (env adapter/provider
472
+ # overrides included), not just the config field: fallback only happens
473
+ # when no explicit adapter override pins the backend.
474
+ try:
475
+ from .tools.web_search import _resolve_provider_override
476
+
477
+ provider_override, _override_error = _resolve_provider_override(cfg=cfg, strict=False)
478
+ except Exception: # noqa: BLE001 - treat unknown override state as pinned.
479
+ provider_override = resolved_adapter
480
+
481
+ def _probe(probe_cfg: AppConfig) -> dict[str, Any]:
482
+ return search_fn(
483
+ query=_WEB_SEARCH_PROBE_QUERY,
484
+ cfg=probe_cfg,
485
+ api_key=api_key.key,
486
+ max_sources=1,
487
+ )
488
+
489
+ # Pin the resolved adapter first so a hard failure surfaces its own error
490
+ # instead of being silently masked by an in-call fallback. Env adapter
491
+ # overrides can defeat the pin, so the actually-used backend is compared
492
+ # against the resolved adapter instead of being assumed.
493
+ try:
494
+ result = _probe(
495
+ _cfg_with_web_search_probe_overrides(
496
+ cfg,
497
+ timeout_s=timeout_s,
498
+ adapter=resolved_adapter,
499
+ )
500
+ )
501
+ except Exception as exc: # noqa: BLE001 - report any probe failure verbatim.
502
+ resolved_error = _redact_url_text(str(exc))
503
+ else:
504
+ backend_used = str(result.get("backend_adapter") or resolved_adapter)
505
+ fallback_used = bool(backend_used) and backend_used != resolved_adapter
506
+ return WebSearchLiveValidation(
507
+ mode=mode,
508
+ configured_adapter=configured_adapter,
509
+ resolved_adapter=resolved_adapter,
510
+ backend_used=backend_used,
511
+ fallback_used=fallback_used,
512
+ status="passed",
513
+ message=(
514
+ f"web_search served {int(result.get('source_count') or 0)} source(s) "
515
+ f"via {backend_used}."
516
+ + (
517
+ f" The resolved adapter {resolved_adapter} did not serve the probe; "
518
+ "an in-call fallback did."
519
+ if fallback_used
520
+ else ""
521
+ )
522
+ ),
523
+ )
524
+
525
+ can_fall_back = mode == "auto" and provider_override is None
526
+ if can_fall_back:
527
+ try:
528
+ fallback_result = _probe(_cfg_with_web_search_probe_overrides(cfg, timeout_s=timeout_s))
529
+ except Exception as exc: # noqa: BLE001 - combined failure is the report.
530
+ return WebSearchLiveValidation(
531
+ mode=mode,
532
+ configured_adapter=configured_adapter,
533
+ resolved_adapter=resolved_adapter,
534
+ backend_used="",
535
+ fallback_used=False,
536
+ status="failed",
537
+ message=(
538
+ f"{resolved_adapter} failed: {resolved_error}; "
539
+ f"fallback backends also failed: {_redact_url_text(str(exc))}"
540
+ ),
541
+ )
542
+ backend_used = str(fallback_result.get("backend_adapter") or "")
543
+ fallback_used = bool(backend_used) and backend_used != resolved_adapter
544
+ return WebSearchLiveValidation(
545
+ mode=mode,
546
+ configured_adapter=configured_adapter,
547
+ resolved_adapter=resolved_adapter,
548
+ backend_used=backend_used,
549
+ fallback_used=fallback_used,
550
+ status="passed",
551
+ message=(
552
+ f"{resolved_adapter} failed ({resolved_error}); "
553
+ f"fallback {backend_used} served the probe."
554
+ if fallback_used
555
+ else (
556
+ f"web_search served the probe via {backend_used} after an "
557
+ f"initial {resolved_adapter} failure ({resolved_error})."
558
+ )
559
+ ),
560
+ )
561
+ return WebSearchLiveValidation(
562
+ mode=mode,
563
+ configured_adapter=configured_adapter,
564
+ resolved_adapter=resolved_adapter,
565
+ backend_used="",
566
+ fallback_used=False,
567
+ status="failed",
568
+ message=f"{resolved_adapter} failed: {resolved_error}",
569
+ )
570
+
571
+
572
+ @dataclass(frozen=True)
573
+ class ReasoningSuppressionReport:
574
+ """Whether a reasoning-off request actually suppressed provider reasoning.
575
+
576
+ Latency-sensitive internal calls are issued with enable_thinking=False,
577
+ but some gateways ignore the flag and burn reasoning tokens anyway. This
578
+ makes the per-provider behavior visible instead of assumed.
579
+ """
580
+
581
+ profile_name: str
582
+ provider_key: str
583
+ protocol: str
584
+ model: str
585
+ outcome: str
586
+ reasoning_tokens: int | None
587
+ message: str
588
+
589
+ def rows(self) -> tuple[tuple[str, str], ...]:
590
+ return (
591
+ ("profile", self.profile_name),
592
+ ("provider_key", self.provider_key),
593
+ ("protocol", self.protocol),
594
+ ("model", self.model or "(missing)"),
595
+ ("reasoning_off_outcome", self.outcome),
596
+ (
597
+ "reasoning_tokens",
598
+ "(not reported)" if self.reasoning_tokens is None else str(self.reasoning_tokens),
599
+ ),
600
+ ("message", self.message),
601
+ )
602
+
603
+
604
+ def probe_reasoning_suppression_live(
605
+ cfg: AppConfig,
606
+ *,
607
+ timeout_s: float = 15.0,
608
+ client_factory: _ChatClientFactory | None = None,
609
+ ) -> ReasoningSuppressionReport:
610
+ """Send one reasoning-off request and report whether reasoning was suppressed.
611
+
612
+ Mirrors how internal latency-sensitive clients are built (enable_thinking
613
+ False, empty reasoning effort) and inspects usage reasoning-token details
614
+ on the response. The caller is responsible for confirmation.
615
+ """
616
+
617
+ profile = get_active_profile(cfg)
618
+ base_url = resolve_effective_base_url(cfg=cfg, profile=profile)
619
+ protocol = str(profile.protocol or OPENAI_COMPAT_PROTOCOL).strip()
620
+ model = str(profile.default_model or getattr(cfg, "model", "") or "").strip()
621
+ provider_key = (
622
+ resolve_model_provider_key(
623
+ cfg=cfg,
624
+ model_name=model,
625
+ base_url=base_url,
626
+ profile_name=profile.name,
627
+ )
628
+ or profile_provider_family(profile)
629
+ or profile.name
630
+ )
631
+
632
+ def _report(outcome: str, message: str, reasoning_tokens: int | None = None):
633
+ return ReasoningSuppressionReport(
634
+ profile_name=profile.name,
635
+ provider_key=str(provider_key or ""),
636
+ protocol=protocol,
637
+ model=model,
638
+ outcome=outcome,
639
+ reasoning_tokens=reasoning_tokens,
640
+ message=message,
641
+ )
642
+
643
+ if not model:
644
+ return _report("skipped", "Active profile has no model configured.")
645
+ api_key = _resolve_active_profile_api_key(cfg, profile.name)
646
+ if not api_key.key and not profile.auth_provider:
647
+ return _report("skipped", "Active profile has no API key.")
648
+ try:
649
+ if client_factory is None:
650
+ from .llm.factory import make_llm_client
651
+
652
+ client_factory = make_llm_client
653
+ client = client_factory(
654
+ cfg=cfg,
655
+ api_key=api_key.key or "",
656
+ model=model,
657
+ timeout_s=timeout_s,
658
+ temperature=0.0,
659
+ profile=profile,
660
+ enable_thinking=False,
661
+ reasoning_effort="",
662
+ )
663
+ response = client.chat(
664
+ messages=[{"role": "user", "content": "Reply with only: ok"}],
665
+ max_tokens=20,
666
+ )
667
+ except Exception as exc: # noqa: BLE001 - probe failures become the report.
668
+ return _report("failed", _classify_live_validation_error(str(exc), model=model))
669
+
670
+ usage = getattr(response, "usage", None)
671
+ reasoning_tokens = getattr(usage, "reasoning_tokens", None)
672
+ visible_reasoning = bool(getattr(response, "reasoning", ()) or ())
673
+ if isinstance(reasoning_tokens, int) and reasoning_tokens > 0:
674
+ return _report(
675
+ "ignored",
676
+ (
677
+ f"Provider returned {reasoning_tokens} reasoning token(s) despite "
678
+ "enable_thinking=false; latency-sensitive internal calls (e.g. routing) "
679
+ "will pay reasoning latency on this provider."
680
+ ),
681
+ reasoning_tokens,
682
+ )
683
+ if visible_reasoning:
684
+ return _report(
685
+ "ignored",
686
+ (
687
+ "Provider returned a reasoning trace despite enable_thinking=false; "
688
+ "latency-sensitive internal calls (e.g. routing) will pay reasoning "
689
+ "latency on this provider."
690
+ ),
691
+ reasoning_tokens if isinstance(reasoning_tokens, int) else None,
692
+ )
693
+ if isinstance(reasoning_tokens, int):
694
+ return _report(
695
+ "suppressed",
696
+ "Provider honored enable_thinking=false (0 reasoning tokens).",
697
+ reasoning_tokens,
698
+ )
699
+ return _report(
700
+ "not_reported",
701
+ (
702
+ "Provider does not report reasoning token details; suppression cannot be "
703
+ "verified from usage data."
704
+ ),
705
+ )
706
+
707
+
708
+ def _live_validation_tool_schema() -> list[dict[str, Any]]:
709
+ return [
710
+ {
711
+ "type": "function",
712
+ "function": {
713
+ "name": "diagnostic_echo",
714
+ "description": "Return a short diagnostic acknowledgement.",
715
+ "parameters": {
716
+ "type": "object",
717
+ "properties": {},
718
+ "additionalProperties": False,
719
+ },
720
+ },
721
+ }
722
+ ]
723
+
724
+
725
+ def _response_reports_tool_calling_disabled(response: Any) -> bool:
726
+ metadata = getattr(response, "provider_metadata", None)
727
+ if not isinstance(metadata, dict):
728
+ return False
729
+ transport = metadata.get("transport")
730
+ if not isinstance(transport, dict):
731
+ return False
732
+ if transport.get("tools_retry_used") is True:
733
+ return True
734
+ return str(transport.get("tools_omit_reason") or "") in {
735
+ "provider_rejected_tool_calling",
736
+ "cached_provider_rejection",
737
+ }
738
+
739
+
740
+ def _provider_diagnostic_issues(
741
+ *,
742
+ cfg: AppConfig,
743
+ profile: ProfileSpec,
744
+ provider_key: str,
745
+ protocol: str,
746
+ base_url: str,
747
+ api_key: ApiKeyResolution,
748
+ model: str,
749
+ streaming_supported: bool,
750
+ web_search_mode: str,
751
+ configured_adapter: str,
752
+ runtime_adapter: str,
753
+ search_ready: bool,
754
+ search_notes: tuple[str, ...],
755
+ ) -> list[str]:
756
+ issues: list[str] = []
757
+ missing_key_issue = _missing_api_key_issue(
758
+ profile=profile,
759
+ provider_key=provider_key,
760
+ base_url=base_url,
761
+ api_key=api_key,
762
+ )
763
+ if missing_key_issue:
764
+ issues.append(missing_key_issue)
765
+ issues.extend(
766
+ _provider_model_issues(
767
+ profile=profile,
768
+ protocol=protocol,
769
+ model=model,
770
+ stream_enabled=bool(getattr(cfg, "stream", False)),
771
+ web_search_mode=web_search_mode,
772
+ )
773
+ )
774
+ if bool(getattr(cfg, "stream", False)) and not streaming_supported:
775
+ issues.append(
776
+ f"stream=true is configured but protocol={protocol} does not support streaming yet; "
777
+ "Alysis Code will disable streaming for native chat runs until that protocol's "
778
+ "streaming path is implemented. Suggested fix: `alysis config set stream false`."
779
+ )
780
+ if protocol == GEMINI_INTERACTIONS_PROTOCOL and not gemini_interactions_enabled(cfg):
781
+ issues.append(gemini_interactions_disabled_message())
782
+ issues.extend(
783
+ _provider_protocol_mismatch_issues(
784
+ profile=profile,
785
+ protocol=protocol,
786
+ base_url=base_url,
787
+ )
788
+ )
789
+
790
+ if web_search_mode == "native" and configured_adapter in EXTERNAL_WEB_SEARCH_ADAPTERS:
791
+ issues.append(
792
+ f"web_search_mode=native is incompatible with web_search_adapter={configured_adapter}; "
793
+ "native mode never uses Tavily or other external search providers. Suggested fix: "
794
+ "`alysis config set web_search_mode external` or choose a provider-hosted "
795
+ "web_search_adapter."
796
+ )
797
+ if web_search_mode == "external" and configured_adapter in NATIVE_WEB_SEARCH_ADAPTERS:
798
+ issues.append(
799
+ f"web_search_mode=external is incompatible with web_search_adapter={configured_adapter}; "
800
+ "external mode does not use provider-hosted search adapters. Suggested fix: "
801
+ "`alysis config set web_search_mode native` or choose an external adapter such as Tavily."
802
+ )
803
+ if web_search_mode in {"native", "external"} and not search_ready:
804
+ detail = search_notes[0] if search_notes else "no ready web_search adapter"
805
+ issues.append(f"web_search_mode={web_search_mode} is not ready: {detail}")
806
+
807
+ if (
808
+ web_search_mode == "external"
809
+ and not search_ready
810
+ and (
811
+ configured_adapter == AUTO_WEB_SEARCH_ADAPTER
812
+ or web_search_adapter_is_external(configured_adapter)
813
+ )
814
+ ):
815
+ issues.append(
816
+ "external web_search needs ALYSIS_WEB_SEARCH_API_KEY or TAVILY_API_KEY for "
817
+ "the Tavily adapter, or the 'ddgs' package for the keyless backend. Suggested "
818
+ "fix: export one search key, `pip install ddgs`, or run "
819
+ "`alysis config set web_search_mode auto`."
820
+ )
821
+
822
+ is_custom_openai_compat = protocol == OPENAI_COMPAT_PROTOCOL and provider_key not in {
823
+ "openai",
824
+ "anthropic",
825
+ "gemini",
826
+ "qwen",
827
+ "openrouter",
828
+ }
829
+ if web_search_mode == "native" and is_custom_openai_compat and not search_ready:
830
+ issues.append(
831
+ "custom OpenAI-compatible profiles do not automatically provide native hosted "
832
+ "search; use a native provider preset, configure a matching native web_search_adapter, "
833
+ "or switch web_search_mode to auto/external."
834
+ )
835
+
836
+ if (
837
+ runtime_adapter
838
+ and web_search_mode == "native"
839
+ and not web_search_adapter_is_native(runtime_adapter)
840
+ ):
841
+ issues.append(f"native web_search selected non-native adapter {runtime_adapter}.")
842
+ if (
843
+ runtime_adapter
844
+ and web_search_mode == "external"
845
+ and not web_search_adapter_is_external(runtime_adapter)
846
+ ):
847
+ issues.append(f"external web_search selected non-external adapter {runtime_adapter}.")
848
+
849
+ return issues
850
+
851
+
852
+ def _provider_model_issues(
853
+ *,
854
+ profile: ProfileSpec,
855
+ protocol: str,
856
+ model: str,
857
+ stream_enabled: bool,
858
+ web_search_mode: str,
859
+ ) -> list[str]:
860
+ issues: list[str] = []
861
+ normalized_model = str(model or "").strip()
862
+ if not normalized_model:
863
+ issues.append(
864
+ "Model is empty for the active profile. Suggested fix: `alysis config set model "
865
+ "<provider-model-id>` or choose a model in `/config`."
866
+ )
867
+ return issues
868
+
869
+ family = profile_provider_family(profile)
870
+ model_family = known_model_family(normalized_model)
871
+ if family is not None and model_known_incompatible_with_family(normalized_model, family):
872
+ issues.append(
873
+ f"Model {normalized_model!r} looks like a {model_family} model, but the active "
874
+ f"profile/protocol is for {family}. Suggested fix: choose a {family} model or "
875
+ "convert the profile to the matching provider."
876
+ )
877
+
878
+ preset = find_preset_for_profile(profile)
879
+ if preset is not None:
880
+ canonical = canonical_model_alias_for_preset(preset, normalized_model)
881
+ if canonical and canonical != normalized_model:
882
+ issues.append(
883
+ f"Model {normalized_model!r} is a known renamed/deprecated alias for preset "
884
+ f"{preset.key!r}; suggested model: {canonical!r}."
885
+ )
886
+
887
+ if _looks_like_unstable_model_alias(normalized_model):
888
+ issues.append(
889
+ f"Model {normalized_model!r} looks like a preview/latest alias. Availability can vary "
890
+ "by account, region, and provider rollout; use a dated/stable model or run "
891
+ "`alysis doctor providers --live --yes` to validate this profile."
892
+ )
893
+
894
+ if web_search_mode == "native" and _likely_non_chat_or_search_model(normalized_model):
895
+ issues.append(
896
+ f"Native web_search is enabled, but model {normalized_model!r} looks like a "
897
+ "non-chat, audio, image, embedding, or realtime model that is unlikely to support "
898
+ "provider-hosted search. Choose a chat model or set `web_search_mode` to off/auto."
899
+ )
900
+
901
+ if (
902
+ stream_enabled
903
+ and protocol != OPENAI_COMPAT_PROTOCOL
904
+ and _likely_non_chat_stream_model(normalized_model)
905
+ ):
906
+ issues.append(
907
+ f"stream=true is enabled, but model {normalized_model!r} looks like a specialized "
908
+ "model that may not support this native streaming protocol. Validate with "
909
+ "`alysis doctor providers --live --yes` or choose a standard chat model."
910
+ )
911
+
912
+ return issues
913
+
914
+
915
+ def _resolve_active_profile_api_key(cfg: AppConfig, profile_name: str) -> ApiKeyResolution:
916
+ profile_key = resolve_profile_api_key(cfg, profile_name)
917
+ if profile_key.key:
918
+ return profile_key
919
+ return resolve_api_key(cfg, profile_name=profile_name)
920
+
921
+
922
+ def _provider_protocol_mismatch_issues(
923
+ *,
924
+ profile: ProfileSpec,
925
+ protocol: str,
926
+ base_url: str,
927
+ ) -> list[str]:
928
+ issues: list[str] = []
929
+ host = _base_url_host(base_url)
930
+ path = _base_url_path(base_url)
931
+ profile_name = profile.name
932
+ plain_anthropic_legacy = profile_name == "anthropic" and protocol == OPENAI_COMPAT_PROTOCOL
933
+ plain_gemini_legacy = profile_name == "gemini" and protocol == OPENAI_COMPAT_PROTOCOL
934
+ explicit_first_party_compat = profile_name in {"anthropic-compat", "gemini-compat"}
935
+
936
+ if profile_name.endswith("-native") and protocol == OPENAI_COMPAT_PROTOCOL:
937
+ issues.append(
938
+ f"Profile {profile_name!r} is named like a native profile but uses "
939
+ "protocol=openai_compat. Suggested fix: "
940
+ f"`alysis profile convert {profile_name} --to native`."
941
+ )
942
+
943
+ if plain_anthropic_legacy:
944
+ issues.append(
945
+ "Profile 'anthropic' uses legacy compatibility semantics. Plain 'anthropic' now "
946
+ "means native Anthropic Messages for new profiles. Convert explicitly with "
947
+ "`alysis profile convert anthropic --to native`, or keep an explicit legacy "
948
+ "fallback profile such as `anthropic-compat`."
949
+ )
950
+
951
+ if plain_gemini_legacy:
952
+ issues.append(
953
+ "Profile 'gemini' uses legacy compatibility semantics. Plain 'gemini' now means "
954
+ "native Gemini GenerateContent for new profiles. Convert explicitly with "
955
+ "`alysis profile convert gemini --to native`, or keep an explicit legacy fallback "
956
+ "profile such as `gemini-compat`."
957
+ )
958
+
959
+ if (
960
+ protocol == OPENAI_RESPONSES_PROTOCOL
961
+ and host != "api.openai.com"
962
+ and not profile.auth_provider
963
+ ):
964
+ issues.append(
965
+ "protocol=openai_responses is intended for the OpenAI Responses API at "
966
+ "api.openai.com. Suggested fix: "
967
+ f"`alysis profile convert {profile_name} --to compatibility` or update the base URL."
968
+ )
969
+
970
+ if protocol == ANTHROPIC_MESSAGES_PROTOCOL and host != "api.anthropic.com":
971
+ issues.append(
972
+ "protocol=anthropic_messages is intended for the Anthropic first-party Messages API. "
973
+ f"Suggested fix: `alysis profile convert {profile_name} --to compatibility` "
974
+ "or update the base URL."
975
+ )
976
+
977
+ if (
978
+ protocol == OPENAI_COMPAT_PROTOCOL
979
+ and host == "api.anthropic.com"
980
+ and not explicit_first_party_compat
981
+ ):
982
+ issues.append(
983
+ "This looks like the Anthropic first-party API using compatibility mode. "
984
+ "Recommended fix for new first-party Claude profiles: "
985
+ f"`alysis profile convert {profile_name} --to native`."
986
+ )
987
+
988
+ if protocol == GEMINI_GENERATE_CONTENT_PROTOCOL and "/openai" in path:
989
+ issues.append(
990
+ "This looks like the Gemini OpenAI-compatible endpoint, but the profile uses "
991
+ "native Gemini GenerateContent. Suggested fix: "
992
+ f"`alysis profile convert {profile_name} --to compatibility`."
993
+ )
994
+
995
+ if protocol == GEMINI_INTERACTIONS_PROTOCOL and (
996
+ host != "generativelanguage.googleapis.com" or "/openai" in path
997
+ ):
998
+ issues.append(
999
+ "protocol=gemini_interactions is an experimental Gemini first-party protocol for "
1000
+ "generativelanguage.googleapis.com/v1beta/interactions, not OpenAI-compatible "
1001
+ "gateways. Suggested fix: use the stable `gemini` preset or "
1002
+ f"`alysis profile convert {profile_name} --to compatibility`."
1003
+ )
1004
+
1005
+ if (
1006
+ protocol == OPENAI_COMPAT_PROTOCOL
1007
+ and host == "generativelanguage.googleapis.com"
1008
+ and "/openai" not in path
1009
+ and not explicit_first_party_compat
1010
+ ):
1011
+ issues.append(
1012
+ "This looks like the Gemini native API using compatibility mode. Recommended fix "
1013
+ f"for new first-party Gemini profiles: `alysis profile convert {profile_name} --to native`."
1014
+ )
1015
+
1016
+ return issues
1017
+
1018
+
1019
+ def _looks_like_unstable_model_alias(model: str) -> bool:
1020
+ normalized = model.strip().lower()
1021
+ return (
1022
+ normalized.endswith("-latest")
1023
+ or "-latest-" in normalized
1024
+ or normalized.endswith("-preview")
1025
+ or "-preview-" in normalized
1026
+ )
1027
+
1028
+
1029
+ def _likely_non_chat_or_search_model(model: str) -> bool:
1030
+ normalized = model.strip().lower()
1031
+ markers = (
1032
+ "audio",
1033
+ "embedding",
1034
+ "embed",
1035
+ "image",
1036
+ "realtime",
1037
+ "speech",
1038
+ "tts",
1039
+ "transcribe",
1040
+ "whisper",
1041
+ )
1042
+ return any(marker in normalized for marker in markers)
1043
+
1044
+
1045
+ def _likely_non_chat_stream_model(model: str) -> bool:
1046
+ normalized = model.strip().lower()
1047
+ markers = (
1048
+ "audio",
1049
+ "embedding",
1050
+ "embed",
1051
+ "image",
1052
+ "live",
1053
+ "realtime",
1054
+ "speech",
1055
+ "tts",
1056
+ "transcribe",
1057
+ "whisper",
1058
+ )
1059
+ return any(marker in normalized for marker in markers)
1060
+
1061
+
1062
+ def _classify_live_validation_error(error_text: str, *, model: str) -> str:
1063
+ text = _redact_url_text(error_text)
1064
+ lowered = text.lower()
1065
+ if "401" in lowered or "unauthorized" in lowered or "invalid api key" in lowered:
1066
+ return "Provider rejected the API key during live validation. Check the active profile key."
1067
+ if "403" in lowered or "permission" in lowered or "forbidden" in lowered:
1068
+ return (
1069
+ "Provider denied live validation for this key/account. Check account permissions, "
1070
+ "region, billing, and model access."
1071
+ )
1072
+ if (
1073
+ "404" in lowered
1074
+ or "not found" in lowered
1075
+ or "model_not_found" in lowered
1076
+ or "does not exist" in lowered
1077
+ or "not supported" in lowered
1078
+ ):
1079
+ return (
1080
+ f"Provider could not use model {model!r}. Model availability can vary by account, "
1081
+ "region, and rollout; choose another model or set the relevant smoke/model override."
1082
+ )
1083
+ if "429" in lowered or "rate limit" in lowered or "quota" in lowered:
1084
+ return "Provider rate limit or quota blocked live validation. Retry later or use another model."
1085
+ if "timeout" in lowered or "timed out" in lowered:
1086
+ return "Live validation timed out. Retry later, increase timeout, or choose a faster model."
1087
+ return f"Live validation failed: {text}"
1088
+
1089
+
1090
+ def _missing_api_key_issue(
1091
+ *,
1092
+ profile: ProfileSpec,
1093
+ provider_key: str,
1094
+ base_url: str,
1095
+ api_key: ApiKeyResolution,
1096
+ ) -> str | None:
1097
+ if profile.auth_provider:
1098
+ return None
1099
+ if api_key.key:
1100
+ return None
1101
+ if not _profile_requires_api_key(profile=profile, provider_key=provider_key, base_url=base_url):
1102
+ return None
1103
+
1104
+ env_name = str(profile.api_key_env or "").strip()
1105
+ if env_name:
1106
+ return (
1107
+ f"API key is missing for active profile {profile.name!r}. Suggested fix: "
1108
+ f"export {env_name} or run `alysis config set-api-key`."
1109
+ )
1110
+ return (
1111
+ f"API key is missing for active profile {profile.name!r}. Suggested fix: "
1112
+ "run `alysis config set-api-key` or set a profile-specific api_key_env."
1113
+ )
1114
+
1115
+
1116
+ def _profile_requires_api_key(
1117
+ *,
1118
+ profile: ProfileSpec,
1119
+ provider_key: str,
1120
+ base_url: str,
1121
+ ) -> bool:
1122
+ if str(profile.api_key_env or "").strip():
1123
+ return True
1124
+ host = _base_url_host(base_url)
1125
+ if not host:
1126
+ return False
1127
+ if _is_local_host(host):
1128
+ return False
1129
+ if provider_key in {
1130
+ "openai",
1131
+ "anthropic",
1132
+ "gemini",
1133
+ "qwen",
1134
+ "openrouter",
1135
+ "deepseek",
1136
+ "mistral",
1137
+ "xai",
1138
+ }:
1139
+ return True
1140
+ return "." in host
1141
+
1142
+
1143
+ def _is_local_host(host: str) -> bool:
1144
+ normalized = host.strip("[]").lower()
1145
+ return normalized in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} or normalized.endswith(
1146
+ ".localhost"
1147
+ )
1148
+
1149
+
1150
+ def _web_search_backend_kind(runtime_adapter: str, configured_adapter: str) -> str:
1151
+ adapter = runtime_adapter or (
1152
+ "" if configured_adapter == AUTO_WEB_SEARCH_ADAPTER else configured_adapter
1153
+ )
1154
+ if not adapter:
1155
+ return "unavailable"
1156
+ if web_search_adapter_is_native(adapter):
1157
+ return "native/provider-hosted"
1158
+ if web_search_adapter_is_external(adapter):
1159
+ return "external"
1160
+ return "unknown"
1161
+
1162
+
1163
+ def _base_url_host(base_url: str) -> str:
1164
+ try:
1165
+ return (urlsplit(base_url).hostname or "").rstrip(".").lower()
1166
+ except ValueError:
1167
+ return ""
1168
+
1169
+
1170
+ def _base_url_path(base_url: str) -> str:
1171
+ try:
1172
+ return (urlsplit(base_url).path or "").rstrip("/").lower()
1173
+ except ValueError:
1174
+ return ""
1175
+
1176
+
1177
+ _URL_RE = re.compile(r"https?://[^\s),;]+")
1178
+
1179
+
1180
+ def _redact_url_text(value: str) -> str:
1181
+ def _replace(match: re.Match[str]) -> str:
1182
+ return _base_url_host(match.group(0)) or "(url)"
1183
+
1184
+ return _URL_RE.sub(_replace, str(value or ""))
1185
+
1186
+
1187
+ def _redacted_api_key_source(api_key: ApiKeyResolution) -> str:
1188
+ source = str(api_key.source or "").strip()
1189
+ if not api_key.key:
1190
+ return "missing"
1191
+ if source.startswith("env:"):
1192
+ return f"env:{source.removeprefix('env:')} (redacted)"
1193
+ if source.startswith("stored:profile="):
1194
+ return f"stored profile:{source.removeprefix('stored:profile=')} (redacted)"
1195
+ if source in {"stored", "stored:legacy"}:
1196
+ return "stored legacy key (redacted)"
1197
+ return "configured (redacted)"
1198
+
1199
+
1200
+ def _dedupe(values: list[str] | tuple[str, ...]) -> list[str]:
1201
+ result: list[str] = []
1202
+ seen: set[str] = set()
1203
+ for value in values:
1204
+ text = str(value or "").strip()
1205
+ if not text or text in seen:
1206
+ continue
1207
+ seen.add(text)
1208
+ result.append(text)
1209
+ return result