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,3882 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import logging
5
+ import math
6
+ import re
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field, replace
9
+ from typing import Any
10
+ from urllib.parse import urlparse
11
+
12
+ import typer
13
+ from click import Abort
14
+ from rich.console import Console
15
+ from rich.markup import escape
16
+ from rich.panel import Panel
17
+
18
+ from ..branding import env_get
19
+ from ..config import (
20
+ _ROLE_TEMPERATURE_FIELDS,
21
+ DEFAULT_SUBAGENT_TIMEOUT_S,
22
+ AgentRuntimeSettings,
23
+ AppConfig,
24
+ ConfigError,
25
+ _normalize_web_search_mode,
26
+ clear_persisted_api_key,
27
+ clear_persisted_profile_key,
28
+ config_path,
29
+ load_config,
30
+ resolve_api_key,
31
+ resolve_llm_reasoning_effort,
32
+ save_config,
33
+ save_persisted_api_key,
34
+ save_persisted_profile_key,
35
+ set_config_value,
36
+ )
37
+ from ..llm.cache_capabilities import (
38
+ EffectiveCacheCapability,
39
+ resolve_effective_cache_capability,
40
+ )
41
+ from ..llm.cache_policy import ResolvedPromptCachePolicy, resolve_prompt_cache_policy
42
+ from ..llm.metadata import credential_scope_fingerprint
43
+ from ..llm.protocols import (
44
+ OPENAI_COMPAT_PROTOCOL,
45
+ get_provider_protocol_capabilities,
46
+ validate_reasoning_trace_adapter_for_protocol,
47
+ )
48
+ from ..model_registry import resolve_model_provider_key
49
+ from ..profile_presets import (
50
+ PROFILE_PRESETS,
51
+ ProfilePreset,
52
+ advanced_provider_selection_presets,
53
+ find_preset_for_base_url,
54
+ find_preset_for_profile,
55
+ make_profile_from_preset,
56
+ model_options_for_preset,
57
+ preset_protocol_summary,
58
+ preset_selection_label,
59
+ provider_selection_presets,
60
+ )
61
+ from ..profiles import (
62
+ SUBSCRIPTION_SELECTION_REQUIRED_KEY,
63
+ ProfileSpec,
64
+ get_active_profile,
65
+ list_profiles,
66
+ set_active_profile,
67
+ subscription_selection_supported,
68
+ validate_base_url,
69
+ )
70
+ from ..provider_diagnostics import provider_diagnostic_warning_lines
71
+ from ..provider_model_catalog import (
72
+ ProviderModelCatalogError,
73
+ ProviderModelOption,
74
+ discover_provider_models,
75
+ )
76
+ from ..reasoning_contracts import (
77
+ UNKNOWN_CONTRACT,
78
+ reasoning_contract_for,
79
+ reasoning_labels_allowed_by_contract,
80
+ )
81
+ from ..sandbox_settings import (
82
+ apply_sandbox_mode_to_config,
83
+ normalize_sandbox_mode,
84
+ sandbox_mode_from_config,
85
+ )
86
+ from ..surface.console import make_console
87
+ from ..surface.styles import STYLE_CONTENT, STYLE_DIM, STYLE_EMPHASIS
88
+ from ..web_search_adapters import WEB_SEARCH_ADAPTER_CHOICES, normalize_web_search_adapter
89
+ from ..web_search_policy import normalize_web_search_policy
90
+
91
+ _LOGGER = logging.getLogger(__name__)
92
+
93
+ ROLE_ORDER: tuple[str, ...] = (
94
+ "coding",
95
+ "planner",
96
+ "review",
97
+ "compactor",
98
+ "conflict_review",
99
+ "conflict_resolve",
100
+ )
101
+ ROLE_MODEL_ORDER: tuple[str, ...] = ROLE_ORDER
102
+ FORGE_ROLE_ORDER: tuple[str, ...] = ROLE_MODEL_ORDER
103
+ SECTION_VALUES: tuple[tuple[str, str], ...] = (
104
+ ("profile", "Provider Profile"),
105
+ ("api_key", "API Key"),
106
+ ("default", "Default Model"),
107
+ ("web_search", "Web Search"),
108
+ ("cache", "Context & Cache"),
109
+ ("subagents", "Subagent model overrides"),
110
+ ("forge", "Forge model overrides"),
111
+ ("sandbox", "Sandbox"),
112
+ ("personas", "Personas"),
113
+ ("execution", "Model Access"),
114
+ )
115
+ SANDBOX_MODES: tuple[str, ...] = ("strict", "warn", "off")
116
+ THINKING_LABELS: tuple[str, ...] = (
117
+ "off",
118
+ "minimal",
119
+ "low",
120
+ "medium",
121
+ "high",
122
+ "xhigh",
123
+ "max",
124
+ "ultra",
125
+ "auto",
126
+ )
127
+ STEP_BUDGET_POLICIES: tuple[str, ...] = ("autonomous", "limited", "adaptive", "fixed")
128
+ PROMPT_CACHE_MODES: tuple[str, ...] = ("auto", "manual", "off")
129
+ ANTHROPIC_PROMPT_CACHE_TTLS: tuple[str, ...] = ("5m", "1h")
130
+ _CACHE_AWARE_COMPACTION_DEFAULT = True
131
+ _CACHE_AWARE_MIN_TRIGGER_RATIO_DEFAULT = 0.72
132
+ _CACHE_AWARE_MIN_TRIGGER_RATIO_MIN = 0.05
133
+ _CACHE_AWARE_MIN_TRIGGER_RATIO_MAX = 0.98
134
+ _THINKING_LABEL_EXTRA_FIELD = "llm_thinking_label"
135
+ _MISSING_REQUIRED = "[yellow]missing · required[/yellow]"
136
+ _CLEAR_KEY_PENDING = "[yellow]will be cleared on save[/yellow]"
137
+ _MISSING_RUNTIME = "[yellow]AI subscription · connection required[/yellow]"
138
+ _WARNING_SUMMARIES = {_MISSING_REQUIRED, _CLEAR_KEY_PENDING, _MISSING_RUNTIME}
139
+ _ROLE_DESCRIPTIONS: dict[str, str] = {
140
+ "coding": "generating and editing code",
141
+ "planner": "high-level task planning",
142
+ "review": "reviewing diffs and changes",
143
+ "compactor": "summarizing long conversations",
144
+ "conflict_review": "reviewing merge conflicts",
145
+ "conflict_resolve": "resolving merge conflicts",
146
+ }
147
+ _FIELD_LABELS: dict[str, str] = {
148
+ "max_steps": "Max steps per response",
149
+ "task_max_steps": "Max steps per task",
150
+ "subagent_max_steps": "Max steps per subagent run",
151
+ "subagent_timeout_s": "Subagent timeout (seconds)",
152
+ }
153
+ _API_KEY_ENV_PROMPT = "API key env var name (NOT the key itself, e.g. 'ANTHROPIC_API_KEY')"
154
+ _API_KEY_ENV_FIELD_NAME = "API key env var name"
155
+ _SECRET_FORCE_TOKEN = "force"
156
+ _CUSTOM_MODEL_VALUE = "__custom_model__"
157
+ _ADVANCED_PROVIDER_PRESETS_VALUE = "__advanced_provider_presets__"
158
+ _EXECUTION_BACKENDS: tuple[str, ...] = ("native", "delegated")
159
+
160
+
161
+ def _normalize_execution_backend(value: Any) -> str:
162
+ normalized = str(value or "native").strip().lower()
163
+ if normalized not in _EXECUTION_BACKENDS:
164
+ raise ValueError("Model access must be one of: API key, AI subscription.")
165
+ return normalized
166
+
167
+
168
+ def _runtime_setup_options() -> tuple[Any, ...]:
169
+ from ..provider_auth import provider_auth_setup_options
170
+
171
+ return tuple(provider_auth_setup_options())
172
+
173
+
174
+ def _runtime_setup_rows() -> list[tuple[str, str, str]]:
175
+ """Return registry-backed delegated runtime choices for both config UIs."""
176
+ rows: list[tuple[str, str, str]] = []
177
+ seen: set[str] = set()
178
+ for option in _runtime_setup_options():
179
+ runtime_id = str(getattr(option, "id", "") or "").strip()
180
+ if not runtime_id or runtime_id in seen:
181
+ continue
182
+ seen.add(runtime_id)
183
+ label = str(getattr(option, "label", "") or runtime_id).strip() or runtime_id
184
+ description = str(getattr(option, "description", "") or "").strip()
185
+ rows.append((runtime_id, label, description))
186
+ return rows
187
+
188
+
189
+ def _is_direct_subscription_id(runtime_id: str) -> bool:
190
+ from ..provider_auth import provider_auth_ids
191
+
192
+ return str(runtime_id or "").strip() in provider_auth_ids()
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class ConfigMenuResult:
197
+ saved: bool
198
+ changes: dict[str, Any]
199
+ api_key_changed: bool
200
+ error: str | None = None
201
+
202
+
203
+ @dataclass
204
+ class ConfigMenuState:
205
+ fields: dict[str, str]
206
+ thinking_label: str
207
+ role_models: dict[str, str]
208
+ forge_role_models: dict[str, str]
209
+ role_temperatures: dict[str, str]
210
+ profiles: dict[str, dict[str, Any]]
211
+ active_profile: str
212
+ execution_backend: str = "native"
213
+ execution_runtime: str = ""
214
+ agent_runtimes: dict[str, Any] = field(default_factory=dict)
215
+ api_key_source: str = "missing"
216
+ masked_api_key: str = "(not set)"
217
+ new_api_key: str = ""
218
+ new_api_key_profile: str | None = None
219
+ clear_stored_key_confirmed: bool = False
220
+ clear_stored_key_profile: str | None = None
221
+ config_warning: str | None = None
222
+ subscription_selection_required: bool = False
223
+ default_workspace_path: str = ""
224
+ thinking_label_explicitly_set: bool = field(default=False, repr=False)
225
+ _subscription_models_cache: tuple[Any, ...] = field(default=(), repr=False)
226
+ _subscription_models_loaded: bool = field(default=False, repr=False)
227
+ _provider_models_cache_identity: tuple[str, str, str] | None = field(default=None, repr=False)
228
+ _provider_models_cache: tuple[ProviderModelOption, ...] = field(default=(), repr=False)
229
+ _provider_model_catalog_warning: str = field(default="", repr=False)
230
+ _original: dict[str, Any] = field(default_factory=dict, repr=False)
231
+
232
+ @classmethod
233
+ def from_cfg(cls, cfg: AppConfig) -> ConfigMenuState:
234
+ warnings: list[str] = []
235
+ try:
236
+ resolved_key = resolve_api_key(cfg)
237
+ api_key_source = resolved_key.source
238
+ masked_api_key = mask_api_key(resolved_key.key)
239
+ except ConfigError as exc:
240
+ warnings.append(str(exc))
241
+ api_key_source = "error"
242
+ masked_api_key = "(not set)"
243
+ try:
244
+ thinking_label = thinking_label_from_cfg(cfg)
245
+ except ConfigError as exc:
246
+ warnings.append(str(exc))
247
+ thinking_label = "auto"
248
+ profiles = {profile.name: profile.to_dict() for profile in list_profiles(cfg)}
249
+ active_profile = str(cfg.extra_fields.get("active_profile") or "")
250
+ execution = getattr(cfg, "execution", None)
251
+ execution_backend = _normalize_execution_backend(getattr(execution, "backend", "native"))
252
+ execution_runtime = str(getattr(execution, "runtime", None) or "").strip()
253
+ try:
254
+ active_spec = ProfileSpec.from_dict(active_profile, profiles[active_profile])
255
+ except (KeyError, ConfigError):
256
+ active_spec = None
257
+ if active_spec is not None and active_spec.auth_provider:
258
+ execution_backend = "delegated"
259
+ execution_runtime = active_spec.auth_provider
260
+ configured_model = active_spec.default_model
261
+ thinking_label = (
262
+ "auto"
263
+ if active_spec.reasoning_effort is None
264
+ else (
265
+ "off"
266
+ if active_spec.reasoning_effort == "none"
267
+ else active_spec.reasoning_effort
268
+ )
269
+ )
270
+ selection_marker = cfg.extra_fields.get(SUBSCRIPTION_SELECTION_REQUIRED_KEY)
271
+ subscription_selection_required = bool(
272
+ selection_marker is True
273
+ or str(selection_marker or "").strip() == active_spec.auth_provider
274
+ or not active_spec.default_model
275
+ or active_spec.reasoning_effort is None
276
+ )
277
+ else:
278
+ configured_model = str(getattr(cfg, "model", "") or "")
279
+ subscription_selection_required = False
280
+ agent_runtimes = copy.deepcopy(dict(getattr(cfg, "agent_runtimes", {}) or {}))
281
+ default_workspace_path = str(cfg.extra_fields.get("default_workspace_path") or "")
282
+ role_models = _normalized_role_model_values(
283
+ cfg.extra_fields.get("role_models") if isinstance(cfg.extra_fields, dict) else None
284
+ )
285
+ raw_compaction = _raw_compaction_extra_fields(cfg)
286
+ forge_role_models = _normalized_role_model_values(
287
+ cfg.extra_fields.get("forge_role_models")
288
+ if isinstance(cfg.extra_fields, dict)
289
+ else None
290
+ )
291
+ role_temperatures: dict[str, str] = {}
292
+ default_temperature = _finite_float(getattr(cfg, "temperature", 0.2), fallback=0.2)
293
+ for role in ROLE_ORDER:
294
+ field_name = _ROLE_TEMPERATURE_FIELDS.get(role)
295
+ if field_name is None:
296
+ continue
297
+ current = _finite_float(getattr(cfg, field_name, default_temperature), fallback=None)
298
+ if current is None or current == default_temperature:
299
+ role_temperatures[role] = ""
300
+ else:
301
+ role_temperatures[role] = _format_number(current)
302
+
303
+ state = cls(
304
+ fields={
305
+ "model": configured_model,
306
+ "base_url": str(getattr(cfg, "base_url", "") or ""),
307
+ "llm_timeout_s": _format_number(getattr(cfg, "llm_timeout_s", 60.0)),
308
+ "step_budget_policy": _normalize_step_budget_policy(
309
+ getattr(cfg, "step_budget_policy", "autonomous") or "autonomous"
310
+ ),
311
+ "max_steps": _format_integer(getattr(cfg, "max_steps", 25)),
312
+ "task_max_steps": _format_integer(getattr(cfg, "task_max_steps", 100)),
313
+ "subagent_max_steps": _format_integer(getattr(cfg, "subagent_max_steps", 16)),
314
+ "subagent_timeout_s": _format_number(
315
+ getattr(cfg, "subagent_timeout_s", DEFAULT_SUBAGENT_TIMEOUT_S)
316
+ ),
317
+ "stream": "true" if bool(getattr(cfg, "stream", True)) else "false",
318
+ "prompt_cache_mode": _normalize_prompt_cache_mode(
319
+ getattr(cfg, "prompt_cache_mode", "manual") or "manual"
320
+ ),
321
+ "prompt_cache_key": str(getattr(cfg, "prompt_cache_key", "") or ""),
322
+ "prompt_cache_retention": str(getattr(cfg, "prompt_cache_retention", "") or ""),
323
+ "anthropic_prompt_cache_enabled": (
324
+ "true"
325
+ if bool(getattr(cfg, "anthropic_prompt_cache_enabled", False))
326
+ else "false"
327
+ ),
328
+ "anthropic_prompt_cache_ttl": _normalize_anthropic_prompt_cache_ttl(
329
+ getattr(cfg, "anthropic_prompt_cache_ttl", "5m") or "5m"
330
+ ),
331
+ "cache_aware_compaction": _cache_aware_compaction_text(
332
+ raw_compaction.get("cache_aware_compaction")
333
+ ),
334
+ "cache_aware_min_trigger_ratio": _cache_aware_min_trigger_ratio_text(
335
+ raw_compaction.get("cache_aware_min_trigger_ratio")
336
+ ),
337
+ "web_search_mode": str(getattr(cfg, "web_search_mode", "auto") or "auto"),
338
+ "web_search_policy": str(getattr(cfg, "web_search_policy", "auto") or "auto"),
339
+ "web_search_adapter": str(getattr(cfg, "web_search_adapter", "auto") or "auto"),
340
+ "web_search_base_url": str(getattr(cfg, "web_search_base_url", "") or ""),
341
+ "web_search_model": str(getattr(cfg, "web_search_model", "") or ""),
342
+ "sandbox_mode": sandbox_mode_from_config(cfg),
343
+ "default_persona": str(getattr(cfg, "default_persona", "code") or "code"),
344
+ },
345
+ thinking_label=thinking_label,
346
+ role_models=role_models,
347
+ forge_role_models=forge_role_models,
348
+ role_temperatures=role_temperatures,
349
+ profiles=profiles,
350
+ active_profile=active_profile,
351
+ execution_backend=execution_backend,
352
+ execution_runtime=execution_runtime,
353
+ agent_runtimes=agent_runtimes,
354
+ api_key_source=api_key_source,
355
+ masked_api_key=masked_api_key,
356
+ config_warning="; ".join(warnings) or None,
357
+ subscription_selection_required=subscription_selection_required,
358
+ default_workspace_path=default_workspace_path,
359
+ )
360
+ state._normalize_provider_thinking_label()
361
+ state._original = state.snapshot()
362
+ return state
363
+
364
+ @property
365
+ def dirty(self) -> bool:
366
+ return self.snapshot() != self._original
367
+
368
+ def snapshot(self) -> dict[str, Any]:
369
+ return {
370
+ "fields": {key: str(value) for key, value in sorted(self.fields.items())},
371
+ "thinking_label": self.thinking_label,
372
+ "role_models": {role: str(self.role_models.get(role, "")) for role in ROLE_MODEL_ORDER},
373
+ "forge_role_models": {
374
+ role: str(self.forge_role_models.get(role, "")) for role in FORGE_ROLE_ORDER
375
+ },
376
+ "role_temperatures": {
377
+ role: str(self.role_temperatures.get(role, ""))
378
+ for role in ROLE_ORDER
379
+ if role in _ROLE_TEMPERATURE_FIELDS
380
+ },
381
+ "profiles": {name: dict(data) for name, data in sorted(self.profiles.items())},
382
+ "active_profile": self.active_profile,
383
+ "execution_backend": self.execution_backend,
384
+ "execution_runtime": self.execution_runtime,
385
+ "agent_runtimes": copy.deepcopy(self.agent_runtimes),
386
+ "default_workspace_path": self.default_workspace_path,
387
+ "new_api_key": self.new_api_key,
388
+ "new_api_key_profile": self.new_api_key_profile,
389
+ "clear_stored_key_confirmed": self.clear_stored_key_confirmed,
390
+ "clear_stored_key_profile": self.clear_stored_key_profile,
391
+ "thinking_label_explicitly_set": self.thinking_label_explicitly_set,
392
+ "subscription_selection_required": self.subscription_selection_required,
393
+ }
394
+
395
+ def reset(self) -> None:
396
+ original_fields = self._original.get("fields")
397
+ if isinstance(original_fields, dict):
398
+ self.fields = {str(key): str(value) for key, value in original_fields.items()}
399
+ self.thinking_label = str(self._original.get("thinking_label") or "auto")
400
+ self.subscription_selection_required = bool(
401
+ self._original.get("subscription_selection_required", False)
402
+ )
403
+ self.role_models = _role_text_map_from_snapshot(self._original.get("role_models"))
404
+ self.forge_role_models = _role_text_map_from_snapshot(
405
+ self._original.get("forge_role_models")
406
+ )
407
+ self.role_temperatures = _role_text_map_from_snapshot(
408
+ self._original.get("role_temperatures")
409
+ )
410
+ profiles = self._original.get("profiles")
411
+ self.profiles = {
412
+ str(name): dict(data)
413
+ for name, data in (profiles.items() if isinstance(profiles, dict) else ())
414
+ if isinstance(data, dict)
415
+ }
416
+ self.active_profile = str(self._original.get("active_profile") or "")
417
+ self.execution_backend = _normalize_execution_backend(
418
+ self._original.get("execution_backend")
419
+ )
420
+ self.execution_runtime = str(self._original.get("execution_runtime") or "")
421
+ raw_agent_runtimes = self._original.get("agent_runtimes")
422
+ self.agent_runtimes = copy.deepcopy(
423
+ dict(raw_agent_runtimes) if isinstance(raw_agent_runtimes, dict) else {}
424
+ )
425
+ self.default_workspace_path = str(self._original.get("default_workspace_path") or "")
426
+ self.new_api_key = str(self._original.get("new_api_key") or "")
427
+ raw_new_key_profile = self._original.get("new_api_key_profile")
428
+ self.new_api_key_profile = str(raw_new_key_profile) if raw_new_key_profile else None
429
+ self.clear_stored_key_confirmed = bool(
430
+ self._original.get("clear_stored_key_confirmed", False)
431
+ )
432
+ raw_clear_profile = self._original.get("clear_stored_key_profile")
433
+ self.clear_stored_key_profile = str(raw_clear_profile) if raw_clear_profile else None
434
+ self.thinking_label_explicitly_set = bool(
435
+ self._original.get("thinking_label_explicitly_set", False)
436
+ )
437
+ self.refresh_api_key_status()
438
+
439
+ def set_field(self, name: str, value: str) -> None:
440
+ key = str(name).strip()
441
+ if key == "new_api_key":
442
+ self.new_api_key = str(value)
443
+ self.new_api_key_profile = self.active_profile or None
444
+ self.clear_stored_key_confirmed = False
445
+ self.clear_stored_key_profile = None
446
+ return
447
+ if key not in {
448
+ "model",
449
+ "base_url",
450
+ "llm_timeout_s",
451
+ "step_budget_policy",
452
+ "max_steps",
453
+ "task_max_steps",
454
+ "subagent_max_steps",
455
+ "subagent_timeout_s",
456
+ "prompt_cache_mode",
457
+ "prompt_cache_key",
458
+ "prompt_cache_retention",
459
+ "anthropic_prompt_cache_enabled",
460
+ "anthropic_prompt_cache_ttl",
461
+ "cache_aware_compaction",
462
+ "cache_aware_min_trigger_ratio",
463
+ "web_search_mode",
464
+ "web_search_policy",
465
+ "web_search_adapter",
466
+ "web_search_base_url",
467
+ "web_search_model",
468
+ }:
469
+ raise KeyError(f"Unknown config menu field: {name}")
470
+ self.fields[key] = str(value)
471
+ if key == "model":
472
+ profile = _active_subscription_profile(self)
473
+ if profile is not None and str(value).strip() != profile.default_model:
474
+ self.subscription_selection_required = True
475
+ self._normalize_provider_thinking_label()
476
+ if key == "base_url":
477
+ self.refresh_api_key_status()
478
+
479
+ def set_thinking_label(self, label: str) -> None:
480
+ normalized = _normalize_thinking_label(label)
481
+ self.thinking_label = normalized
482
+ self.thinking_label_explicitly_set = True
483
+ if _active_subscription_profile(self) is not None:
484
+ self.subscription_selection_required = True
485
+
486
+ def _normalize_provider_thinking_label(self) -> None:
487
+ """Drop a stale effort inherited from another provider/model surface."""
488
+
489
+ preset = _active_preset(self)
490
+ if preset is None or preset.key not in {"nvidia", "zai-coding-plan"}:
491
+ return
492
+ allowed = _thinking_labels_for_state(
493
+ self,
494
+ model=str(self.fields.get("model") or ""),
495
+ )
496
+ if self.thinking_label in allowed:
497
+ return
498
+ self.thinking_label = "auto"
499
+ self.thinking_label_explicitly_set = True
500
+
501
+ def set_step_budget_policy(self, value: str) -> None:
502
+ self.fields["step_budget_policy"] = _normalize_step_budget_policy(value)
503
+
504
+ def set_max_steps(self, value: str) -> None:
505
+ self.fields["max_steps"] = str(value)
506
+
507
+ def set_task_max_steps(self, value: str) -> None:
508
+ self.fields["task_max_steps"] = str(value)
509
+
510
+ def set_subagent_max_steps(self, value: str) -> None:
511
+ self.fields["subagent_max_steps"] = str(value)
512
+
513
+ def set_subagent_timeout_s(self, value: str) -> None:
514
+ self.fields["subagent_timeout_s"] = str(value)
515
+
516
+ def set_role_model(self, role: str, model: str) -> None:
517
+ role_key = _normalize_role(role)
518
+ self.role_models[role_key] = str(model)
519
+
520
+ def set_forge_role_model(self, role: str, model: str) -> None:
521
+ role_key = _normalize_role(role)
522
+ self.forge_role_models[role_key] = str(model)
523
+
524
+ def set_role_temperature(self, role: str, value: str) -> None:
525
+ role_key = _normalize_role(role)
526
+ if role_key not in _ROLE_TEMPERATURE_FIELDS:
527
+ raise KeyError(f"Role has no temperature setting: {role}")
528
+ self.role_temperatures[role_key] = str(value)
529
+
530
+ def mark_clear_stored_key_confirmed(self) -> None:
531
+ self.clear_stored_key_confirmed = True
532
+ self.clear_stored_key_profile = self.active_profile or None
533
+ self.new_api_key = ""
534
+ self.new_api_key_profile = None
535
+
536
+ def set_default_workspace_path(self, path: str) -> None:
537
+ self.default_workspace_path = str(path or "").strip()
538
+
539
+ def set_execution_backend(self, backend: str, *, runtime: str | None = None) -> None:
540
+ normalized = _normalize_execution_backend(backend)
541
+ self.execution_backend = normalized
542
+ if normalized == "native":
543
+ self.execution_runtime = ""
544
+ if self.active_profile in self.profiles:
545
+ active = ProfileSpec.from_dict(
546
+ self.active_profile,
547
+ self.profiles[self.active_profile],
548
+ )
549
+ if active.auth_provider:
550
+ replacement = next(
551
+ (
552
+ name
553
+ for name, data in sorted(self.profiles.items())
554
+ if not ProfileSpec.from_dict(name, data).auth_provider
555
+ ),
556
+ None,
557
+ )
558
+ if replacement:
559
+ self.set_active_profile_name(replacement)
560
+ return
561
+ if runtime is not None:
562
+ self.execution_runtime = str(runtime or "").strip()
563
+ runtime_id = self.execution_runtime
564
+ if _is_direct_subscription_id(runtime_id):
565
+ from ..provider_auth import create_provider_auth
566
+
567
+ adapter = create_provider_auth(runtime_id)
568
+ existing_data = self.profiles.get(adapter.profile_name)
569
+ existing = (
570
+ ProfileSpec.from_dict(adapter.profile_name, existing_data)
571
+ if isinstance(existing_data, dict)
572
+ else None
573
+ )
574
+ if existing is not None and existing.auth_provider == runtime_id:
575
+ profile = ProfileSpec(
576
+ name=existing.name,
577
+ protocol=adapter.protocol,
578
+ base_url=adapter.base_url,
579
+ api_key_env=existing.api_key_env,
580
+ auth_provider=runtime_id,
581
+ extra_headers=dict(existing.extra_headers),
582
+ default_model=existing.default_model,
583
+ reasoning_effort=existing.reasoning_effort,
584
+ web_search_adapter=existing.web_search_adapter,
585
+ web_search_model=existing.web_search_model,
586
+ notes=existing.notes,
587
+ cache_capability=existing.cache_capability,
588
+ reasoning_trace_adapter=existing.reasoning_trace_adapter,
589
+ )
590
+ else:
591
+ profile = ProfileSpec(
592
+ name=adapter.profile_name,
593
+ protocol=adapter.protocol,
594
+ base_url=adapter.base_url,
595
+ auth_provider=runtime_id,
596
+ notes=f"{adapter.display_name}. Uses Alysis Code's native agent loop.",
597
+ )
598
+ self.add_profile_spec(
599
+ profile,
600
+ make_active=True,
601
+ allow_subscription_update=True,
602
+ )
603
+ self.subscription_selection_required = bool(
604
+ self.subscription_selection_required
605
+ or not profile.default_model
606
+ or profile.reasoning_effort is None
607
+ )
608
+ return
609
+ if not runtime_id or runtime_id in self.agent_runtimes:
610
+ return
611
+ option = next(
612
+ (
613
+ item
614
+ for item in _runtime_setup_options()
615
+ if str(getattr(item, "id", "") or "").strip() == runtime_id
616
+ ),
617
+ None,
618
+ )
619
+ if option is None:
620
+ return
621
+ self.agent_runtimes[runtime_id] = AgentRuntimeSettings(
622
+ adapter=str(getattr(option, "adapter", "") or "").strip(),
623
+ executable=str(getattr(option, "default_executable", "") or "").strip(),
624
+ )
625
+
626
+ def set_active_profile_name(self, name: str) -> None:
627
+ profile_name = str(name or "").strip().lower()
628
+ if profile_name not in self.profiles:
629
+ raise KeyError(f"Unknown profile: {name}")
630
+ was_pending_for_profile = bool(
631
+ self.subscription_selection_required and self.active_profile == profile_name
632
+ )
633
+ original_pending_for_profile = bool(
634
+ self._original.get("subscription_selection_required")
635
+ and str(self._original.get("active_profile") or "") == profile_name
636
+ )
637
+ self.active_profile = profile_name
638
+ self._subscription_models_cache = ()
639
+ self._subscription_models_loaded = False
640
+ profile = ProfileSpec.from_dict(profile_name, self.profiles[profile_name])
641
+ if profile.base_url:
642
+ self.fields["base_url"] = profile.base_url
643
+ if profile.auth_provider:
644
+ self.fields["model"] = profile.default_model
645
+ self.thinking_label = (
646
+ "auto"
647
+ if profile.reasoning_effort is None
648
+ else ("off" if profile.reasoning_effort == "none" else profile.reasoning_effort)
649
+ )
650
+ self.subscription_selection_required = bool(
651
+ was_pending_for_profile
652
+ or original_pending_for_profile
653
+ or not profile.default_model
654
+ or profile.reasoning_effort is None
655
+ )
656
+ else:
657
+ if profile.default_model:
658
+ self.fields["model"] = profile.default_model
659
+ if profile.reasoning_effort is not None:
660
+ self.thinking_label = (
661
+ "off" if profile.reasoning_effort == "none" else profile.reasoning_effort
662
+ )
663
+ self.subscription_selection_required = False
664
+ self._normalize_provider_thinking_label()
665
+ self.refresh_api_key_status()
666
+
667
+ def staged_api_key_target_profile(self) -> str | None:
668
+ if not self.new_api_key.strip():
669
+ return None
670
+ return self.new_api_key_profile or self.active_profile or None
671
+
672
+ def staged_api_key_for_active_profile(self) -> str:
673
+ target = self.staged_api_key_target_profile()
674
+ if target != (self.active_profile or None):
675
+ return ""
676
+ return self.new_api_key.strip()
677
+
678
+ def add_profile_spec(
679
+ self,
680
+ profile: ProfileSpec,
681
+ *,
682
+ make_active: bool = True,
683
+ allow_subscription_update: bool = False,
684
+ ) -> None:
685
+ existing_data = self.profiles.get(profile.name)
686
+ existing = (
687
+ ProfileSpec.from_dict(profile.name, existing_data)
688
+ if isinstance(existing_data, dict)
689
+ else None
690
+ )
691
+ if existing is not None and existing.auth_provider:
692
+ trusted_refresh = bool(
693
+ allow_subscription_update and profile.auth_provider == existing.auth_provider
694
+ )
695
+ if not trusted_refresh:
696
+ raise ConfigError(
697
+ f"Profile {existing.name!r} is managed by the "
698
+ f"{existing.auth_provider!r} subscription connection and cannot be "
699
+ "overwritten through generic profile settings. Choose a different "
700
+ "profile name or manage the connection through auth."
701
+ )
702
+ self.profiles[profile.name] = profile.to_dict()
703
+ if make_active:
704
+ self.set_active_profile_name(profile.name)
705
+
706
+ def update_active_profile_spec(self, **fields: Any) -> None:
707
+ if self.active_profile not in self.profiles:
708
+ raise KeyError("No active profile configured.")
709
+ profile = ProfileSpec.from_dict(self.active_profile, self.profiles[self.active_profile])
710
+ if profile.auth_provider:
711
+ protected = {
712
+ "protocol",
713
+ "base_url",
714
+ "api_key_env",
715
+ "auth_provider",
716
+ "extra_headers",
717
+ "default_model",
718
+ "reasoning_effort",
719
+ "reasoning_trace_adapter",
720
+ }
721
+ if any(key in protected and fields[key] != getattr(profile, key) for key in fields):
722
+ raise ConfigError(
723
+ "Subscription connection fields are provider-managed; use Default Model "
724
+ "for model and reasoning choices."
725
+ )
726
+ self.profiles[self.active_profile] = replace(profile, **fields).to_dict()
727
+ self.set_active_profile_name(self.active_profile)
728
+
729
+ def remove_profile_name(self, name: str) -> bool:
730
+ profile_name = str(name or "").strip().lower()
731
+ if profile_name not in self.profiles:
732
+ raise KeyError(f"Unknown profile: {name}")
733
+ staged_key_discarded = bool(
734
+ self.new_api_key.strip() and self.staged_api_key_target_profile() == profile_name
735
+ )
736
+ if staged_key_discarded:
737
+ self.new_api_key = ""
738
+ self.new_api_key_profile = None
739
+ self.profiles.pop(profile_name, None)
740
+ if self.active_profile == profile_name:
741
+ next_profile = sorted(self.profiles)[0] if self.profiles else ""
742
+ if next_profile:
743
+ self.set_active_profile_name(next_profile)
744
+ else:
745
+ self.active_profile = ""
746
+ self.refresh_api_key_status()
747
+ return staged_key_discarded
748
+
749
+ def _resolution_cfg(self) -> AppConfig:
750
+ cfg = AppConfig(
751
+ model=str(self.fields.get("model", "") or ""),
752
+ base_url=str(self.fields.get("base_url", "") or ""),
753
+ stream=str(self.fields.get("stream", "true")).strip().lower()
754
+ in {"1", "true", "yes", "on"},
755
+ prompt_cache_mode=_normalize_prompt_cache_mode(
756
+ self.fields.get("prompt_cache_mode", "manual")
757
+ ),
758
+ prompt_cache_key=str(self.fields.get("prompt_cache_key", "") or ""),
759
+ prompt_cache_retention=str(self.fields.get("prompt_cache_retention", "") or ""),
760
+ anthropic_prompt_cache_enabled=_normalize_bool_text(
761
+ self.fields.get("anthropic_prompt_cache_enabled", "false"),
762
+ label="Anthropic prompt cache",
763
+ ),
764
+ anthropic_prompt_cache_ttl=_normalize_anthropic_prompt_cache_ttl(
765
+ self.fields.get("anthropic_prompt_cache_ttl", "5m")
766
+ ),
767
+ web_search_mode=str(self.fields.get("web_search_mode", "auto") or "auto"),
768
+ web_search_policy=str(self.fields.get("web_search_policy", "auto") or "auto"),
769
+ web_search_adapter=str(self.fields.get("web_search_adapter", "auto") or "auto"),
770
+ web_search_base_url=str(self.fields.get("web_search_base_url", "") or "") or None,
771
+ web_search_model=str(self.fields.get("web_search_model", "") or "") or None,
772
+ )
773
+ cfg.extra_fields = {"profiles": dict(self.profiles)}
774
+ if self.active_profile:
775
+ cfg.extra_fields["active_profile"] = self.active_profile
776
+ execution = getattr(cfg, "execution", None)
777
+ if execution is not None:
778
+ direct = self.execution_backend == "delegated" and _is_direct_subscription_id(
779
+ self.execution_runtime
780
+ )
781
+ execution.backend = "native" if direct else self.execution_backend
782
+ execution.runtime = None if direct else (self.execution_runtime or None)
783
+ if hasattr(cfg, "agent_runtimes"):
784
+ cfg.agent_runtimes = copy.deepcopy(self.agent_runtimes)
785
+ return cfg
786
+
787
+ def refresh_api_key_status(self) -> None:
788
+ try:
789
+ resolved_key = resolve_api_key(self._resolution_cfg())
790
+ except ConfigError:
791
+ self.api_key_source = "error"
792
+ self.masked_api_key = "(not set)"
793
+ return
794
+ self.api_key_source = resolved_key.source
795
+ self.masked_api_key = mask_api_key(resolved_key.key)
796
+
797
+ def commit_to(self, cfg: AppConfig) -> ConfigMenuResult:
798
+ validation_error = self.validate()
799
+ if validation_error is not None:
800
+ return ConfigMenuResult(
801
+ saved=False,
802
+ changes={},
803
+ api_key_changed=False,
804
+ error=validation_error,
805
+ )
806
+
807
+ changes: dict[str, Any] = {}
808
+
809
+ execution = getattr(cfg, "execution", None)
810
+ if execution is None:
811
+ return ConfigMenuResult(
812
+ saved=False,
813
+ changes={},
814
+ api_key_changed=False,
815
+ error="Model access configuration is unavailable.",
816
+ )
817
+ current_backend = _normalize_execution_backend(getattr(execution, "backend", "native"))
818
+ current_runtime = str(getattr(execution, "runtime", None) or "").strip()
819
+ direct = self.execution_backend == "delegated" and _is_direct_subscription_id(
820
+ self.execution_runtime
821
+ )
822
+ desired_backend = "native" if direct else self.execution_backend
823
+ desired_runtime = None if direct else (self.execution_runtime or None)
824
+ if current_backend != desired_backend:
825
+ execution.backend = desired_backend
826
+ changes["execution.backend"] = desired_backend
827
+ if current_runtime != str(desired_runtime or ""):
828
+ execution.runtime = desired_runtime
829
+ changes["execution.runtime"] = desired_runtime
830
+ current_agent_runtimes = dict(getattr(cfg, "agent_runtimes", {}) or {})
831
+ if current_agent_runtimes != self.agent_runtimes:
832
+ cfg.agent_runtimes = copy.deepcopy(self.agent_runtimes)
833
+ changes["agent_runtimes"] = sorted(self.agent_runtimes)
834
+
835
+ original_profiles = self._original.get("profiles")
836
+ if self.profiles != (original_profiles if isinstance(original_profiles, dict) else {}):
837
+ cfg.extra_fields["profiles"] = dict(sorted(self.profiles.items()))
838
+ changes["profiles"] = sorted(self.profiles)
839
+
840
+ original_active_profile = str(self._original.get("active_profile") or "")
841
+ if self.active_profile != original_active_profile:
842
+ if self.active_profile:
843
+ cfg.extra_fields["active_profile"] = self.active_profile
844
+ set_active_profile(cfg, self.active_profile)
845
+ else:
846
+ cfg.extra_fields.pop("active_profile", None)
847
+ changes["active_profile"] = self.active_profile
848
+
849
+ original_workspace = str(self._original.get("default_workspace_path") or "")
850
+ if self.default_workspace_path != original_workspace:
851
+ if self.default_workspace_path:
852
+ cfg.extra_fields["default_workspace_path"] = self.default_workspace_path
853
+ else:
854
+ cfg.extra_fields.pop("default_workspace_path", None)
855
+ changes["default_workspace_path"] = self.default_workspace_path
856
+
857
+ desired_base_url = str(self.fields.get("base_url", ""))
858
+ if str(getattr(cfg, "base_url", "") or "") != desired_base_url:
859
+ set_config_value(cfg, "base_url", desired_base_url)
860
+ changes["base_url"] = desired_base_url
861
+
862
+ desired_model = str(self.fields.get("model", ""))
863
+ original_fields = self._original.get("fields")
864
+ original_model = (
865
+ str(original_fields.get("model", "") or "")
866
+ if isinstance(original_fields, dict)
867
+ else str(getattr(cfg, "model", "") or "")
868
+ )
869
+ # Only write the model when the user changed it in this menu. Subscription
870
+ # profiles can intentionally differ from the top-level compatibility field;
871
+ # comparing against ``cfg.model`` made an unrelated save rewrite the active
872
+ # subscription model.
873
+ if desired_model != original_model:
874
+ set_config_value(
875
+ cfg,
876
+ "model",
877
+ desired_model,
878
+ allow_subscription_selection=True,
879
+ )
880
+ changes["model"] = desired_model
881
+
882
+ desired_timeout = float(str(self.fields.get("llm_timeout_s", "")).strip())
883
+ current_timeout = _finite_float(getattr(cfg, "llm_timeout_s", None), fallback=None)
884
+ if current_timeout != desired_timeout:
885
+ set_config_value(cfg, "llm_timeout_s", _format_number(desired_timeout))
886
+ changes["llm_timeout_s"] = desired_timeout
887
+
888
+ desired_step_budget_policy = _normalize_step_budget_policy(
889
+ self.fields.get("step_budget_policy", "autonomous")
890
+ )
891
+ if str(getattr(cfg, "step_budget_policy", "") or "") != desired_step_budget_policy:
892
+ set_config_value(cfg, "step_budget_policy", desired_step_budget_policy)
893
+ changes["step_budget_policy"] = desired_step_budget_policy
894
+
895
+ for key in ("max_steps", "task_max_steps", "subagent_max_steps"):
896
+ desired_int = int(str(self.fields.get(key, "")).strip())
897
+ current_int = _finite_int(getattr(cfg, key, None), fallback=None)
898
+ if current_int != desired_int:
899
+ set_config_value(cfg, key, str(desired_int))
900
+ changes[key] = desired_int
901
+
902
+ desired_subagent_timeout = float(str(self.fields.get("subagent_timeout_s", "")).strip())
903
+ current_subagent_timeout = _finite_float(
904
+ getattr(cfg, "subagent_timeout_s", None),
905
+ fallback=None,
906
+ )
907
+ if current_subagent_timeout != desired_subagent_timeout:
908
+ set_config_value(
909
+ cfg,
910
+ "subagent_timeout_s",
911
+ _format_number(desired_subagent_timeout),
912
+ )
913
+ changes["subagent_timeout_s"] = desired_subagent_timeout
914
+
915
+ desired_cache_mode = _normalize_prompt_cache_mode(
916
+ self.fields.get("prompt_cache_mode", "manual")
917
+ )
918
+ if str(getattr(cfg, "prompt_cache_mode", "") or "manual") != desired_cache_mode:
919
+ set_config_value(cfg, "prompt_cache_mode", desired_cache_mode)
920
+ changes["prompt_cache_mode"] = desired_cache_mode
921
+
922
+ for key in ("prompt_cache_key", "prompt_cache_retention"):
923
+ desired_text = str(self.fields.get(key, "") or "").strip()
924
+ current_text = str(getattr(cfg, key, "") or "").strip()
925
+ if current_text != desired_text:
926
+ set_config_value(cfg, key, desired_text)
927
+ changes[key] = desired_text or None
928
+
929
+ desired_anthropic_enabled = _normalize_bool_text(
930
+ self.fields.get("anthropic_prompt_cache_enabled", "false"),
931
+ label="Anthropic prompt cache",
932
+ )
933
+ if bool(getattr(cfg, "anthropic_prompt_cache_enabled", False)) != desired_anthropic_enabled:
934
+ set_config_value(
935
+ cfg,
936
+ "anthropic_prompt_cache_enabled",
937
+ "true" if desired_anthropic_enabled else "false",
938
+ )
939
+ changes["anthropic_prompt_cache_enabled"] = desired_anthropic_enabled
940
+
941
+ desired_anthropic_ttl = _normalize_anthropic_prompt_cache_ttl(
942
+ self.fields.get("anthropic_prompt_cache_ttl", "5m")
943
+ )
944
+ if str(getattr(cfg, "anthropic_prompt_cache_ttl", "") or "5m") != desired_anthropic_ttl:
945
+ set_config_value(cfg, "anthropic_prompt_cache_ttl", desired_anthropic_ttl)
946
+ changes["anthropic_prompt_cache_ttl"] = desired_anthropic_ttl
947
+
948
+ desired_web_search_policy = normalize_web_search_policy(
949
+ self.fields.get("web_search_policy", "auto")
950
+ )
951
+ if str(getattr(cfg, "web_search_policy", "auto") or "auto") != desired_web_search_policy:
952
+ set_config_value(cfg, "web_search_policy", desired_web_search_policy)
953
+ changes["web_search_policy"] = desired_web_search_policy
954
+
955
+ desired_web_search_mode = _normalize_web_search_mode(
956
+ self.fields.get("web_search_mode", "auto")
957
+ )
958
+ if str(getattr(cfg, "web_search_mode", "auto") or "auto") != desired_web_search_mode:
959
+ set_config_value(cfg, "web_search_mode", desired_web_search_mode)
960
+ changes["web_search_mode"] = desired_web_search_mode
961
+
962
+ desired_web_search_adapter = normalize_web_search_adapter(
963
+ self.fields.get("web_search_adapter", "auto")
964
+ )
965
+ if str(getattr(cfg, "web_search_adapter", "auto") or "auto") != desired_web_search_adapter:
966
+ set_config_value(cfg, "web_search_adapter", desired_web_search_adapter)
967
+ changes["web_search_adapter"] = desired_web_search_adapter
968
+
969
+ for key in ("web_search_base_url", "web_search_model"):
970
+ desired_text = str(self.fields.get(key, "") or "").strip()
971
+ current_text = str(getattr(cfg, key, "") or "").strip()
972
+ if current_text != desired_text:
973
+ set_config_value(cfg, key, desired_text)
974
+ changes[key] = desired_text or None
975
+
976
+ desired_cache_aware = _normalize_bool_text(
977
+ self.fields.get("cache_aware_compaction", "true"),
978
+ label="Cache-aware compaction",
979
+ )
980
+ desired_cache_aware_ratio = _parse_cache_aware_min_trigger_ratio(
981
+ self.fields.get("cache_aware_min_trigger_ratio", _CACHE_AWARE_MIN_TRIGGER_RATIO_DEFAULT)
982
+ )
983
+ raw_compaction = _raw_compaction_extra_fields(cfg)
984
+ compaction = dict(raw_compaction)
985
+ current_cache_aware = _cache_aware_compaction_bool(compaction.get("cache_aware_compaction"))
986
+ current_cache_aware_ratio = _cache_aware_min_trigger_ratio_value(
987
+ compaction.get("cache_aware_min_trigger_ratio")
988
+ )
989
+ compaction_changed = False
990
+ if current_cache_aware != desired_cache_aware:
991
+ if desired_cache_aware == _CACHE_AWARE_COMPACTION_DEFAULT:
992
+ compaction.pop("cache_aware_compaction", None)
993
+ else:
994
+ compaction["cache_aware_compaction"] = desired_cache_aware
995
+ changes["compaction.cache_aware_compaction"] = desired_cache_aware
996
+ compaction_changed = True
997
+ if current_cache_aware_ratio != desired_cache_aware_ratio:
998
+ if desired_cache_aware_ratio == _CACHE_AWARE_MIN_TRIGGER_RATIO_DEFAULT:
999
+ compaction.pop("cache_aware_min_trigger_ratio", None)
1000
+ else:
1001
+ compaction["cache_aware_min_trigger_ratio"] = desired_cache_aware_ratio
1002
+ changes["compaction.cache_aware_min_trigger_ratio"] = desired_cache_aware_ratio
1003
+ compaction_changed = True
1004
+ if compaction_changed:
1005
+ if compaction:
1006
+ cfg.extra_fields["compaction"] = dict(sorted(compaction.items()))
1007
+ else:
1008
+ cfg.extra_fields.pop("compaction", None)
1009
+
1010
+ desired_thinking_value = thinking_label_to_config_value(self.thinking_label)
1011
+ desired_reasoning_effort = thinking_label_to_reasoning_effort(self.thinking_label)
1012
+ original_thinking_label = str(self._original.get("thinking_label") or "auto")
1013
+ should_write_thinking = (
1014
+ self.thinking_label_explicitly_set or self.thinking_label != original_thinking_label
1015
+ )
1016
+ if should_write_thinking:
1017
+ if getattr(cfg, "llm_enable_thinking", None) != desired_thinking_value:
1018
+ set_config_value(
1019
+ cfg,
1020
+ "llm_enable_thinking",
1021
+ _thinking_config_text(desired_thinking_value),
1022
+ allow_subscription_selection=True,
1023
+ )
1024
+ changes["llm_enable_thinking"] = desired_thinking_value
1025
+ profile_effort = get_active_profile(cfg).reasoning_effort
1026
+ desired_profile_effort = desired_reasoning_effort or "auto"
1027
+ if (
1028
+ getattr(cfg, "llm_reasoning_effort", None) != desired_reasoning_effort
1029
+ or profile_effort != desired_profile_effort
1030
+ ):
1031
+ set_config_value(
1032
+ cfg,
1033
+ "llm_reasoning_effort",
1034
+ desired_reasoning_effort or "auto",
1035
+ allow_subscription_selection=True,
1036
+ )
1037
+ changes["llm_reasoning_effort"] = desired_reasoning_effort
1038
+ _set_thinking_label_hint(cfg, self.thinking_label)
1039
+
1040
+ raw_role_models = cfg.extra_fields.get("role_models")
1041
+ role_models = _role_model_storage_values(raw_role_models, self.role_models)
1042
+ current_role_models = _role_model_storage_values(
1043
+ raw_role_models,
1044
+ _normalized_role_model_values(raw_role_models),
1045
+ )
1046
+ if current_role_models != role_models:
1047
+ if role_models:
1048
+ cfg.extra_fields["role_models"] = dict(sorted(role_models.items()))
1049
+ else:
1050
+ cfg.extra_fields.pop("role_models", None)
1051
+ changes["role_models"] = dict(role_models)
1052
+
1053
+ raw_forge_role_models = cfg.extra_fields.get("forge_role_models")
1054
+ forge_role_models = _role_model_storage_values(
1055
+ raw_forge_role_models,
1056
+ self.forge_role_models,
1057
+ )
1058
+ current_forge_role_models = _role_model_storage_values(
1059
+ raw_forge_role_models,
1060
+ _normalized_role_model_values(raw_forge_role_models),
1061
+ )
1062
+ if current_forge_role_models != forge_role_models:
1063
+ if forge_role_models:
1064
+ cfg.extra_fields["forge_role_models"] = dict(sorted(forge_role_models.items()))
1065
+ else:
1066
+ cfg.extra_fields.pop("forge_role_models", None)
1067
+ changes["forge_role_models"] = dict(forge_role_models)
1068
+
1069
+ default_temperature = _finite_float(getattr(cfg, "temperature", 0.2), fallback=0.2)
1070
+ for role in ROLE_ORDER:
1071
+ field_name = _ROLE_TEMPERATURE_FIELDS.get(role)
1072
+ if field_name is None:
1073
+ continue
1074
+ raw_value = str(self.role_temperatures.get(role, "")).strip()
1075
+ current_value = _finite_float(getattr(cfg, field_name, None), fallback=None)
1076
+ if raw_value:
1077
+ desired_temperature = float(raw_value)
1078
+ else:
1079
+ desired_temperature = default_temperature
1080
+ if current_value != desired_temperature:
1081
+ set_config_value(cfg, field_name, _format_number(desired_temperature))
1082
+ changes[field_name] = desired_temperature
1083
+
1084
+ desired_sandbox_mode = normalize_sandbox_mode(self.fields.get("sandbox_mode", "strict"))
1085
+ if sandbox_mode_from_config(cfg) != desired_sandbox_mode:
1086
+ apply_sandbox_mode_to_config(cfg, desired_sandbox_mode)
1087
+ changes["sandbox_mode"] = desired_sandbox_mode
1088
+
1089
+ desired_persona = str(self.fields.get("default_persona") or "code").strip().lower()
1090
+ if desired_persona not in {"code", "architect", "ask", "debug"}:
1091
+ desired_persona = "code"
1092
+ if str(getattr(cfg, "default_persona", "code") or "code") != desired_persona:
1093
+ set_config_value(cfg, "default_persona", desired_persona)
1094
+ changes["default_persona"] = desired_persona
1095
+
1096
+ active = get_active_profile(cfg)
1097
+ selection_confirmed = bool(
1098
+ active.default_model
1099
+ and active.reasoning_effort is not None
1100
+ and self.thinking_label_explicitly_set
1101
+ and subscription_selection_supported(
1102
+ active,
1103
+ _subscription_models_for_state(self),
1104
+ )
1105
+ )
1106
+ if active.auth_provider and self.subscription_selection_required and selection_confirmed:
1107
+ cfg.extra_fields.pop(SUBSCRIPTION_SELECTION_REQUIRED_KEY, None)
1108
+ cfg.extra_fields.pop("subscription_reconnect_required", None)
1109
+ cfg.extra_fields["onboarded"] = True
1110
+ self.subscription_selection_required = False
1111
+ changes["subscription_model_selection"] = "configured"
1112
+ elif active.auth_provider and self.subscription_selection_required:
1113
+ cfg.extra_fields[SUBSCRIPTION_SELECTION_REQUIRED_KEY] = active.auth_provider
1114
+
1115
+ return ConfigMenuResult(
1116
+ saved=True,
1117
+ changes=changes,
1118
+ api_key_changed=bool(self.new_api_key.strip() or self.clear_stored_key_confirmed),
1119
+ )
1120
+
1121
+ def validate(self) -> str | None:
1122
+ try:
1123
+ backend = _normalize_execution_backend(self.execution_backend)
1124
+ except ValueError as exc:
1125
+ return str(exc)
1126
+ if not isinstance(self.agent_runtimes, dict):
1127
+ return "Agent runtime settings must be a mapping."
1128
+ if backend == "delegated":
1129
+ runtime = str(self.execution_runtime or "").strip()
1130
+ if not runtime:
1131
+ return "Choose a supported AI subscription connection."
1132
+ known_runtime_ids = {value for value, _label, _description in _runtime_setup_rows()}
1133
+ if runtime not in known_runtime_ids:
1134
+ return f"Unknown AI subscription connection: {runtime}"
1135
+ if not _is_direct_subscription_id(runtime) and runtime not in self.agent_runtimes:
1136
+ return f"AI subscription connection settings are missing: {runtime}"
1137
+ if _is_direct_subscription_id(runtime):
1138
+ try:
1139
+ active = ProfileSpec.from_dict(
1140
+ self.active_profile,
1141
+ self.profiles[self.active_profile],
1142
+ )
1143
+ except (KeyError, ConfigError):
1144
+ return "AI subscription profile is missing."
1145
+ if active.auth_provider != runtime:
1146
+ return "AI subscription profile does not match the selected connection."
1147
+ if self.subscription_selection_required:
1148
+ try:
1149
+ from ..provider_auth import create_provider_auth
1150
+
1151
+ connected = create_provider_auth(runtime).account_status().connected
1152
+ except Exception:
1153
+ connected = False
1154
+ if connected:
1155
+ model = str(self.fields.get("model") or "").strip()
1156
+ if not model:
1157
+ return "Choose a subscription model in Default Model."
1158
+ models = _subscription_models_for_state(self)
1159
+ if not models:
1160
+ return (
1161
+ self.config_warning or "Subscription model catalog is unavailable."
1162
+ )
1163
+ selected = next((item for item in models if item.id == model), None)
1164
+ if selected is None:
1165
+ return "Choose a model available to the connected subscription account."
1166
+ if not self.thinking_label_explicitly_set:
1167
+ return "Choose a reasoning effort for the subscription model."
1168
+ effort = thinking_label_to_reasoning_effort(self.thinking_label)
1169
+ supported = {item.id for item in selected.reasoning_efforts}
1170
+ if effort is not None and supported and effort not in supported:
1171
+ return "Choose a reasoning effort supported by the subscription model."
1172
+ timeout_text = str(self.fields.get("llm_timeout_s", "")).strip()
1173
+ try:
1174
+ timeout = float(timeout_text)
1175
+ except ValueError:
1176
+ return "Request timeout (seconds) must be a positive number."
1177
+ if timeout <= 0 or not math.isfinite(timeout):
1178
+ return "Request timeout (seconds) must be a positive number."
1179
+ base_url_text = str(self.fields.get("base_url", "")).strip()
1180
+ try:
1181
+ validate_base_url(base_url_text, key="Base URL", allow_empty=True)
1182
+ except ConfigError as exc:
1183
+ return str(exc)
1184
+ try:
1185
+ _normalize_thinking_label(self.thinking_label)
1186
+ except ValueError as exc:
1187
+ return str(exc)
1188
+ try:
1189
+ _normalize_step_budget_policy(self.fields.get("step_budget_policy", "autonomous"))
1190
+ except ValueError as exc:
1191
+ return str(exc)
1192
+ for key in ("max_steps", "task_max_steps", "subagent_max_steps"):
1193
+ text = str(self.fields.get(key, "")).strip()
1194
+ label = _FIELD_LABELS.get(key, key)
1195
+ try:
1196
+ value = int(text)
1197
+ except ValueError:
1198
+ return f"{label} must be a positive integer."
1199
+ if value <= 0:
1200
+ return f"{label} must be a positive integer."
1201
+ subagent_timeout_text = str(self.fields.get("subagent_timeout_s", "")).strip()
1202
+ try:
1203
+ subagent_timeout = float(subagent_timeout_text)
1204
+ except ValueError:
1205
+ return "Subagent timeout (seconds) must be a positive number."
1206
+ if subagent_timeout <= 0 or not math.isfinite(subagent_timeout):
1207
+ return "Subagent timeout (seconds) must be a positive number."
1208
+ try:
1209
+ _normalize_prompt_cache_mode(self.fields.get("prompt_cache_mode", "manual"))
1210
+ except ValueError as exc:
1211
+ return str(exc)
1212
+ try:
1213
+ normalize_web_search_policy(self.fields.get("web_search_policy", "auto"))
1214
+ _normalize_web_search_mode(self.fields.get("web_search_mode", "auto"))
1215
+ normalize_web_search_adapter(self.fields.get("web_search_adapter", "auto"))
1216
+ validate_base_url(
1217
+ str(self.fields.get("web_search_base_url", "") or "").strip(),
1218
+ key="Web search base URL",
1219
+ allow_empty=True,
1220
+ )
1221
+ except (ConfigError, ValueError) as exc:
1222
+ return str(exc)
1223
+ try:
1224
+ _normalize_bool_text(
1225
+ self.fields.get("anthropic_prompt_cache_enabled", "false"),
1226
+ label="Anthropic prompt cache",
1227
+ )
1228
+ except ValueError as exc:
1229
+ return str(exc)
1230
+ try:
1231
+ _normalize_anthropic_prompt_cache_ttl(
1232
+ self.fields.get("anthropic_prompt_cache_ttl", "5m")
1233
+ )
1234
+ except ValueError as exc:
1235
+ return str(exc)
1236
+ try:
1237
+ _normalize_bool_text(
1238
+ self.fields.get("cache_aware_compaction", "true"),
1239
+ label="Cache-aware compaction",
1240
+ )
1241
+ except ValueError as exc:
1242
+ return str(exc)
1243
+ try:
1244
+ _parse_cache_aware_min_trigger_ratio(
1245
+ self.fields.get(
1246
+ "cache_aware_min_trigger_ratio",
1247
+ _CACHE_AWARE_MIN_TRIGGER_RATIO_DEFAULT,
1248
+ )
1249
+ )
1250
+ except ValueError as exc:
1251
+ return str(exc)
1252
+ for role, raw_value in self.role_temperatures.items():
1253
+ text = str(raw_value).strip()
1254
+ if not text:
1255
+ continue
1256
+ label = f"{_role_label(role)} temperature"
1257
+ try:
1258
+ temperature = float(text)
1259
+ except ValueError:
1260
+ return f"{label} must be a non-negative number."
1261
+ if temperature < 0 or not math.isfinite(temperature):
1262
+ return f"{label} must be a non-negative number."
1263
+ return None
1264
+
1265
+
1266
+ def run_config_menu(
1267
+ *,
1268
+ cfg: AppConfig | None = None,
1269
+ auto_focus: str | None = None,
1270
+ ) -> ConfigMenuResult:
1271
+ """Open the inline configuration menu and persist changes on save."""
1272
+ effective_cfg = cfg or load_config()
1273
+ state = ConfigMenuState.from_cfg(effective_cfg)
1274
+ console = _resolve_console()
1275
+
1276
+ if auto_focus == "api_key":
1277
+ _run_api_key_section(state, console)
1278
+ elif auto_focus == "model":
1279
+ _run_default_section(state, console)
1280
+ elif auto_focus == "execution":
1281
+ _run_execution_section(state, console)
1282
+ elif auto_focus == "web_search":
1283
+ _run_web_search_section(state, console)
1284
+
1285
+ while True:
1286
+ action = _prompt_main_action(console, state)
1287
+ if action == "execution":
1288
+ _run_execution_section(state, console)
1289
+ elif action == "profile":
1290
+ _run_provider_section(state, console)
1291
+ elif action == "api_key":
1292
+ _run_api_key_section(state, console)
1293
+ elif action == "default":
1294
+ _run_default_section(state, console)
1295
+ elif action == "web_search":
1296
+ _run_web_search_section(state, console)
1297
+ elif action == "cache":
1298
+ _run_cache_section(state, console)
1299
+ elif action == "subagents":
1300
+ _run_subagent_section(state, console)
1301
+ elif action == "forge":
1302
+ _run_forge_section(state, console)
1303
+ elif action == "personas":
1304
+ _run_personas_section(state, console)
1305
+ elif action == "sandbox":
1306
+ _run_sandbox_section(state, console)
1307
+ elif action == "save":
1308
+ result = _save_and_exit(state, effective_cfg, console)
1309
+ if result.saved or result.error is None:
1310
+ return result
1311
+ elif action == "cancel" and _confirm_cancel_when_dirty(state, console):
1312
+ return ConfigMenuResult(saved=False, changes={}, api_key_changed=False)
1313
+
1314
+
1315
+ def _resolve_console() -> Console:
1316
+ return make_console()
1317
+
1318
+
1319
+ def _execution_summary_text(state: ConfigMenuState) -> str:
1320
+ if state.execution_backend == "native":
1321
+ return "API key · Alysis Code agent"
1322
+ runtime = str(state.execution_runtime or "").strip()
1323
+ if not runtime:
1324
+ return _MISSING_RUNTIME
1325
+ labels = {value: label for value, label, _description in _runtime_setup_rows()}
1326
+ return f"AI subscription · {labels.get(runtime, runtime)}"
1327
+
1328
+
1329
+ def _inactive_native_summary(summary: str) -> str:
1330
+ if summary == _MISSING_REQUIRED:
1331
+ base = "not configured"
1332
+ elif summary == _CLEAR_KEY_PENDING:
1333
+ base = "will be cleared on save"
1334
+ else:
1335
+ base = re.sub(r"\[/?[a-z][a-z0-9 _#-]*\]", "", str(summary)).strip()
1336
+ return f"{base} · inactive while using an AI subscription"
1337
+
1338
+
1339
+ def _profile_summary_text(state: ConfigMenuState) -> str:
1340
+ if state.active_profile:
1341
+ return f"{state.active_profile} (active)"
1342
+ if state.profiles:
1343
+ return f"{len(state.profiles)} profiles, none active"
1344
+ return _MISSING_REQUIRED
1345
+
1346
+
1347
+ def _api_key_summary_text(state: ConfigMenuState) -> str:
1348
+ if state.clear_stored_key_confirmed and (
1349
+ not state.clear_stored_key_profile
1350
+ or state.clear_stored_key_profile == (state.active_profile or None)
1351
+ ):
1352
+ return _CLEAR_KEY_PENDING
1353
+ pending_key = state.staged_api_key_for_active_profile()
1354
+ if pending_key:
1355
+ return f"set ({mask_api_key(pending_key)})"
1356
+ if state.masked_api_key != "(not set)":
1357
+ return f"set ({state.masked_api_key})"
1358
+ return _MISSING_REQUIRED
1359
+
1360
+
1361
+ def _default_model_summary_text(state: ConfigMenuState) -> str:
1362
+ model = state.fields.get("model", "").strip()
1363
+ if not model:
1364
+ return _MISSING_REQUIRED
1365
+ return f"{model} · thinking {state.thinking_label}"
1366
+
1367
+
1368
+ def _cache_summary_text(state: ConfigMenuState) -> str:
1369
+ mode = _normalize_prompt_cache_mode(state.fields.get("prompt_cache_mode", "manual"))
1370
+ compaction = _cache_aware_summary_text(state)
1371
+ policy, policy_error = _resolved_cache_policy_with_error_for_state(state)
1372
+ policy_summary = _cache_policy_summary_text(policy, compact=True, error=policy_error)
1373
+ if mode == "off":
1374
+ return f"cache off · {policy_summary} · {compaction}"
1375
+ if mode == "auto":
1376
+ ttl = _normalize_anthropic_prompt_cache_ttl(
1377
+ state.fields.get("anthropic_prompt_cache_ttl", "5m")
1378
+ )
1379
+ return f"auto · {policy_summary} · Anthropic TTL {ttl} · {compaction}"
1380
+
1381
+ key = str(state.fields.get("prompt_cache_key", "") or "").strip()
1382
+ retention = str(state.fields.get("prompt_cache_retention", "") or "").strip()
1383
+ key_summary = key or "no manual key"
1384
+ retention_summary = retention or "default retention"
1385
+ anthropic = (
1386
+ "on"
1387
+ if _normalize_bool_text(
1388
+ state.fields.get("anthropic_prompt_cache_enabled", "false"),
1389
+ label="Anthropic prompt cache",
1390
+ )
1391
+ else "off"
1392
+ )
1393
+ return (
1394
+ f"manual · {policy_summary} · {key_summary} · {retention_summary} · "
1395
+ f"Anthropic {anthropic} · {compaction}"
1396
+ )
1397
+
1398
+
1399
+ def _effective_cache_capability_for_state(
1400
+ state: ConfigMenuState,
1401
+ ) -> EffectiveCacheCapability | None:
1402
+ if not state.active_profile or state.active_profile not in state.profiles:
1403
+ return None
1404
+ try:
1405
+ profile = ProfileSpec.from_dict(state.active_profile, state.profiles[state.active_profile])
1406
+ preview_profile = ProfileSpec(
1407
+ name=profile.name,
1408
+ protocol=profile.protocol or OPENAI_COMPAT_PROTOCOL,
1409
+ base_url=str(state.fields.get("base_url", profile.base_url) or profile.base_url),
1410
+ api_key_env=profile.api_key_env,
1411
+ auth_provider=profile.auth_provider,
1412
+ extra_headers=dict(profile.extra_headers),
1413
+ default_model=str(
1414
+ state.fields.get("model", profile.default_model) or profile.default_model
1415
+ ),
1416
+ reasoning_effort=profile.reasoning_effort,
1417
+ web_search_adapter=profile.web_search_adapter,
1418
+ web_search_model=profile.web_search_model,
1419
+ notes=profile.notes,
1420
+ cache_capability=profile.cache_capability,
1421
+ reasoning_trace_adapter=profile.reasoning_trace_adapter,
1422
+ )
1423
+ cfg = state._resolution_cfg()
1424
+ model = str(preview_profile.default_model or getattr(cfg, "model", "") or "").strip()
1425
+ base_url = str(preview_profile.base_url or getattr(cfg, "base_url", "") or "").strip()
1426
+ provider_key = (
1427
+ resolve_model_provider_key(
1428
+ cfg=cfg,
1429
+ model_name=model,
1430
+ base_url=base_url,
1431
+ profile_name=preview_profile.name,
1432
+ )
1433
+ or preview_profile.name
1434
+ )
1435
+ protocol = str(preview_profile.protocol or OPENAI_COMPAT_PROTOCOL).strip()
1436
+ capabilities = get_provider_protocol_capabilities(
1437
+ provider_key=provider_key,
1438
+ protocol=protocol,
1439
+ )
1440
+ preset = find_preset_for_profile(preview_profile)
1441
+ return resolve_effective_cache_capability(
1442
+ provider_key=provider_key,
1443
+ protocol=protocol,
1444
+ model=model,
1445
+ base_url=base_url,
1446
+ transport_capabilities=capabilities,
1447
+ preset_cache_capability=(preset.cache_capability if preset is not None else None),
1448
+ profile_cache_capability=preview_profile.cache_capability,
1449
+ )
1450
+ except Exception as exc:
1451
+ _LOGGER.warning(
1452
+ "Effective cache capability resolution failed for profile %s: %s",
1453
+ state.active_profile,
1454
+ exc,
1455
+ )
1456
+ return None
1457
+
1458
+
1459
+ def _resolved_cache_policy_for_state(
1460
+ state: ConfigMenuState,
1461
+ ) -> ResolvedPromptCachePolicy | None:
1462
+ policy, _error = _resolved_cache_policy_with_error_for_state(state)
1463
+ return policy
1464
+
1465
+
1466
+ def _resolved_cache_policy_with_error_for_state(
1467
+ state: ConfigMenuState,
1468
+ ) -> tuple[ResolvedPromptCachePolicy | None, str | None]:
1469
+ if not state.active_profile or state.active_profile not in state.profiles:
1470
+ return None, None
1471
+ try:
1472
+ profile = ProfileSpec.from_dict(state.active_profile, state.profiles[state.active_profile])
1473
+ cfg = state._resolution_cfg()
1474
+ model = str(state.fields.get("model", profile.default_model) or profile.default_model)
1475
+ base_url = str(state.fields.get("base_url", profile.base_url) or profile.base_url)
1476
+ provider_key = (
1477
+ resolve_model_provider_key(
1478
+ cfg=cfg,
1479
+ model_name=model,
1480
+ base_url=base_url,
1481
+ profile_name=profile.name,
1482
+ )
1483
+ or profile.name
1484
+ )
1485
+ protocol = str(profile.protocol or OPENAI_COMPAT_PROTOCOL).strip()
1486
+ capabilities = get_provider_protocol_capabilities(
1487
+ provider_key=provider_key,
1488
+ protocol=protocol,
1489
+ )
1490
+ cache_capability = _effective_cache_capability_for_state(state)
1491
+ policy = resolve_prompt_cache_policy(
1492
+ cfg=cfg,
1493
+ capabilities=capabilities,
1494
+ provider_key=provider_key,
1495
+ protocol=protocol,
1496
+ model=model,
1497
+ prompt_cache_key=str(state.fields.get("prompt_cache_key", "") or ""),
1498
+ prompt_cache_retention=str(state.fields.get("prompt_cache_retention", "") or ""),
1499
+ prompt_cache_namespace=None,
1500
+ cache_capability=cache_capability,
1501
+ )
1502
+ return policy, None
1503
+ except Exception as exc:
1504
+ _LOGGER.warning(
1505
+ "Prompt cache policy resolution failed for profile %s: %s",
1506
+ state.active_profile,
1507
+ exc,
1508
+ )
1509
+ return None, str(exc)
1510
+
1511
+
1512
+ def _cache_policy_summary_text(
1513
+ policy: ResolvedPromptCachePolicy | None,
1514
+ *,
1515
+ compact: bool,
1516
+ error: str | None = None,
1517
+ ) -> str:
1518
+ if policy is None:
1519
+ if error:
1520
+ return f"policy unavailable: {error}"
1521
+ return "policy unknown"
1522
+ fields = ", ".join(policy.emitted_fields) if policy.emitted_fields else "no fields"
1523
+ if compact:
1524
+ return f"{policy.status} {policy.strategy}"
1525
+ allowed = ", ".join(policy.allowed_fields) if policy.allowed_fields else "none"
1526
+ usage = ", ".join(policy.trusted_usage_fields) if policy.trusted_usage_fields else "none"
1527
+ return (
1528
+ f"{policy.status}; strategy={policy.strategy}; source={policy.capability_source}; "
1529
+ f"allowed={allowed}; emits={fields}; usage={usage}"
1530
+ )
1531
+
1532
+
1533
+ def _cache_aware_summary_text(state: ConfigMenuState) -> str:
1534
+ aware = (
1535
+ "on"
1536
+ if _normalize_bool_text(
1537
+ state.fields.get("cache_aware_compaction", "true"),
1538
+ label="Cache-aware compaction",
1539
+ )
1540
+ else "off"
1541
+ )
1542
+ ratio = _normalize_cache_aware_min_trigger_ratio(
1543
+ state.fields.get("cache_aware_min_trigger_ratio", _CACHE_AWARE_MIN_TRIGGER_RATIO_DEFAULT)
1544
+ )
1545
+ return f"compaction {aware} · min {ratio}"
1546
+
1547
+
1548
+ def _override_summary_text(values: dict[str, str]) -> str:
1549
+ count = sum(1 for value in values.values() if str(value).strip())
1550
+ if count <= 0:
1551
+ return "none"
1552
+ return _pluralize(count, "override")
1553
+
1554
+
1555
+ def _web_search_summary_text(state: ConfigMenuState) -> str:
1556
+ policy = normalize_web_search_policy(state.fields.get("web_search_policy", "auto"))
1557
+ mode = _normalize_web_search_mode(state.fields.get("web_search_mode", "auto"))
1558
+ adapter = normalize_web_search_adapter(state.fields.get("web_search_adapter", "auto"))
1559
+ suffix = f" / adapter {adapter}" if adapter != "auto" else ""
1560
+ access = "model decides" if policy == "auto" else "off"
1561
+ return f"access {access} / backend {mode}{suffix}"
1562
+
1563
+
1564
+ def _web_search_policy_rows() -> list[tuple[str, str, str]]:
1565
+ return [
1566
+ (
1567
+ "auto",
1568
+ "Model decides (recommended)",
1569
+ "Expose web search to the active model and let it decide when external evidence is needed.",
1570
+ ),
1571
+ ("off", "Off", "Do not expose web search to the active model."),
1572
+ ]
1573
+
1574
+
1575
+ def _web_search_mode_rows() -> list[tuple[str, str, str]]:
1576
+ return [
1577
+ (
1578
+ "auto",
1579
+ "Auto (recommended)",
1580
+ "Use provider-native search when ready, otherwise use configured external search.",
1581
+ ),
1582
+ (
1583
+ "external",
1584
+ "External",
1585
+ "Use model-independent search only; Tavily requires an external search API key.",
1586
+ ),
1587
+ (
1588
+ "native",
1589
+ "Provider native",
1590
+ "Use only the active model provider's supported search adapter.",
1591
+ ),
1592
+ ("off", "Off", "Disable all web search backends."),
1593
+ ]
1594
+
1595
+
1596
+ def _top_level_menu_rows(state: ConfigMenuState) -> list[tuple[str, str, str]]:
1597
+ subagent_values = {
1598
+ **{role: state.role_models.get(role, "") for role in ROLE_ORDER},
1599
+ **{f"{role}_temperature": value for role, value in state.role_temperatures.items()},
1600
+ }
1601
+ delegated = state.execution_backend == "delegated" and not _is_direct_subscription_id(
1602
+ state.execution_runtime
1603
+ )
1604
+ direct_subscription = state.execution_backend == "delegated" and _is_direct_subscription_id(
1605
+ state.execution_runtime
1606
+ )
1607
+ profile_summary = _profile_summary_text(state)
1608
+ api_key_summary = _api_key_summary_text(state)
1609
+ model_summary = _default_model_summary_text(state)
1610
+ if delegated:
1611
+ profile_summary = _inactive_native_summary(profile_summary)
1612
+ api_key_summary = _inactive_native_summary(api_key_summary)
1613
+ model_summary = _inactive_native_summary(model_summary)
1614
+ elif direct_subscription:
1615
+ api_key_summary = "not used · managed by AI subscription"
1616
+ native_suffix = " (inactive)" if delegated else ""
1617
+ api_key_suffix = " (not used)" if direct_subscription else native_suffix
1618
+ return [
1619
+ ("profile", f"Provider Profile{native_suffix}", profile_summary),
1620
+ ("api_key", f"API Key{api_key_suffix}", api_key_summary),
1621
+ ("default", f"Default Model{native_suffix}", model_summary),
1622
+ ("web_search", "Web Search", _web_search_summary_text(state)),
1623
+ ("cache", "Context & Cache", _cache_summary_text(state)),
1624
+ ("subagents", "Subagent model overrides", _override_summary_text(subagent_values)),
1625
+ (
1626
+ "forge",
1627
+ "Forge model overrides",
1628
+ _override_summary_text(state.forge_role_models),
1629
+ ),
1630
+ ("sandbox", "Sandbox", _sandbox_summary_text(state)),
1631
+ ("personas", "Personas", _persona_summary_text(state)),
1632
+ ("execution", "Model Access", _execution_summary_text(state)),
1633
+ ]
1634
+
1635
+
1636
+ def _run_execution_section(state: ConfigMenuState, console: Console) -> None:
1637
+ while True:
1638
+ selected_backend = _run_config_picker(
1639
+ console=console,
1640
+ title="Model Access",
1641
+ subtitle="How would you like to connect Alysis Code to AI models?",
1642
+ rows=[
1643
+ (
1644
+ "native",
1645
+ "Use an API key",
1646
+ "Connect directly to a supported model provider.",
1647
+ ),
1648
+ (
1649
+ "delegated",
1650
+ "Use an AI subscription",
1651
+ "Sign in through a supported provider connection; API-key settings stay saved.",
1652
+ ),
1653
+ ],
1654
+ current_value=state.execution_backend,
1655
+ )
1656
+ if selected_backend is None:
1657
+ _print_section_cancelled(console, "Model Access")
1658
+ return
1659
+ if selected_backend == "native":
1660
+ state.set_execution_backend("native")
1661
+ console.print("[green]Model access:[/green] API key. Save to apply.")
1662
+ return
1663
+
1664
+ rows = _runtime_setup_rows()
1665
+ if not rows:
1666
+ console.print(
1667
+ "[yellow]No AI subscription connections are available in this build.[/yellow]"
1668
+ )
1669
+ return
1670
+ current_runtime = state.execution_runtime
1671
+ if current_runtime not in {value for value, _label, _description in rows}:
1672
+ current_runtime = rows[0][0]
1673
+ selected_runtime = _run_config_picker(
1674
+ console=console,
1675
+ title="AI Subscription",
1676
+ subtitle=(
1677
+ "Connect during setup (or with `alysis auth login`), then choose the "
1678
+ "subscription model and reasoning effort in Default Model."
1679
+ ),
1680
+ rows=rows,
1681
+ current_value=current_runtime,
1682
+ )
1683
+ if selected_runtime is None:
1684
+ continue
1685
+ state.set_execution_backend("delegated", runtime=selected_runtime)
1686
+ runtime_label = next(
1687
+ (label for value, label, _description in rows if value == selected_runtime),
1688
+ selected_runtime,
1689
+ )
1690
+ console.print(
1691
+ f"[green]Model access:[/green] AI subscription via {escape(runtime_label)}. "
1692
+ "Save to apply."
1693
+ )
1694
+ _run_subscription_account_section(selected_runtime, console)
1695
+ return
1696
+
1697
+
1698
+ def _run_subscription_account_section(provider_id: str, console: Console) -> None:
1699
+ from ..provider_auth import ProviderAuthError, create_provider_auth
1700
+
1701
+ try:
1702
+ adapter = create_provider_auth(provider_id)
1703
+ except (ProviderAuthError, ValueError) as exc:
1704
+ console.print(f"[red]Subscription connection unavailable:[/red] {escape(str(exc))}")
1705
+ return
1706
+ while True:
1707
+ try:
1708
+ status = adapter.account_status()
1709
+ except ProviderAuthError as exc:
1710
+ console.print(f"[red]Authentication status failed:[/red] {escape(str(exc))}")
1711
+ return
1712
+ account = status.account_label or ("connected" if status.connected else "not connected")
1713
+ rows = [
1714
+ (
1715
+ "connect",
1716
+ "Reconnect / switch account" if status.connected else "Connect account",
1717
+ "Open the provider sign-in flow in your browser.",
1718
+ )
1719
+ ]
1720
+ if status.connected:
1721
+ rows.append(
1722
+ (
1723
+ "disconnect",
1724
+ "Disconnect account",
1725
+ "Remove locally stored subscription credentials.",
1726
+ )
1727
+ )
1728
+ rows.append(("back", "Back", "Return to configuration."))
1729
+ action = _run_config_picker(
1730
+ console=console,
1731
+ title="AI Subscription Account",
1732
+ subtitle=f"Account: {account}",
1733
+ rows=rows,
1734
+ current_value="back",
1735
+ )
1736
+ if action in {None, "back"}:
1737
+ return
1738
+ try:
1739
+ if action == "disconnect":
1740
+ if not _prompt_yes_no("Disconnect this AI subscription account? [y/N]"):
1741
+ continue
1742
+ result = adapter.logout()
1743
+ console.print(
1744
+ f"[yellow]{escape(result.detail or 'Disconnected locally.')}[/yellow]"
1745
+ )
1746
+ continue
1747
+ if status.connected:
1748
+ adapter.logout()
1749
+ result = adapter.login(
1750
+ method="browser",
1751
+ output_write=lambda message: console.print(message, highlight=False),
1752
+ )
1753
+ if not result.connected:
1754
+ raise ProviderAuthError(result.detail or "Provider sign-in did not complete.")
1755
+ console.print(
1756
+ f"[green]Connected:[/green] {escape(result.account_label or provider_id)}"
1757
+ )
1758
+ except ProviderAuthError as exc:
1759
+ console.print(f"[red]Subscription account action failed:[/red] {escape(str(exc))}")
1760
+
1761
+
1762
+ def _sandbox_summary_text(state: ConfigMenuState) -> str:
1763
+ mode = normalize_sandbox_mode(state.fields.get("sandbox_mode", "strict"))
1764
+ base = {
1765
+ "strict": "strict · sandboxed (recommended)",
1766
+ "warn": "warn · sandbox, fail-closed if missing",
1767
+ "off": "off · host execution (less safe)",
1768
+ }[mode]
1769
+ if _sandbox_mode_env_override() is not None:
1770
+ return f"{base} · overridden by env"
1771
+ return base
1772
+
1773
+
1774
+ def _sandbox_mode_env_override() -> str | None:
1775
+ for name in ("ALYSIS_SHELL_SANDBOX_MODE", "ALYSIS_VERIFY_SANDBOX_MODE"):
1776
+ value = str(env_get(name) or "").strip()
1777
+ if value:
1778
+ return f"{name}={value}"
1779
+ return None
1780
+
1781
+
1782
+ def _print_section_cancelled(console: Console, section_name: str) -> None:
1783
+ console.print(f'[dim]Section "{section_name}" cancelled.[/dim]')
1784
+
1785
+
1786
+ def _run_web_search_section(state: ConfigMenuState, console: Console) -> None:
1787
+ fields_snapshot = dict(state.fields)
1788
+ try:
1789
+ policy = _run_config_picker(
1790
+ console=console,
1791
+ title="Web Search",
1792
+ subtitle="When Alysis Code should search before asking the selected model to answer.",
1793
+ rows=_web_search_policy_rows(),
1794
+ current_value=normalize_web_search_policy(
1795
+ state.fields.get("web_search_policy", "auto")
1796
+ ),
1797
+ )
1798
+ if policy is None:
1799
+ raise Abort()
1800
+ state.set_field("web_search_policy", normalize_web_search_policy(policy))
1801
+
1802
+ mode = _run_config_picker(
1803
+ console=console,
1804
+ title="Web Search Backend",
1805
+ subtitle=(
1806
+ "How Alysis Code executes searches. Auto can use model-independent Tavily "
1807
+ "when an external search key is configured."
1808
+ ),
1809
+ rows=_web_search_mode_rows(),
1810
+ current_value=_normalize_web_search_mode(state.fields.get("web_search_mode", "auto")),
1811
+ )
1812
+ if mode is None:
1813
+ raise Abort()
1814
+ state.set_field("web_search_mode", _normalize_web_search_mode(mode))
1815
+ except (Abort, EOFError, KeyboardInterrupt):
1816
+ state.fields = fields_snapshot
1817
+ _print_section_cancelled(console, "Web Search")
1818
+ return
1819
+ console.print("[green]Web search settings updated.[/green] Save to apply.")
1820
+
1821
+
1822
+ def _persona_summary_text(state: ConfigMenuState) -> str:
1823
+ persona = str(state.fields.get("default_persona") or "code").strip().lower()
1824
+ labels = {
1825
+ "code": "code · implementation (default)",
1826
+ "architect": "architect · plans, writes markdown only",
1827
+ "ask": "ask · read-only questions",
1828
+ "debug": "debug · reproduce-before-fix",
1829
+ }
1830
+ return labels.get(persona, persona)
1831
+
1832
+
1833
+ def _run_personas_section(state: ConfigMenuState, console: Console) -> None:
1834
+ current = str(state.fields.get("default_persona") or "code").strip().lower()
1835
+ rows = [
1836
+ ("code", "Code (default)", "Implementation work; keeps your execution mode."),
1837
+ ("architect", "Architect", "Plans and designs; writes markdown documents only."),
1838
+ ("ask", "Ask", "Read-only questions with inspection tools."),
1839
+ ("debug", "Debug", "Reproduce-before-fix; keeps your execution mode."),
1840
+ ]
1841
+ value = _run_config_picker(
1842
+ console=console,
1843
+ title="Personas",
1844
+ subtitle=(
1845
+ "The persona chat starts in. Switch live with /persona or Tab; "
1846
+ "per-persona model roles: config set persona_models.<persona> <role>."
1847
+ ),
1848
+ rows=rows,
1849
+ current_value=current,
1850
+ )
1851
+ if value is None:
1852
+ _print_section_cancelled(console, "Personas")
1853
+ return
1854
+ state.fields["default_persona"] = str(value).strip().lower()
1855
+
1856
+
1857
+ def _run_sandbox_section(state: ConfigMenuState, console: Console) -> None:
1858
+ override = _sandbox_mode_env_override()
1859
+ if override is not None:
1860
+ console.print(
1861
+ f"[yellow]Note: {override} is set in your environment and overrides "
1862
+ "this config value at runtime.[/yellow]"
1863
+ )
1864
+ current = normalize_sandbox_mode(state.fields.get("sandbox_mode", "strict"))
1865
+ rows = [
1866
+ (
1867
+ "strict",
1868
+ "Strict (recommended)",
1869
+ "Run shell & verification commands inside a sandbox (bubblewrap/Docker).",
1870
+ ),
1871
+ (
1872
+ "warn",
1873
+ "Warn",
1874
+ "Try the sandbox; fail closed if no backend (no host fallback).",
1875
+ ),
1876
+ (
1877
+ "off",
1878
+ "Off (less safe)",
1879
+ "Run commands directly on the host shell. No isolation.",
1880
+ ),
1881
+ ]
1882
+ value = _run_config_picker(
1883
+ console=console,
1884
+ title="Sandbox",
1885
+ subtitle="How Alysis Code isolates shell and verification commands.",
1886
+ rows=rows,
1887
+ current_value=current,
1888
+ )
1889
+ if value is None:
1890
+ _print_section_cancelled(console, "Sandbox")
1891
+ return
1892
+ normalized = normalize_sandbox_mode(value)
1893
+ state.fields["sandbox_mode"] = normalized
1894
+ if normalized == "off":
1895
+ console.print(
1896
+ "[yellow]Sandbox set to off - commands will run on the host shell. "
1897
+ "Save to apply.[/yellow]"
1898
+ )
1899
+ else:
1900
+ console.print(f"[green]Sandbox mode: {normalized}.[/green] Save to apply.")
1901
+
1902
+
1903
+ def _summary_is_markup(summary: str) -> bool:
1904
+ return summary in _WARNING_SUMMARIES
1905
+
1906
+
1907
+ def _pluralize(count: int, singular: str, plural: str | None = None) -> str:
1908
+ word = singular if count == 1 else (plural or f"{singular}s")
1909
+ return f"{count} {word}"
1910
+
1911
+
1912
+ def _role_label(role: str) -> str:
1913
+ return str(role).replace("_", " ").capitalize()
1914
+
1915
+
1916
+ def _role_description(role: str) -> str:
1917
+ return _ROLE_DESCRIPTIONS.get(str(role), "role-specific work")
1918
+
1919
+
1920
+ def _print_role_explainer(console: Console, *, roles: tuple[str, ...] = ROLE_ORDER) -> None:
1921
+ from rich.table import Table
1922
+
1923
+ console.print("[dim]Roles:[/dim]")
1924
+ table = Table(show_header=False, box=None, padding=(0, 2), collapse_padding=True)
1925
+ table.add_column("role", no_wrap=True, style="dim")
1926
+ table.add_column("description", no_wrap=False, style="dim")
1927
+ for role in roles:
1928
+ table.add_row(role, _role_description(role))
1929
+ console.print(table)
1930
+
1931
+
1932
+ def _config_picker_hint(*, row_count: int, allow_save: bool = False) -> str:
1933
+ jump_limit = min(max(row_count, 1), 9)
1934
+ base = f"↑/↓ navigate Enter select 1-{jump_limit} jump"
1935
+ if allow_save:
1936
+ return f"{base} s save q exit"
1937
+ return f"{base} Esc back"
1938
+
1939
+
1940
+ def _build_config_picker_panel(
1941
+ *,
1942
+ title: str,
1943
+ subtitle: str,
1944
+ rows: list[tuple[str, str, str]],
1945
+ selected_value: str | None,
1946
+ interactive: bool,
1947
+ footer_hint: str | None = None,
1948
+ ) -> Panel:
1949
+ from rich.console import Group
1950
+ from rich.table import Table
1951
+ from rich.text import Text
1952
+
1953
+ selected = str(selected_value or "").strip().casefold()
1954
+ table = Table(show_header=False, box=None, expand=True, padding=(0, 1), collapse_padding=True)
1955
+ table.add_column("option", no_wrap=True, ratio=3)
1956
+ table.add_column("description", no_wrap=False, ratio=5, overflow="fold")
1957
+
1958
+ for index, (value, label, description) in enumerate(rows, start=1):
1959
+ row_selected = str(value).strip().casefold() == selected
1960
+ marker = "> " if row_selected else " "
1961
+ option_style = "bold cyan" if row_selected else STYLE_CONTENT
1962
+ desc_style = STYLE_CONTENT if row_selected else STYLE_DIM
1963
+ table.add_row(
1964
+ Text(f"{marker}{index}) {label}", style=option_style),
1965
+ Text(str(description or ""), style=desc_style),
1966
+ )
1967
+
1968
+ renderables: list[Any] = []
1969
+ if str(subtitle or "").strip():
1970
+ renderables.append(Text(str(subtitle), style="dim"))
1971
+ renderables.append(Text(""))
1972
+ renderables.append(table)
1973
+ renderables.append(Text(""))
1974
+ renderables.append(
1975
+ Text(
1976
+ footer_hint or _config_picker_hint(row_count=len(rows)),
1977
+ style="dim",
1978
+ )
1979
+ )
1980
+ return Panel(
1981
+ Group(*renderables),
1982
+ title=title,
1983
+ border_style="cyan" if interactive else "dim",
1984
+ )
1985
+
1986
+
1987
+ def _build_config_top_level_panel(
1988
+ *,
1989
+ state: ConfigMenuState,
1990
+ selected_value: str | None,
1991
+ interactive: bool,
1992
+ unknown_key_message: str | None = None,
1993
+ ) -> Panel:
1994
+ from rich.console import Group
1995
+ from rich.table import Table
1996
+ from rich.text import Text
1997
+
1998
+ rows = _top_level_menu_rows(state)
1999
+ selected = str(selected_value or "").strip().casefold()
2000
+ pending_changes = _pending_change_count(state)
2001
+
2002
+ table = Table(show_header=False, box=None, expand=True, padding=(0, 1), collapse_padding=True)
2003
+ table.add_column("option", no_wrap=True, ratio=3)
2004
+ table.add_column("summary", no_wrap=False, ratio=5, overflow="fold")
2005
+ for index, (value, label, summary) in enumerate(rows, start=1):
2006
+ row_selected = str(value).strip().casefold() == selected
2007
+ marker = "> " if row_selected else " "
2008
+ option_style = "bold cyan" if row_selected else STYLE_CONTENT
2009
+ summary_style = STYLE_CONTENT if row_selected else STYLE_DIM
2010
+ summary_text = (
2011
+ Text.from_markup(summary)
2012
+ if _summary_is_markup(summary)
2013
+ else Text(summary, style=summary_style)
2014
+ )
2015
+ table.add_row(
2016
+ Text(f"{marker}{index}) {label}", style=option_style),
2017
+ summary_text,
2018
+ )
2019
+
2020
+ renderables: list[Any] = []
2021
+ if pending_changes > 0:
2022
+ renderables.append(Text(f"Pending changes: {pending_changes}", style="yellow"))
2023
+ else:
2024
+ renderables.append(Text("No pending changes.", style="dim"))
2025
+ if state.config_warning:
2026
+ renderables.append(Text(str(state.config_warning), style="yellow"))
2027
+ renderables.append(Text(""))
2028
+ renderables.append(table)
2029
+ renderables.append(Text(""))
2030
+ renderables.append(Text(_config_picker_hint(row_count=len(rows), allow_save=True), style="dim"))
2031
+ if unknown_key_message:
2032
+ renderables.append(Text(f"Unknown key: {unknown_key_message}", style="red"))
2033
+ return Panel(
2034
+ Group(*renderables),
2035
+ title="Alysis Code Configuration",
2036
+ border_style="cyan" if interactive else "dim",
2037
+ )
2038
+
2039
+
2040
+ def _prompt_inline_choice_fallback(
2041
+ *,
2042
+ console: Console,
2043
+ title: str,
2044
+ text: str,
2045
+ choices: list[tuple[str, str]],
2046
+ default: str | None = None,
2047
+ prompt_label: str | None = None,
2048
+ fallback_reason: str | None = None,
2049
+ ) -> str | None:
2050
+ if not choices:
2051
+ return None
2052
+ error_message: str | None = None
2053
+ while True:
2054
+ console.print()
2055
+ console.print(f"[bold]{title}[/bold]")
2056
+ if fallback_reason:
2057
+ console.print(f"[dim]{fallback_reason}[/dim]")
2058
+ fallback_reason = None
2059
+ if error_message:
2060
+ console.print(f"[red]{error_message}[/red]")
2061
+ if text:
2062
+ console.print(f"[dim]{text}[/dim]")
2063
+ console.print()
2064
+ for index, (value, label) in enumerate(choices, start=1):
2065
+ suffix = " [dim](current)[/dim]" if default and value == default else ""
2066
+ console.print(f" {index}) {label}{suffix}")
2067
+ footer = prompt_label or f"[1-{len(choices)}] choose [Enter/q] back"
2068
+ console.print()
2069
+ console.print(f"[dim]{footer}[/dim]")
2070
+ try:
2071
+ raw = str(typer.prompt("Choice", default="", show_default=False)).strip()
2072
+ except (Abort, EOFError, KeyboardInterrupt):
2073
+ console.print("")
2074
+ return None
2075
+ if not raw:
2076
+ return None
2077
+ normalized = raw.casefold()
2078
+ if normalized in {"q", "quit", "cancel", "c"}:
2079
+ return None
2080
+ if raw.isdigit():
2081
+ index = int(raw) - 1
2082
+ if 0 <= index < len(choices):
2083
+ return choices[index][0]
2084
+ error_message = "Unknown choice."
2085
+
2086
+
2087
+ def _prompt_inline_choice(
2088
+ *,
2089
+ console: Console,
2090
+ title: str,
2091
+ text: str,
2092
+ choices: list[tuple[str, str]],
2093
+ default: str | None = None,
2094
+ prompt_label: str | None = None,
2095
+ ) -> str | None:
2096
+ return _prompt_inline_choice_fallback(
2097
+ console=console,
2098
+ title=title,
2099
+ text=text,
2100
+ choices=choices,
2101
+ default=default,
2102
+ prompt_label=prompt_label,
2103
+ )
2104
+
2105
+
2106
+ def _prompt_main_action_fallback(console: Console, state: ConfigMenuState) -> str:
2107
+ error_message: str | None = None
2108
+ while True:
2109
+ rows = _top_level_menu_rows(state)
2110
+ pending_changes = _pending_change_count(state)
2111
+ console.print()
2112
+ if error_message:
2113
+ console.print(f"[red]{error_message}[/red]")
2114
+ console.rule("[bold]Alysis Code Configuration[/bold]")
2115
+ if pending_changes > 0:
2116
+ console.print(f"[yellow]Pending changes: {pending_changes}[/yellow]")
2117
+ else:
2118
+ console.print("[dim]No pending changes.[/dim]")
2119
+ if state.config_warning:
2120
+ console.print(f"[yellow]{escape(str(state.config_warning))}[/yellow]")
2121
+ console.print()
2122
+ for index, (_value, label, summary) in enumerate(rows, start=1):
2123
+ summary_text = (
2124
+ summary if _summary_is_markup(summary) else f"[dim]{escape(summary)}[/dim]"
2125
+ )
2126
+ console.print(f" {index}) [bold]{label}[/bold] {summary_text}")
2127
+ console.print()
2128
+ jump_hint = f"[1-{len(rows)}]"
2129
+ if state.dirty:
2130
+ console.print(f"[dim]{jump_hint} edit [s] save [q] cancel [Enter] save & exit[/dim]")
2131
+ else:
2132
+ console.print(f"[dim]{jump_hint} edit [s] save [q] cancel [Enter] cancel[/dim]")
2133
+ try:
2134
+ raw = str(typer.prompt("Choice", default="", show_default=False)).strip()
2135
+ except (Abort, EOFError, KeyboardInterrupt):
2136
+ console.print("")
2137
+ return "cancel"
2138
+ if not raw:
2139
+ return "save" if state.dirty else "cancel"
2140
+ normalized = raw.casefold()
2141
+ if normalized in {"s", "save"}:
2142
+ return "save"
2143
+ if normalized in {"q", "quit", "cancel", "c"}:
2144
+ return "cancel"
2145
+ if raw.isdigit():
2146
+ index = int(raw) - 1
2147
+ if 0 <= index < len(rows):
2148
+ return rows[index][0]
2149
+ error_message = "Unknown choice."
2150
+
2151
+
2152
+ def _try_run_config_live_menu(
2153
+ *,
2154
+ console: Console,
2155
+ rows: list[tuple[str, str, str]],
2156
+ current_value: str,
2157
+ panel_builder: Callable[[str | None, bool], Panel],
2158
+ unknown_key_panel_builder: Callable[[str | None, bool, str | None], Panel] | None = None,
2159
+ confirm_on_digit: bool = False,
2160
+ command_hotkeys: dict[str, str] | None = None,
2161
+ accept_right: bool = False,
2162
+ too_small_result: str | None = None,
2163
+ ) -> tuple[str | None, bool, str | None]:
2164
+ try:
2165
+ from ..cli import (
2166
+ _clear_terminal_screen,
2167
+ _is_non_interactive_terminal,
2168
+ _read_input_keys_with_timeout,
2169
+ _terminal_too_small,
2170
+ _terminal_too_small_panel,
2171
+ _watch_terminal_resize,
2172
+ )
2173
+ except Exception as exc:
2174
+ return None, False, str(exc)
2175
+
2176
+ if _is_non_interactive_terminal():
2177
+ return None, False, "non-interactive terminal"
2178
+
2179
+ try:
2180
+ from prompt_toolkit.input import create_input
2181
+ from prompt_toolkit.keys import Keys
2182
+ from rich.live import Live
2183
+ except Exception as exc:
2184
+ return None, False, str(exc)
2185
+
2186
+ if not rows:
2187
+ return None, True, None
2188
+
2189
+ if _terminal_too_small():
2190
+ console.print(_terminal_too_small_panel())
2191
+ return too_small_result, True, None
2192
+
2193
+ selected_index = next(
2194
+ (idx for idx, (value, _label, _desc) in enumerate(rows) if value == current_value),
2195
+ 0,
2196
+ )
2197
+ if selected_index < 0 or selected_index >= len(rows):
2198
+ selected_index = 0
2199
+
2200
+ input_reader: Any | None = None
2201
+ try:
2202
+ input_reader = create_input()
2203
+ except Exception as exc:
2204
+ return None, False, str(exc)
2205
+
2206
+ def _selected_value() -> str:
2207
+ return rows[selected_index][0]
2208
+
2209
+ last_unknown_key: str | None = None
2210
+
2211
+ def _panel() -> Panel:
2212
+ if unknown_key_panel_builder is not None:
2213
+ return unknown_key_panel_builder(_selected_value(), True, last_unknown_key)
2214
+ return panel_builder(_selected_value(), True)
2215
+
2216
+ def _hotkey_for(key: Any, raw_data: Any) -> str | None:
2217
+ if isinstance(raw_data, str) and len(raw_data) == 1 and raw_data.isprintable():
2218
+ return raw_data.lower()
2219
+ if isinstance(key, str) and len(key) == 1 and key.isprintable():
2220
+ return key.lower()
2221
+ return None
2222
+
2223
+ def _is_escape(key: Any) -> bool:
2224
+ return bool(key == Keys.Escape or key == Keys.ControlC or key in {"escape", "c-c"})
2225
+
2226
+ try:
2227
+ with _watch_terminal_resize() as consume_resize:
2228
+ with Live(
2229
+ _panel(),
2230
+ console=console,
2231
+ auto_refresh=False,
2232
+ transient=True,
2233
+ screen=False,
2234
+ ) as live:
2235
+ with input_reader.raw_mode():
2236
+ while True:
2237
+ if _terminal_too_small():
2238
+ console.print(_terminal_too_small_panel())
2239
+ return too_small_result, True, None
2240
+
2241
+ resized = consume_resize()
2242
+ if resized:
2243
+ _clear_terminal_screen(console=console)
2244
+ live.update(_panel(), refresh=True)
2245
+
2246
+ key_presses = _read_input_keys_with_timeout(
2247
+ input_reader=input_reader,
2248
+ timeout_s=0.12,
2249
+ )
2250
+ if not key_presses:
2251
+ continue
2252
+
2253
+ panel_updated = False
2254
+ for key_press in key_presses:
2255
+ key = key_press.key
2256
+ raw_data = key_press.data
2257
+
2258
+ if key == Keys.Up or key == "up":
2259
+ last_unknown_key = None
2260
+ selected_index = (selected_index - 1) % len(rows)
2261
+ panel_updated = True
2262
+ continue
2263
+ if key == Keys.Down or key == "down":
2264
+ last_unknown_key = None
2265
+ selected_index = (selected_index + 1) % len(rows)
2266
+ panel_updated = True
2267
+ continue
2268
+
2269
+ digit_key: str | None = None
2270
+ if isinstance(key, str) and key.isdigit():
2271
+ digit_key = key
2272
+ elif isinstance(raw_data, str) and raw_data.isdigit():
2273
+ digit_key = raw_data
2274
+ if digit_key is not None:
2275
+ last_unknown_key = None
2276
+ idx = int(digit_key) - 1
2277
+ if 0 <= idx < len(rows):
2278
+ selected_index = idx
2279
+ if confirm_on_digit:
2280
+ return _selected_value(), True, None
2281
+ panel_updated = True
2282
+ continue
2283
+
2284
+ if accept_right and (key == Keys.Right or key == "right"):
2285
+ return _selected_value(), True, None
2286
+
2287
+ if (
2288
+ key == Keys.Enter
2289
+ or key == Keys.ControlM
2290
+ or raw_data in {chr(13), chr(10)}
2291
+ ):
2292
+ return _selected_value(), True, None
2293
+
2294
+ if _is_escape(key):
2295
+ if command_hotkeys and "escape" in command_hotkeys:
2296
+ return command_hotkeys["escape"], True, None
2297
+ return None, True, None
2298
+
2299
+ hotkey = _hotkey_for(key, raw_data)
2300
+ if hotkey and command_hotkeys and hotkey in command_hotkeys:
2301
+ return command_hotkeys[hotkey], True, None
2302
+ if (
2303
+ hotkey
2304
+ and not hotkey.isdigit()
2305
+ and unknown_key_panel_builder is not None
2306
+ ):
2307
+ last_unknown_key = hotkey
2308
+ panel_updated = True
2309
+
2310
+ if panel_updated:
2311
+ live.update(_panel(), refresh=True)
2312
+ except Exception as exc:
2313
+ return None, False, str(exc)
2314
+ finally:
2315
+ if input_reader is not None:
2316
+ close = getattr(input_reader, "close", None)
2317
+ if callable(close):
2318
+ close()
2319
+
2320
+ return None, True, None
2321
+
2322
+
2323
+ def _run_config_picker(
2324
+ *,
2325
+ console: Console,
2326
+ title: str,
2327
+ subtitle: str,
2328
+ rows: list[tuple[str, str, str]],
2329
+ current_value: str,
2330
+ footer_hint: str | None = None,
2331
+ ) -> str | None:
2332
+ selected_value, interactive_available, reason = _try_run_config_live_menu(
2333
+ console=console,
2334
+ rows=rows,
2335
+ current_value=current_value,
2336
+ panel_builder=lambda selected, interactive: _build_config_picker_panel(
2337
+ title=title,
2338
+ subtitle=subtitle,
2339
+ rows=rows,
2340
+ selected_value=selected,
2341
+ interactive=interactive,
2342
+ footer_hint=footer_hint,
2343
+ ),
2344
+ confirm_on_digit=True,
2345
+ too_small_result=None,
2346
+ )
2347
+ if interactive_available:
2348
+ return selected_value
2349
+
2350
+ detail = reason or "unknown reason"
2351
+ console.print(f"[dim]Interactive picker unavailable: {detail}. Using numeric input.[/dim]")
2352
+ return _prompt_inline_choice(
2353
+ console=console,
2354
+ title=title,
2355
+ text=subtitle,
2356
+ choices=[(value, label) for value, label, _description in rows],
2357
+ default=current_value,
2358
+ prompt_label=footer_hint,
2359
+ )
2360
+
2361
+
2362
+ def _run_config_top_level(*, state: ConfigMenuState, console: Console) -> str:
2363
+ rows = _top_level_menu_rows(state)
2364
+ current_value = rows[0][0] if rows else "cancel"
2365
+ selected_value, interactive_available, reason = _try_run_config_live_menu(
2366
+ console=console,
2367
+ rows=rows,
2368
+ current_value=current_value,
2369
+ panel_builder=lambda selected, interactive: _build_config_top_level_panel(
2370
+ state=state,
2371
+ selected_value=selected,
2372
+ interactive=interactive,
2373
+ ),
2374
+ unknown_key_panel_builder=lambda selected, interactive, unknown_key: (
2375
+ _build_config_top_level_panel(
2376
+ state=state,
2377
+ selected_value=selected,
2378
+ interactive=interactive,
2379
+ unknown_key_message=unknown_key,
2380
+ )
2381
+ ),
2382
+ confirm_on_digit=True,
2383
+ command_hotkeys={
2384
+ "s": "save",
2385
+ "q": "cancel",
2386
+ "escape": "cancel",
2387
+ },
2388
+ accept_right=True,
2389
+ too_small_result="cancel",
2390
+ )
2391
+ if interactive_available and selected_value is not None:
2392
+ return selected_value
2393
+ if interactive_available:
2394
+ return "cancel"
2395
+
2396
+ detail = reason or "unknown reason"
2397
+ console.print(f"[dim]Interactive picker unavailable: {detail}. Using numeric input.[/dim]")
2398
+ return _prompt_main_action_fallback(console, state)
2399
+
2400
+
2401
+ def _prompt_main_action(console: Console, state: ConfigMenuState) -> str:
2402
+ return _run_config_top_level(state=state, console=console)
2403
+
2404
+
2405
+ def _run_provider_section(state: ConfigMenuState, console: Console) -> None:
2406
+ console.print()
2407
+ console.rule("[bold]Provider Profile[/bold]")
2408
+ if state.execution_backend == "delegated" and not _is_direct_subscription_id(
2409
+ state.execution_runtime
2410
+ ):
2411
+ console.print(
2412
+ "[yellow]API-key provider settings are preserved but inactive while an AI subscription is selected.[/yellow]"
2413
+ )
2414
+ console.print(
2415
+ "[dim]A profile bundles provider URL, headers, and the API key env variable. "
2416
+ "The active profile is what Alysis Code uses for all model calls.[/dim]"
2417
+ )
2418
+ for profile_name in sorted(state.profiles):
2419
+ marker = "active" if profile_name == state.active_profile else ""
2420
+ profile = ProfileSpec.from_dict(profile_name, state.profiles[profile_name])
2421
+ console.print(f"{profile.name}: {profile.base_url} {marker}".rstrip())
2422
+ try:
2423
+ action = _run_config_picker(
2424
+ console=console,
2425
+ title="Provider Profile",
2426
+ subtitle=f"Active profile: {state.active_profile or '(none)'}",
2427
+ rows=[
2428
+ ("switch", "Switch active profile", "Choose another configured profile."),
2429
+ ("add_preset", "Add from preset", "Pick a known provider (OpenAI, Anthropic, ...)"),
2430
+ ("add_custom", "Add custom", "Use any OpenAI-compatible base URL"),
2431
+ (
2432
+ "edit",
2433
+ "Edit current",
2434
+ "Change URL, model, trace adapter, headers, and notes",
2435
+ ),
2436
+ ("remove", "Remove", "Delete a profile (applied on save)"),
2437
+ ("back", "Back", "Return to the menu"),
2438
+ ],
2439
+ current_value="switch" if state.profiles else "add_preset",
2440
+ )
2441
+ if action in {None, "back"}:
2442
+ return
2443
+ if action == "switch":
2444
+ _run_profile_switch(state, console)
2445
+ elif action == "add_preset":
2446
+ _run_profile_add_preset(state, console)
2447
+ elif action == "add_custom":
2448
+ _run_profile_add_custom(state, console)
2449
+ elif action == "edit":
2450
+ _run_profile_edit_current(state, console)
2451
+ elif action == "remove":
2452
+ _run_profile_remove(state, console)
2453
+ except (Abort, EOFError, KeyboardInterrupt):
2454
+ console.print("")
2455
+ _print_section_cancelled(console, "Provider Profile")
2456
+ return
2457
+ except ConfigError as exc:
2458
+ console.print(f"[red]{exc}[/red]")
2459
+ return
2460
+
2461
+
2462
+ def _run_profile_switch(state: ConfigMenuState, console: Console) -> None:
2463
+ if not state.profiles:
2464
+ return
2465
+ rows = [(name, name, "") for name in sorted(state.profiles)]
2466
+ selected = _run_config_picker(
2467
+ console=console,
2468
+ title="Provider Profile",
2469
+ subtitle=f"Active: {state.active_profile or 'none'}",
2470
+ rows=rows,
2471
+ current_value=state.active_profile or rows[0][0],
2472
+ )
2473
+ if selected:
2474
+ state.set_active_profile_name(selected)
2475
+ staged_profile = state.staged_api_key_target_profile()
2476
+ if staged_profile and staged_profile != selected:
2477
+ console.print(
2478
+ f"[yellow]The unsaved API key remains bound to profile "
2479
+ f"{escape(staged_profile)}.[/yellow]"
2480
+ )
2481
+ if (
2482
+ state.clear_stored_key_confirmed
2483
+ and state.clear_stored_key_profile
2484
+ and state.clear_stored_key_profile != selected
2485
+ ):
2486
+ console.print(
2487
+ f"[yellow]Stored-key removal remains bound to profile "
2488
+ f"{escape(state.clear_stored_key_profile)}.[/yellow]"
2489
+ )
2490
+ _print_state_provider_diagnostic_warnings(state, console)
2491
+
2492
+
2493
+ def _run_profile_add_preset(state: ConfigMenuState, console: Console) -> None:
2494
+ rows = _provider_picker_rows(_ordered_profile_presets_for_setup())
2495
+ selected = _run_config_picker(
2496
+ console=console,
2497
+ title="Provider Profile",
2498
+ subtitle="Pick a provider preset",
2499
+ rows=rows,
2500
+ current_value=rows[0][0] if rows else "openai-responses",
2501
+ )
2502
+ if selected == _ADVANCED_PROVIDER_PRESETS_VALUE:
2503
+ advanced_rows = [
2504
+ (preset.key, preset_selection_label(preset), _preset_description(preset))
2505
+ for preset in _advanced_profile_presets_for_setup()
2506
+ ]
2507
+ selected = _run_config_picker(
2508
+ console=console,
2509
+ title="Advanced Provider Profile",
2510
+ subtitle="Pick a local, custom, or legacy OpenAI-compatible provider preset",
2511
+ rows=advanced_rows,
2512
+ current_value=advanced_rows[0][0] if advanced_rows else "openai",
2513
+ )
2514
+ if not selected:
2515
+ return
2516
+ preset = next(preset for preset in PROFILE_PRESETS if preset.key == selected)
2517
+ profile_name = _prompt_text("Profile name", preset.key)
2518
+ profile = make_profile_from_preset(preset, name=profile_name)
2519
+ if not profile.base_url:
2520
+ base_url = _prompt_text("Base URL", "")
2521
+ profile = ProfileSpec(
2522
+ name=profile.name,
2523
+ protocol=profile.protocol,
2524
+ base_url=base_url,
2525
+ api_key_env=profile.api_key_env,
2526
+ auth_provider=profile.auth_provider,
2527
+ extra_headers=profile.extra_headers,
2528
+ default_model=profile.default_model,
2529
+ reasoning_effort=profile.reasoning_effort,
2530
+ web_search_adapter=profile.web_search_adapter,
2531
+ web_search_model=profile.web_search_model,
2532
+ notes=profile.notes,
2533
+ cache_capability=profile.cache_capability,
2534
+ reasoning_trace_adapter=profile.reasoning_trace_adapter,
2535
+ )
2536
+ state.add_profile_spec(profile)
2537
+ console.print(f"[green]Profile {profile.name} added.[/green]")
2538
+ _print_preset_warning(console, preset)
2539
+ _print_state_provider_diagnostic_warnings(state, console)
2540
+
2541
+
2542
+ def _preset_description(preset: ProfilePreset) -> str:
2543
+ prefix = preset_protocol_summary(preset)
2544
+ if preset.notes:
2545
+ return f"{prefix}. {preset.notes}"
2546
+ parsed = urlparse(preset.base_url)
2547
+ if parsed.netloc:
2548
+ return f"{prefix}. Host: {parsed.netloc}"
2549
+ return f"{prefix}. Use any OpenAI-compatible base URL"
2550
+
2551
+
2552
+ def _ordered_profile_presets_for_setup() -> list[ProfilePreset]:
2553
+ return provider_selection_presets()
2554
+
2555
+
2556
+ def _advanced_profile_presets_for_setup() -> list[ProfilePreset]:
2557
+ return advanced_provider_selection_presets()
2558
+
2559
+
2560
+ def _provider_picker_rows(presets: list[ProfilePreset]) -> list[tuple[str, str, str]]:
2561
+ rows = [
2562
+ (preset.key, preset_selection_label(preset), _preset_description(preset))
2563
+ for preset in presets
2564
+ ]
2565
+ rows.append(
2566
+ (
2567
+ _ADVANCED_PROVIDER_PRESETS_VALUE,
2568
+ "Advanced / local / compatibility providers",
2569
+ "Show local endpoints (Ollama, LM Studio, vLLM), custom URLs, and legacy "
2570
+ "OpenAI-compatible first-party presets.",
2571
+ )
2572
+ )
2573
+ return rows
2574
+
2575
+
2576
+ def _print_preset_warning(console: Console, preset: ProfilePreset) -> None:
2577
+ warning = str(preset.setup_warning or "").strip()
2578
+ if warning:
2579
+ console.print(f"[yellow]{warning}[/yellow]")
2580
+
2581
+
2582
+ def _run_profile_add_custom(state: ConfigMenuState, console: Console) -> None:
2583
+ name = _prompt_text("Profile name", "custom")
2584
+ base_url = _prompt_text("Base URL", "")
2585
+ headers = _parse_header_text(_prompt_text("Extra headers (k=v, comma-separated)", ""))
2586
+ profile = ProfileSpec(
2587
+ name=name.lower(),
2588
+ protocol="openai_compat",
2589
+ base_url=base_url,
2590
+ extra_headers=headers,
2591
+ web_search_adapter="auto",
2592
+ web_search_model="",
2593
+ notes="Custom OpenAI-compatible endpoint.",
2594
+ )
2595
+ state.add_profile_spec(profile)
2596
+ console.print(f"[green]Profile {profile.name} added.[/green]")
2597
+ _print_state_provider_diagnostic_warnings(state, console)
2598
+
2599
+
2600
+ def _prompt_web_search_adapter(console: Console, current: str) -> str:
2601
+ for _attempt in range(3):
2602
+ value = _prompt_non_secret_text(
2603
+ console,
2604
+ "Web search adapter",
2605
+ current,
2606
+ "Web search adapter",
2607
+ )
2608
+ try:
2609
+ return normalize_web_search_adapter(value or "auto")
2610
+ except (ConfigError, ValueError) as e:
2611
+ allowed = ", ".join(WEB_SEARCH_ADAPTER_CHOICES)
2612
+ console.print(f"[red]{e}[/red]")
2613
+ console.print(f"[dim]Allowed adapters: {allowed}[/dim]")
2614
+ return normalize_web_search_adapter(current or "auto")
2615
+
2616
+
2617
+ def _prompt_reasoning_trace_adapter(
2618
+ console: Console,
2619
+ *,
2620
+ protocol: str,
2621
+ current: str,
2622
+ ) -> str:
2623
+ for _attempt in range(3):
2624
+ value = _prompt_non_secret_text(
2625
+ console,
2626
+ "Reasoning trace adapter",
2627
+ current,
2628
+ "Reasoning trace adapter",
2629
+ )
2630
+ try:
2631
+ return validate_reasoning_trace_adapter_for_protocol(
2632
+ protocol=protocol,
2633
+ adapter=value or "auto",
2634
+ )
2635
+ except (ConfigError, ValueError) as exc:
2636
+ console.print(f"[red]{exc}[/red]")
2637
+ return validate_reasoning_trace_adapter_for_protocol(
2638
+ protocol=protocol,
2639
+ adapter=current or "auto",
2640
+ )
2641
+
2642
+
2643
+ def _run_profile_edit_current(state: ConfigMenuState, console: Console) -> None:
2644
+ if not state.active_profile or state.active_profile not in state.profiles:
2645
+ console.print(
2646
+ "[yellow]No active profile selected. Switch to one first or add a new profile.[/yellow]"
2647
+ )
2648
+ return
2649
+ profile = ProfileSpec.from_dict(state.active_profile, state.profiles[state.active_profile])
2650
+ if profile.auth_provider:
2651
+ console.print(
2652
+ "[yellow]This subscription connection is provider-managed. Use Default Model "
2653
+ "to choose its model and reasoning effort.[/yellow]"
2654
+ )
2655
+ return
2656
+ console.print(f"[dim]Protocol:[/dim] {profile.protocol} [dim](advanced/read-only)[/dim]")
2657
+ console.print(_build_profile_edit_current_panel(profile.name))
2658
+ console.print(
2659
+ "[dim]Note: the API key itself is stored separately (encrypted in keyring). "
2660
+ "This field is only the name of the env variable Alysis Code reads as a fallback.[/dim]"
2661
+ )
2662
+ base_url = _prompt_non_secret_text(console, "Base URL", profile.base_url, "Base URL")
2663
+ api_key_env = _prompt_non_secret_text(
2664
+ console, _API_KEY_ENV_PROMPT, profile.api_key_env or "", _API_KEY_ENV_FIELD_NAME
2665
+ )
2666
+ default_model = _prompt_non_secret_text(
2667
+ console, "Default model", profile.default_model, "Default model"
2668
+ )
2669
+ reasoning_trace_adapter = _prompt_reasoning_trace_adapter(
2670
+ console,
2671
+ protocol=profile.protocol,
2672
+ current=profile.reasoning_trace_adapter,
2673
+ )
2674
+ web_search_adapter = _prompt_web_search_adapter(console, profile.web_search_adapter)
2675
+ web_search_model = _prompt_non_secret_text(
2676
+ console,
2677
+ "Web search model",
2678
+ profile.web_search_model,
2679
+ "Web search model",
2680
+ )
2681
+ headers_default = ", ".join(f"{key}={value}" for key, value in profile.extra_headers.items())
2682
+ headers = _parse_header_text(
2683
+ _prompt_non_secret_text(console, "Extra headers", headers_default, "Extra headers")
2684
+ )
2685
+ notes = _prompt_non_secret_text(console, "Notes", profile.notes, "Notes")
2686
+ state.update_active_profile_spec(
2687
+ base_url=base_url,
2688
+ api_key_env=api_key_env or None,
2689
+ default_model=default_model,
2690
+ reasoning_trace_adapter=reasoning_trace_adapter,
2691
+ web_search_adapter=web_search_adapter or "auto",
2692
+ web_search_model=web_search_model,
2693
+ extra_headers=headers,
2694
+ notes=notes,
2695
+ )
2696
+ _print_state_provider_diagnostic_warnings(state, console)
2697
+
2698
+
2699
+ def _print_state_provider_diagnostic_warnings(
2700
+ state: ConfigMenuState,
2701
+ console: Console,
2702
+ ) -> None:
2703
+ try:
2704
+ issues = provider_diagnostic_warning_lines(state._resolution_cfg())
2705
+ except ConfigError:
2706
+ return
2707
+ for issue in issues:
2708
+ console.print(f"[yellow]Provider diagnostic: {issue}[/yellow]")
2709
+
2710
+
2711
+ def _build_profile_edit_current_panel(profile_name: str) -> Panel:
2712
+ from rich.console import Group
2713
+ from rich.text import Text
2714
+
2715
+ return Panel(
2716
+ Group(
2717
+ Text("Editable here:", style=STYLE_EMPHASIS),
2718
+ Text(" Base URL", style="dim"),
2719
+ Text(" API key env var NAME", style="dim"),
2720
+ Text(" Default model", style="dim"),
2721
+ Text(" Reasoning trace adapter", style="dim"),
2722
+ Text(" Web search adapter", style="dim"),
2723
+ Text(" Web search model", style="dim"),
2724
+ Text(" Extra headers", style="dim"),
2725
+ Text(" Notes", style="dim"),
2726
+ Text(""),
2727
+ Text("Advanced/read-only here:", style=STYLE_EMPHASIS),
2728
+ Text(
2729
+ " Protocol (use `alysis profile convert --to native|compatibility`)",
2730
+ style="dim",
2731
+ ),
2732
+ Text(""),
2733
+ Text("Stored separately:", style=STYLE_EMPHASIS),
2734
+ Text(" The actual API key", style="dim"),
2735
+ Text(" (use the API Key section to update)", style="dim"),
2736
+ ),
2737
+ title=f"Edit Profile: {profile_name}",
2738
+ border_style="cyan",
2739
+ )
2740
+
2741
+
2742
+ def _looks_like_secret(value: str) -> bool:
2743
+ """Conservative pattern check for accidentally-pasted secrets."""
2744
+ text = str(value or "").strip()
2745
+ if len(text) < 16:
2746
+ return False
2747
+ if text.startswith(("sk-", "sk_", "anthropic-", "Bearer ", "ghp_", "gho_", "github_pat_")):
2748
+ return True
2749
+ if len(text) >= 40 and all(c.isalnum() or c in "_-" for c in text):
2750
+ return True
2751
+ return False
2752
+
2753
+
2754
+ def _run_profile_remove(state: ConfigMenuState, console: Console) -> None:
2755
+ if not state.profiles:
2756
+ return
2757
+ rows = [(name, name, "") for name in sorted(state.profiles)]
2758
+ selected = _run_config_picker(
2759
+ console=console,
2760
+ title="Provider Profile",
2761
+ subtitle="Pick a profile to remove (applied on save)",
2762
+ rows=rows,
2763
+ current_value=state.active_profile or rows[0][0],
2764
+ )
2765
+ if selected and _prompt_yes_no(f"Remove profile {selected}? [y/N]"):
2766
+ staged_key_discarded = state.remove_profile_name(selected)
2767
+ console.print(f"[yellow]Profile {selected} will be removed on save.[/yellow]")
2768
+ if staged_key_discarded:
2769
+ console.print("[yellow]Its unsaved API key was discarded.[/yellow]")
2770
+
2771
+
2772
+ def _run_api_key_section(state: ConfigMenuState, console: Console) -> None:
2773
+ console.print()
2774
+ console.rule("[bold]API Key[/bold]")
2775
+ if state.execution_backend == "delegated":
2776
+ console.print(
2777
+ "[yellow]This API key is preserved separately while an AI subscription is selected.[/yellow]"
2778
+ )
2779
+ console.print("[dim]Used by the active profile for all model calls.[/dim]")
2780
+ console.print(f"Stored: {state.masked_api_key} · source: {state.api_key_source}")
2781
+ try:
2782
+ value = str(
2783
+ typer.prompt(
2784
+ 'New API key (Enter to keep current, "clear" to remove)',
2785
+ default="",
2786
+ hide_input=True,
2787
+ show_default=False,
2788
+ )
2789
+ )
2790
+ except (Abort, EOFError, KeyboardInterrupt):
2791
+ console.print("")
2792
+ _print_section_cancelled(console, "API Key")
2793
+ return
2794
+ if value.strip().casefold() == "clear":
2795
+ try:
2796
+ if _prompt_yes_no("Clear the stored API key? [y/N]"):
2797
+ state.mark_clear_stored_key_confirmed()
2798
+ console.print("[yellow]Stored API key will be cleared on save.[/yellow]")
2799
+ except (Abort, EOFError, KeyboardInterrupt):
2800
+ console.print("")
2801
+ _print_section_cancelled(console, "API Key")
2802
+ return
2803
+ elif value.strip():
2804
+ state.set_field("new_api_key", value.strip())
2805
+
2806
+
2807
+ def _run_default_section(state: ConfigMenuState, console: Console) -> None:
2808
+ console.print()
2809
+ console.rule("[bold]Default Model[/bold]")
2810
+ direct_subscription = _active_subscription_profile(state) is not None
2811
+ if state.execution_backend == "delegated" and not direct_subscription:
2812
+ console.print(
2813
+ "[yellow]This API-key model is preserved but inactive while an AI subscription is selected.[/yellow]"
2814
+ )
2815
+ console.print(
2816
+ "[dim]Used when you chat with the agent. Subagents and Forge roles fall back "
2817
+ "to this model unless overridden in the sections below.[/dim]"
2818
+ )
2819
+ try:
2820
+ model = _prompt_default_model(console, state)
2821
+ base_url = (
2822
+ state.fields["base_url"]
2823
+ if direct_subscription
2824
+ else _prompt_text("Base URL", state.fields["base_url"])
2825
+ )
2826
+ state.set_field("base_url", base_url)
2827
+ thinking_label = _prompt_thinking_label(
2828
+ console,
2829
+ state.thinking_label,
2830
+ labels=_thinking_labels_for_state(state, model=model),
2831
+ )
2832
+ timeout = _prompt_positive_float_text(
2833
+ console,
2834
+ "Request timeout (seconds)",
2835
+ state.fields["llm_timeout_s"],
2836
+ )
2837
+ except (Abort, EOFError, KeyboardInterrupt):
2838
+ console.print("")
2839
+ _print_section_cancelled(console, "Default Model")
2840
+ return
2841
+
2842
+ state.set_field("model", model)
2843
+ state.set_thinking_label(thinking_label)
2844
+ state.set_field("llm_timeout_s", timeout)
2845
+ if direct_subscription:
2846
+ console.print("[dim]Model and reasoning choices came from your subscription account.[/dim]")
2847
+ else:
2848
+ console.print(
2849
+ "[dim]Reasoning effort. Some providers ignore this until they add native "
2850
+ "reasoning support.[/dim]"
2851
+ )
2852
+
2853
+
2854
+ def _run_cache_section(state: ConfigMenuState, console: Console) -> None:
2855
+ console.print()
2856
+ console.rule("[bold]Context & Cache[/bold]")
2857
+ console.print(
2858
+ "[dim]Controls provider prompt caching for repeated system/context payloads. "
2859
+ "Auto mode derives safe provider-aware cache settings when the active API supports them.[/dim]"
2860
+ )
2861
+ policy, policy_error = _resolved_cache_policy_with_error_for_state(state)
2862
+ console.print(
2863
+ "[dim]Effective cache policy: "
2864
+ + escape(
2865
+ _cache_policy_summary_text(
2866
+ policy,
2867
+ compact=False,
2868
+ error=policy_error,
2869
+ )
2870
+ )
2871
+ + "[/dim]"
2872
+ )
2873
+ fields_snapshot = dict(state.fields)
2874
+ try:
2875
+ mode = _prompt_prompt_cache_mode(console, state.fields["prompt_cache_mode"])
2876
+ state.set_field("prompt_cache_mode", mode)
2877
+ state.set_field(
2878
+ "prompt_cache_key",
2879
+ _prompt_optional_config_text(
2880
+ console,
2881
+ "Manual cache key",
2882
+ state.fields["prompt_cache_key"],
2883
+ "manual cache key",
2884
+ ),
2885
+ )
2886
+ state.set_field(
2887
+ "prompt_cache_retention",
2888
+ _prompt_optional_config_text(
2889
+ console,
2890
+ "Manual cache retention",
2891
+ state.fields["prompt_cache_retention"],
2892
+ "manual cache retention",
2893
+ ),
2894
+ )
2895
+ state.set_field(
2896
+ "anthropic_prompt_cache_enabled",
2897
+ _prompt_anthropic_prompt_cache_enabled(
2898
+ console,
2899
+ state.fields["anthropic_prompt_cache_enabled"],
2900
+ ),
2901
+ )
2902
+ state.set_field(
2903
+ "anthropic_prompt_cache_ttl",
2904
+ _prompt_anthropic_prompt_cache_ttl(
2905
+ console,
2906
+ state.fields["anthropic_prompt_cache_ttl"],
2907
+ ),
2908
+ )
2909
+ state.set_field(
2910
+ "cache_aware_compaction",
2911
+ _prompt_cache_aware_compaction_enabled(
2912
+ console,
2913
+ state.fields["cache_aware_compaction"],
2914
+ ),
2915
+ )
2916
+ state.set_field(
2917
+ "cache_aware_min_trigger_ratio",
2918
+ _prompt_cache_aware_min_trigger_ratio(
2919
+ console,
2920
+ state.fields["cache_aware_min_trigger_ratio"],
2921
+ ),
2922
+ )
2923
+ except (Abort, EOFError, KeyboardInterrupt):
2924
+ state.fields = dict(fields_snapshot)
2925
+ console.print("")
2926
+ _print_section_cancelled(console, "Context & Cache")
2927
+ return
2928
+ console.print("[green]Context/cache settings updated.[/green] Save to apply.")
2929
+
2930
+
2931
+ def _prompt_prompt_cache_mode(console: Console, current: str) -> str:
2932
+ rows = [
2933
+ (
2934
+ "auto",
2935
+ "Auto",
2936
+ "Derive provider-aware cache settings when the active API supports caching.",
2937
+ ),
2938
+ (
2939
+ "manual",
2940
+ "Manual",
2941
+ "Use the manual cache key/retention below for compatible providers.",
2942
+ ),
2943
+ ("off", "Off", "Disable prompt caching for all providers."),
2944
+ ]
2945
+ value = _run_config_picker(
2946
+ console=console,
2947
+ title="Context & Cache",
2948
+ subtitle="Prompt cache mode.",
2949
+ rows=rows,
2950
+ current_value=_normalize_prompt_cache_mode(current),
2951
+ )
2952
+ if value is None:
2953
+ raise Abort()
2954
+ return _normalize_prompt_cache_mode(value)
2955
+
2956
+
2957
+ def _prompt_anthropic_prompt_cache_enabled(console: Console, current: str) -> str:
2958
+ rows = [
2959
+ ("true", "On", "Enable Anthropic cache_control in manual mode."),
2960
+ ("false", "Off", "Only auto mode enables Anthropic cache_control."),
2961
+ ]
2962
+ value = _run_config_picker(
2963
+ console=console,
2964
+ title="Context & Cache",
2965
+ subtitle="Anthropic cache_control override.",
2966
+ rows=rows,
2967
+ current_value="true"
2968
+ if _normalize_bool_text(current, label="Anthropic prompt cache")
2969
+ else "false",
2970
+ )
2971
+ if value is None:
2972
+ raise Abort()
2973
+ return "true" if _normalize_bool_text(value, label="Anthropic prompt cache") else "false"
2974
+
2975
+
2976
+ def _prompt_anthropic_prompt_cache_ttl(console: Console, current: str) -> str:
2977
+ rows = [
2978
+ ("5m", "5 minutes", "Lower cache lifetime; safest default."),
2979
+ ("1h", "1 hour", "Longer lifetime for stable long-running coding sessions."),
2980
+ ]
2981
+ value = _run_config_picker(
2982
+ console=console,
2983
+ title="Context & Cache",
2984
+ subtitle="Anthropic cache_control TTL.",
2985
+ rows=rows,
2986
+ current_value=_normalize_anthropic_prompt_cache_ttl(current),
2987
+ )
2988
+ if value is None:
2989
+ raise Abort()
2990
+ return _normalize_anthropic_prompt_cache_ttl(value)
2991
+
2992
+
2993
+ def _prompt_cache_aware_compaction_enabled(console: Console, current: str) -> str:
2994
+ rows = [
2995
+ ("true", "On", "Compact earlier when the next request is likely to miss provider cache."),
2996
+ ("false", "Off", "Use only the normal compaction trigger ratio."),
2997
+ ]
2998
+ value = _run_config_picker(
2999
+ console=console,
3000
+ title="Context & Cache",
3001
+ subtitle="Cache-aware compaction trigger.",
3002
+ rows=rows,
3003
+ current_value="true"
3004
+ if _normalize_bool_text(current, label="Cache-aware compaction")
3005
+ else "false",
3006
+ )
3007
+ if value is None:
3008
+ raise Abort()
3009
+ return "true" if _normalize_bool_text(value, label="Cache-aware compaction") else "false"
3010
+
3011
+
3012
+ def _prompt_cache_aware_min_trigger_ratio(console: Console, current: str) -> str:
3013
+ for _attempt in range(3):
3014
+ value = _prompt_text("Cache-aware min trigger ratio", current)
3015
+ try:
3016
+ return _normalize_cache_aware_min_trigger_ratio(value)
3017
+ except ValueError as exc:
3018
+ console.print(f"[red]{escape(str(exc))}[/red]")
3019
+ return _normalize_cache_aware_min_trigger_ratio(current)
3020
+
3021
+
3022
+ def _prompt_default_model(console: Console, state: ConfigMenuState) -> str:
3023
+ rows = _default_model_rows(state)
3024
+ if state._provider_model_catalog_warning:
3025
+ console.print(f"[yellow]{state._provider_model_catalog_warning}[/yellow]")
3026
+ current_model = str(state.fields.get("model") or "").strip()
3027
+ row_values = {value for value, _label, _description in rows}
3028
+ current_value = current_model if current_model in row_values else rows[0][0]
3029
+ selected = _run_config_picker(
3030
+ console=console,
3031
+ title="Default Model",
3032
+ subtitle=_default_model_picker_subtitle(state),
3033
+ rows=rows,
3034
+ current_value=current_value,
3035
+ )
3036
+ if selected is None:
3037
+ raise Abort()
3038
+ if selected == _CUSTOM_MODEL_VALUE:
3039
+ return _prompt_text("Model", current_model)
3040
+ return selected
3041
+
3042
+
3043
+ def _default_model_picker_subtitle(state: ConfigMenuState) -> str:
3044
+ if _active_subscription_profile(state) is not None:
3045
+ return "Pick a model currently advertised for your connected subscription."
3046
+ preset = _active_preset(state)
3047
+ if preset is None:
3048
+ return "Pick the active profile model, or type a custom model ID."
3049
+ return f"Pick a supported model for {preset.label}."
3050
+
3051
+
3052
+ def _provider_models_for_state(
3053
+ state: ConfigMenuState,
3054
+ preset: ProfilePreset,
3055
+ ) -> tuple[ProviderModelOption, ...]:
3056
+ if preset.key != "nvidia":
3057
+ return ()
3058
+ try:
3059
+ profile_data = state.profiles[state.active_profile]
3060
+ profile = ProfileSpec.from_dict(state.active_profile, profile_data)
3061
+ except (KeyError, ConfigError):
3062
+ return ()
3063
+ api_key = state.staged_api_key_for_active_profile()
3064
+ if not api_key:
3065
+ try:
3066
+ api_key = resolve_api_key(state._resolution_cfg()).key
3067
+ except ConfigError:
3068
+ api_key = ""
3069
+ identity = (
3070
+ profile.name,
3071
+ str(profile.base_url or "").strip().rstrip("/"),
3072
+ credential_scope_fingerprint(api_key),
3073
+ )
3074
+ if state._provider_models_cache_identity == identity:
3075
+ return state._provider_models_cache
3076
+ if not api_key:
3077
+ state._provider_models_cache_identity = identity
3078
+ state._provider_models_cache = ()
3079
+ state._provider_model_catalog_warning = (
3080
+ "Add the NVIDIA API key to load its live hosted-model catalog."
3081
+ )
3082
+ return ()
3083
+ try:
3084
+ options = discover_provider_models(profile=profile, api_key=api_key)
3085
+ except ProviderModelCatalogError:
3086
+ options = ()
3087
+ state._provider_model_catalog_warning = (
3088
+ "NVIDIA's live model catalog is unavailable; recommended and custom models "
3089
+ "remain available."
3090
+ )
3091
+ else:
3092
+ state._provider_model_catalog_warning = ""
3093
+ state._provider_models_cache_identity = identity
3094
+ state._provider_models_cache = tuple(sorted(options, key=lambda item: item.id.casefold()))
3095
+ return state._provider_models_cache
3096
+
3097
+
3098
+ def _preset_model_option_rows(
3099
+ preset: ProfilePreset,
3100
+ *,
3101
+ state: ConfigMenuState | None = None,
3102
+ ) -> list[tuple[str, str, str]]:
3103
+ """Model picker rows for a preset, widened with provider-advertised live models.
3104
+
3105
+ NVIDIA's API catalog contains both NVIDIA and third-party models, so its live
3106
+ ``/v1/models`` inventory is merged with a small curated recommendation set.
3107
+ The hosted MiMo trial similarly appends its live proxy allowlist. Discovery is
3108
+ best-effort and offline-safe: on failure the static rows still render.
3109
+ """
3110
+ rows = list(model_options_for_preset(preset))
3111
+
3112
+ if preset.key == "nvidia" and state is not None:
3113
+ known = {value for value, _label, _description in rows}
3114
+ for option in _provider_models_for_state(state, preset):
3115
+ if option.id in known:
3116
+ continue
3117
+ known.add(option.id)
3118
+ owner = option.id.partition("/")[0]
3119
+ description = str(option.description or "").strip()
3120
+ if not description:
3121
+ description = (
3122
+ f"live NVIDIA catalog · hosted model from {owner}"
3123
+ if owner and owner != option.id
3124
+ else "live NVIDIA catalog"
3125
+ )
3126
+ label = option.label or option.id
3127
+ if option.chat_compatibility == "unknown":
3128
+ label = f"{label} (chat compatibility unverified)"
3129
+ description = (
3130
+ f"{description} · provider catalog does not declare endpoint compatibility; "
3131
+ "verify this model before relying on it"
3132
+ )
3133
+ rows.append((option.id, label, description))
3134
+ return rows
3135
+
3136
+ from ..alysis_cloud import PROFILE_KEY
3137
+
3138
+ if preset.key != PROFILE_KEY:
3139
+ return rows
3140
+
3141
+ try:
3142
+ from .. import account_login
3143
+ from ..config import load_config
3144
+
3145
+ discovered = account_login.list_trial_models(load_config())
3146
+ except Exception: # noqa: BLE001 - discovery must never break the picker
3147
+ discovered = []
3148
+
3149
+ def _same_model(a: str, b: str) -> bool:
3150
+ # Equal, or one is just the provider-prefixed form of the other.
3151
+ return a == b or a.endswith("/" + b) or b.endswith("/" + a)
3152
+
3153
+ known = [value for value, _label, _description in rows]
3154
+ for model_id in discovered:
3155
+ if any(_same_model(model_id, existing) for existing in known):
3156
+ continue
3157
+ known.append(model_id)
3158
+ rows.append((model_id, model_id, "available on your Alysis Code trial"))
3159
+ return rows
3160
+
3161
+
3162
+ def _default_model_rows(state: ConfigMenuState) -> list[tuple[str, str, str]]:
3163
+ rows: list[tuple[str, str, str]] = []
3164
+ seen: set[str] = set()
3165
+ current_model = str(state.fields.get("model") or "").strip()
3166
+
3167
+ if _active_subscription_profile(state) is not None:
3168
+ subscription_models = _subscription_models_for_state(state)
3169
+ for model in subscription_models:
3170
+ if model.id in seen:
3171
+ continue
3172
+ seen.add(model.id)
3173
+ efforts = ", ".join(effort.id for effort in model.reasoning_efforts)
3174
+ description = model.description
3175
+ if efforts:
3176
+ description = f"{description} · reasoning: {efforts}" if description else efforts
3177
+ rows.append((model.id, model.label, description))
3178
+ if not rows and current_model:
3179
+ rows.append(
3180
+ (
3181
+ current_model,
3182
+ current_model,
3183
+ "saved model · live subscription catalog unavailable",
3184
+ )
3185
+ )
3186
+ return rows
3187
+
3188
+ if current_model:
3189
+ rows.append((current_model, current_model, "current configured model"))
3190
+ seen.add(current_model)
3191
+
3192
+ preset = _active_preset(state)
3193
+ custom_added = False
3194
+ if preset is not None:
3195
+ preset_rows = _preset_model_option_rows(preset, state=state)
3196
+ curated_count = len(model_options_for_preset(preset))
3197
+ for index, (value, label, description) in enumerate(preset_rows):
3198
+ if preset.key == "nvidia" and index == curated_count and not custom_added:
3199
+ rows.append(
3200
+ (
3201
+ _CUSTOM_MODEL_VALUE,
3202
+ "Type a custom model name",
3203
+ "Use any model supported by the active provider",
3204
+ )
3205
+ )
3206
+ custom_added = True
3207
+ if value in seen:
3208
+ continue
3209
+ seen.add(value)
3210
+ rows.append((value, label, description))
3211
+
3212
+ if not custom_added:
3213
+ rows.append(
3214
+ (
3215
+ _CUSTOM_MODEL_VALUE,
3216
+ "Type a custom model name",
3217
+ "Use any model supported by the active provider",
3218
+ )
3219
+ )
3220
+ return rows
3221
+
3222
+
3223
+ def _active_preset(state: ConfigMenuState) -> ProfilePreset | None:
3224
+ if state.active_profile and state.active_profile in state.profiles:
3225
+ profile = ProfileSpec.from_dict(state.active_profile, state.profiles[state.active_profile])
3226
+ preset = find_preset_for_profile(profile)
3227
+ if preset is not None:
3228
+ state_base_url = str(state.fields.get("base_url", "") or "").strip().rstrip("/")
3229
+ profile_base_url = str(profile.base_url or "").strip().rstrip("/")
3230
+ if not state_base_url or state_base_url == profile_base_url:
3231
+ return preset
3232
+ base_url_preset = find_preset_for_base_url(state_base_url)
3233
+ return base_url_preset or preset
3234
+
3235
+ return find_preset_for_base_url(state.fields.get("base_url", ""))
3236
+
3237
+
3238
+ def _run_subagent_section(state: ConfigMenuState, console: Console) -> None:
3239
+ console.print()
3240
+ console.rule("[bold]Subagent model overrides[/bold]")
3241
+ console.print(
3242
+ "[dim]Override the model used by the agent's internal roles. Leave empty to inherit "
3243
+ "the default model.[/dim]"
3244
+ )
3245
+ temperature_controls_available = _temperature_controls_available(state)
3246
+ if not temperature_controls_available:
3247
+ console.print(
3248
+ "[dim]Temperature is managed by the active AI subscription. Choose the model "
3249
+ "and reasoning effort in Default Model instead.[/dim]"
3250
+ )
3251
+ _print_role_explainer(console, roles=ROLE_ORDER)
3252
+ role_model_snapshot = dict(state.role_models)
3253
+ role_temp_snapshot = dict(state.role_temperatures)
3254
+ try:
3255
+ for role in ROLE_ORDER:
3256
+ role_label = _role_label(role)
3257
+ model = _prompt_override_text(
3258
+ f" {role_label} model (Enter to skip)",
3259
+ state.role_models.get(role, ""),
3260
+ )
3261
+ state.set_role_model(role, model)
3262
+ if temperature_controls_available and role in _ROLE_TEMPERATURE_FIELDS:
3263
+ temperature = _prompt_override_text(
3264
+ f" {role_label} temperature (Enter to skip)",
3265
+ state.role_temperatures.get(role, ""),
3266
+ )
3267
+ state.set_role_temperature(role, temperature)
3268
+ except (Abort, EOFError, KeyboardInterrupt):
3269
+ state.role_models = dict(role_model_snapshot)
3270
+ state.role_temperatures = dict(role_temp_snapshot)
3271
+ console.print("")
3272
+ _print_section_cancelled(console, "Subagent model overrides")
3273
+ return
3274
+
3275
+ overrides = _non_empty_role_values(state.role_models, roles=ROLE_ORDER)
3276
+ if overrides:
3277
+ console.print(f"[dim]{_pluralize(len(overrides), 'override')} set.[/dim]")
3278
+ else:
3279
+ console.print("[dim]No subagent model overrides set.[/dim]")
3280
+
3281
+
3282
+ def _run_forge_section(state: ConfigMenuState, console: Console) -> None:
3283
+ console.print()
3284
+ console.rule("[bold]Forge model overrides[/bold]")
3285
+ console.print(
3286
+ "[dim]Override the model used by Forge swarm roles. Leave empty to inherit the "
3287
+ "subagent override, then the default model.[/dim]"
3288
+ )
3289
+ _print_role_explainer(console, roles=FORGE_ROLE_ORDER)
3290
+ role_model_snapshot = dict(state.forge_role_models)
3291
+ try:
3292
+ for role in FORGE_ROLE_ORDER:
3293
+ model = _prompt_override_text(
3294
+ f" {_role_label(role)} model (Enter to skip)",
3295
+ state.forge_role_models.get(role, ""),
3296
+ )
3297
+ state.set_forge_role_model(role, model)
3298
+ except (Abort, EOFError, KeyboardInterrupt):
3299
+ state.forge_role_models = dict(role_model_snapshot)
3300
+ console.print("")
3301
+ _print_section_cancelled(console, "Forge model overrides")
3302
+ return
3303
+
3304
+ overrides = _non_empty_role_values(state.forge_role_models, roles=FORGE_ROLE_ORDER)
3305
+ if overrides:
3306
+ console.print(f"[dim]{_pluralize(len(overrides), 'override')} set.[/dim]")
3307
+ else:
3308
+ console.print("[dim]No forge model overrides set.[/dim]")
3309
+
3310
+
3311
+ def _save_and_exit(
3312
+ state: ConfigMenuState,
3313
+ cfg: AppConfig,
3314
+ console: Console,
3315
+ ) -> ConfigMenuResult:
3316
+ result = state.commit_to(cfg)
3317
+ if not result.saved:
3318
+ console.print(f"[red]{result.error or 'Config validation failed.'}[/red]")
3319
+ return result
3320
+ try:
3321
+ save_config(cfg)
3322
+ if state.new_api_key.strip():
3323
+ key_profile = state.staged_api_key_target_profile()
3324
+ if key_profile:
3325
+ save_persisted_profile_key(key_profile, state.new_api_key.strip())
3326
+ else:
3327
+ save_persisted_api_key(state.new_api_key.strip())
3328
+ if state.clear_stored_key_confirmed:
3329
+ clear_profile = state.clear_stored_key_profile or state.active_profile
3330
+ if clear_profile:
3331
+ clear_persisted_profile_key(clear_profile)
3332
+ else:
3333
+ clear_persisted_api_key()
3334
+ except (ConfigError, OSError) as exc:
3335
+ message = str(exc)
3336
+ console.print(f"[red]Failed to save config:[/red] {message}")
3337
+ if isinstance(exc, OSError):
3338
+ try:
3339
+ path = config_path()
3340
+ except Exception as path_exc: # noqa: BLE001
3341
+ console.print(
3342
+ f"[dim]Could not resolve config path for permission hint: {path_exc}[/dim]"
3343
+ )
3344
+ else:
3345
+ console.print(f"[dim]Check write permission on {path}.[/dim]")
3346
+ return ConfigMenuResult(saved=False, changes={}, api_key_changed=False, error=message)
3347
+
3348
+ change_count = len(result.changes) + (1 if result.api_key_changed else 0)
3349
+ console.print(f"[green]Saved {_pluralize(change_count, 'change')}.[/green]")
3350
+ return result
3351
+
3352
+
3353
+ def _confirm_cancel_when_dirty(state: ConfigMenuState, console: Console) -> bool:
3354
+ if not state.dirty:
3355
+ return True
3356
+ try:
3357
+ if _prompt_yes_no("Discard pending changes? [y/N]"):
3358
+ return True
3359
+ except (Abort, EOFError, KeyboardInterrupt):
3360
+ console.print("")
3361
+ console.print("[dim]Returning to configuration menu.[/dim]")
3362
+ return False
3363
+
3364
+
3365
+ def _prompt_text(prompt: str, default: str) -> str:
3366
+ value = str(typer.prompt(prompt, default=str(default), show_default=True))
3367
+ if value == "":
3368
+ return str(default)
3369
+ return value.strip()
3370
+
3371
+
3372
+ def _prompt_optional_config_text(
3373
+ console: Console,
3374
+ prompt: str,
3375
+ current: str,
3376
+ field_name: str,
3377
+ ) -> str:
3378
+ display_value = str(current or "").strip() or "(not set)"
3379
+ console.print(
3380
+ f"[dim]Current {field_name}: {escape(display_value)}. "
3381
+ "Enter keeps it; type 'clear' to unset.[/dim]"
3382
+ )
3383
+ value = str(typer.prompt(prompt, default="", show_default=False)).strip()
3384
+ if not value:
3385
+ return str(current or "").strip()
3386
+ if value.casefold() == "clear":
3387
+ return ""
3388
+ return value
3389
+
3390
+
3391
+ def _prompt_non_secret_text(
3392
+ console: Console,
3393
+ prompt: str,
3394
+ default: str,
3395
+ field_name: str,
3396
+ ) -> str:
3397
+ while True:
3398
+ value = _prompt_text(prompt, default)
3399
+ guarded = _resolve_non_secret_value(console, value, field_name)
3400
+ if guarded is not None:
3401
+ return guarded
3402
+
3403
+
3404
+ def _resolve_non_secret_value(console: Console, value: str, field_name: str) -> str | None:
3405
+ candidate = str(value)
3406
+ while _looks_like_secret(candidate):
3407
+ console.print(f"[red]That looks like an API key, not a {field_name}.[/red]")
3408
+ console.print(
3409
+ "[yellow]Tip: paste API keys in the API Key section, not here.\n"
3410
+ "Keys here are written to plaintext profile config.[/yellow]"
3411
+ )
3412
+ replacement = _prompt_override_text(
3413
+ f"Re-enter {field_name} (or 'force' to keep this value)",
3414
+ "",
3415
+ )
3416
+ if replacement == _SECRET_FORCE_TOKEN:
3417
+ console.print("[red]Confirmed: storing potentially-sensitive value in plaintext.[/red]")
3418
+ return candidate
3419
+ if replacement:
3420
+ candidate = replacement
3421
+ continue
3422
+ return None
3423
+ return candidate
3424
+
3425
+
3426
+ def _prompt_override_text(prompt: str, default: str) -> str:
3427
+ value = str(typer.prompt(prompt, default=str(default), show_default=True))
3428
+ return value.strip()
3429
+
3430
+
3431
+ def _prompt_thinking_label(
3432
+ console: Console,
3433
+ current: str,
3434
+ *,
3435
+ labels: tuple[str, ...] | None = None,
3436
+ ) -> str:
3437
+ resolved_labels = labels or THINKING_LABELS
3438
+ current_value = current if current in resolved_labels else "auto"
3439
+ rows = [
3440
+ (
3441
+ label,
3442
+ label,
3443
+ {
3444
+ "off": "no extra reasoning tokens",
3445
+ "minimal": "minimal reasoning budget",
3446
+ "low": "small reasoning budget",
3447
+ "medium": "medium reasoning budget",
3448
+ "high": "large reasoning budget",
3449
+ "xhigh": "maximum reasoning budget when supported",
3450
+ "max": "provider maximum reasoning budget when supported",
3451
+ "ultra": "provider ultra reasoning budget when supported",
3452
+ "auto": "let the provider decide",
3453
+ }.get(label, "provider-specific reasoning budget"),
3454
+ )
3455
+ for label in resolved_labels
3456
+ ]
3457
+ value = _run_config_picker(
3458
+ console=console,
3459
+ title="Default Model",
3460
+ subtitle=(
3461
+ "Reasoning effort. Some providers ignore this until they add native reasoning support."
3462
+ ),
3463
+ rows=rows,
3464
+ current_value=current_value,
3465
+ )
3466
+ if value is None:
3467
+ return current
3468
+ return _normalize_thinking_label(value)
3469
+
3470
+
3471
+ def _active_subscription_profile(state: ConfigMenuState) -> ProfileSpec | None:
3472
+ if not state.active_profile or state.active_profile not in state.profiles:
3473
+ return None
3474
+ profile = ProfileSpec.from_dict(state.active_profile, state.profiles[state.active_profile])
3475
+ return profile if profile.auth_provider else None
3476
+
3477
+
3478
+ def _temperature_controls_available(state: ConfigMenuState) -> bool:
3479
+ """Return whether the active transport accepts caller-controlled sampling."""
3480
+
3481
+ profile = _active_subscription_profile(state)
3482
+ if profile is None:
3483
+ return True
3484
+ try:
3485
+ from ..provider_auth import create_provider_auth
3486
+
3487
+ adapter = create_provider_auth(profile.auth_provider or "")
3488
+ except Exception: # noqa: BLE001 - unknown adapters must not expose a false control
3489
+ return False
3490
+ return bool(getattr(adapter, "supports_temperature", False))
3491
+
3492
+
3493
+ def _subscription_models_for_state(state: ConfigMenuState) -> tuple[Any, ...]:
3494
+ profile = _active_subscription_profile(state)
3495
+ if profile is None or not profile.auth_provider:
3496
+ return ()
3497
+ if state._subscription_models_loaded:
3498
+ return state._subscription_models_cache
3499
+ try:
3500
+ from ..provider_auth import create_provider_auth
3501
+
3502
+ models = tuple(create_provider_auth(profile.auth_provider).list_models(refresh=True))
3503
+ state._subscription_models_cache = models
3504
+ state._subscription_models_loaded = True
3505
+ state.config_warning = None
3506
+ return models
3507
+ except Exception as exc: # noqa: BLE001 - config remains usable while offline
3508
+ state.config_warning = f"Subscription model catalog unavailable: {exc}"
3509
+ state._subscription_models_cache = ()
3510
+ state._subscription_models_loaded = True
3511
+ return ()
3512
+
3513
+
3514
+ def _thinking_labels_for_state(
3515
+ state: ConfigMenuState,
3516
+ *,
3517
+ model: str | None = None,
3518
+ ) -> tuple[str, ...]:
3519
+ profile = _active_subscription_profile(state)
3520
+ if profile is None:
3521
+ preset = _active_preset(state)
3522
+ contract = reasoning_contract_for(
3523
+ getattr(preset, "provider_key", None),
3524
+ str(model or state.fields.get("model") or ""),
3525
+ preset_key=getattr(preset, "key", None),
3526
+ )
3527
+ preset_key = getattr(preset, "key", None)
3528
+ if preset_key == "nvidia" and contract is UNKNOWN_CONTRACT:
3529
+ return ("auto",)
3530
+ current = _normalize_thinking_label(state.thinking_label)
3531
+ if preset_key in {"nvidia", "zai-coding-plan"}:
3532
+ # A global effort may have been saved for the previously active
3533
+ # provider. Do not preserve an invalid value merely because it is
3534
+ # current; these controls are exact per hosted model and surface.
3535
+ current = "auto"
3536
+ return tuple(
3537
+ reasoning_labels_allowed_by_contract(
3538
+ contract,
3539
+ THINKING_LABELS,
3540
+ current=current,
3541
+ )
3542
+ )
3543
+ model_id = str(model or state.fields.get("model") or "").strip()
3544
+ selected = next(
3545
+ (item for item in _subscription_models_for_state(state) if item.id == model_id),
3546
+ None,
3547
+ )
3548
+ if selected is None:
3549
+ current = _normalize_thinking_label(state.thinking_label)
3550
+ if not state._subscription_models_cache and current != "auto":
3551
+ return ("auto", current)
3552
+ return ("auto",)
3553
+ labels: list[str] = ["auto"]
3554
+ for effort in selected.reasoning_efforts:
3555
+ label = "off" if effort.id == "none" else effort.id
3556
+ if label in THINKING_LABELS and label not in labels:
3557
+ labels.append(label)
3558
+ return tuple(labels)
3559
+
3560
+
3561
+ def _prompt_positive_float_text(console: Console, prompt: str, current: str) -> str:
3562
+ for _attempt in range(3):
3563
+ value = _prompt_text(prompt, current)
3564
+ number = _finite_float(value, fallback=None)
3565
+ if number is not None and number > 0:
3566
+ return _format_number(number)
3567
+ console.print(f"[red]{prompt} must be a positive number.[/red]")
3568
+ return current
3569
+
3570
+
3571
+ def _prompt_positive_int_text(console: Console, prompt: str, current: str) -> str:
3572
+ for _attempt in range(3):
3573
+ value = _prompt_text(prompt, current)
3574
+ number = _finite_int(value, fallback=None)
3575
+ if number is not None and number > 0:
3576
+ return str(number)
3577
+ console.print(f"[red]{prompt} must be a positive integer.[/red]")
3578
+ return current
3579
+
3580
+
3581
+ def _prompt_yes_no(prompt: str) -> bool:
3582
+ clean_prompt = str(prompt).removesuffix(" [y/N]")
3583
+ return bool(typer.confirm(clean_prompt, default=False))
3584
+
3585
+
3586
+ def _parse_header_text(raw: str) -> dict[str, str]:
3587
+ headers: dict[str, str] = {}
3588
+ for item in str(raw or "").split(","):
3589
+ text = item.strip()
3590
+ if not text:
3591
+ continue
3592
+ if "=" not in text:
3593
+ raise ConfigError("Profile headers must use k=v syntax.")
3594
+ key, value = text.split("=", 1)
3595
+ if key.strip() and value.strip():
3596
+ headers[key.strip()] = value.strip()
3597
+ return headers
3598
+
3599
+
3600
+ def _pending_change_count(state: ConfigMenuState) -> int:
3601
+ original = state._original
3602
+ current = state.snapshot()
3603
+ count = 0
3604
+ for key in (
3605
+ "fields",
3606
+ "role_models",
3607
+ "forge_role_models",
3608
+ "role_temperatures",
3609
+ "profiles",
3610
+ "agent_runtimes",
3611
+ ):
3612
+ original_values = original.get(key) if isinstance(original.get(key), dict) else {}
3613
+ current_values = current.get(key) if isinstance(current.get(key), dict) else {}
3614
+ for field_name in set(original_values) | set(current_values):
3615
+ if str(original_values.get(field_name, "")) != str(current_values.get(field_name, "")):
3616
+ count += 1
3617
+ for key in (
3618
+ "active_profile",
3619
+ "execution_backend",
3620
+ "execution_runtime",
3621
+ "default_workspace_path",
3622
+ "thinking_label",
3623
+ "thinking_label_explicitly_set",
3624
+ "new_api_key",
3625
+ "new_api_key_profile",
3626
+ "clear_stored_key_confirmed",
3627
+ ):
3628
+ if current.get(key) != original.get(key):
3629
+ count += 1
3630
+ return count
3631
+
3632
+
3633
+ def mask_api_key(api_key: str | None) -> str:
3634
+ clean = str(api_key or "").strip()
3635
+ if not clean:
3636
+ return "(not set)"
3637
+ suffix = clean[-4:] if len(clean) >= 4 else clean
3638
+ if clean.startswith("sk-"):
3639
+ return f"sk-…{suffix}"
3640
+ return f"…{suffix}"
3641
+
3642
+
3643
+ def thinking_label_to_config_value(label: str) -> bool | None:
3644
+ normalized = _normalize_thinking_label(label)
3645
+ if normalized == "off":
3646
+ return False
3647
+ if normalized == "auto":
3648
+ return None
3649
+ return True
3650
+
3651
+
3652
+ def thinking_label_to_reasoning_effort(label: str) -> str | None:
3653
+ normalized = _normalize_thinking_label(label)
3654
+ if normalized == "off":
3655
+ return "none"
3656
+ if normalized == "auto":
3657
+ return None
3658
+ return normalized
3659
+
3660
+
3661
+ def thinking_label_from_cfg(cfg: AppConfig) -> str:
3662
+ value = getattr(cfg, "llm_enable_thinking", None)
3663
+ reasoning_effort = resolve_llm_reasoning_effort(cfg)
3664
+ if reasoning_effort == "none":
3665
+ return "off"
3666
+ if reasoning_effort and reasoning_effort != "none":
3667
+ return reasoning_effort
3668
+ if value is False:
3669
+ return "off"
3670
+ if value is None:
3671
+ return "auto"
3672
+ hint = ""
3673
+ if isinstance(cfg.extra_fields, dict):
3674
+ hint = str(cfg.extra_fields.get(_THINKING_LABEL_EXTRA_FIELD) or "").strip().lower()
3675
+ if hint in THINKING_LABELS and hint not in {"auto", "off"}:
3676
+ return hint
3677
+ return "medium"
3678
+
3679
+
3680
+ def _set_thinking_label_hint(cfg: AppConfig, label: str) -> None:
3681
+ normalized = _normalize_thinking_label(label)
3682
+ if normalized not in {"off", "auto"}:
3683
+ cfg.extra_fields[_THINKING_LABEL_EXTRA_FIELD] = normalized
3684
+ return
3685
+ cfg.extra_fields.pop(_THINKING_LABEL_EXTRA_FIELD, None)
3686
+
3687
+
3688
+ def _normalize_role(role: str) -> str:
3689
+ role_key = str(role or "").strip().lower()
3690
+ if role_key not in ROLE_MODEL_ORDER:
3691
+ raise KeyError(f"Unknown role: {role}")
3692
+ return role_key
3693
+
3694
+
3695
+ def _normalize_thinking_label(label: str) -> str:
3696
+ normalized = str(label or "").strip().lower()
3697
+ if normalized not in THINKING_LABELS:
3698
+ allowed = ", ".join(THINKING_LABELS)
3699
+ raise ValueError(f"thinking must be one of: {allowed}")
3700
+ return normalized
3701
+
3702
+
3703
+ def _normalize_step_budget_policy(value: str) -> str:
3704
+ normalized = str(value or "").strip().lower()
3705
+ aliases = {"adaptive": "autonomous", "fixed": "limited"}
3706
+ if normalized not in STEP_BUDGET_POLICIES:
3707
+ allowed = ", ".join(STEP_BUDGET_POLICIES)
3708
+ raise ValueError(f"Step budget policy must be one of: {allowed}")
3709
+ return aliases.get(normalized, normalized)
3710
+
3711
+
3712
+ def _normalize_prompt_cache_mode(value: Any) -> str:
3713
+ normalized = str(value or "").strip().lower() or "manual"
3714
+ if normalized not in PROMPT_CACHE_MODES:
3715
+ allowed = ", ".join(PROMPT_CACHE_MODES)
3716
+ raise ValueError(f"Prompt cache mode must be one of: {allowed}")
3717
+ return normalized
3718
+
3719
+
3720
+ def _normalize_anthropic_prompt_cache_ttl(value: Any) -> str:
3721
+ normalized = str(value or "").strip().lower() or "5m"
3722
+ if normalized not in ANTHROPIC_PROMPT_CACHE_TTLS:
3723
+ allowed = ", ".join(ANTHROPIC_PROMPT_CACHE_TTLS)
3724
+ raise ValueError(f"Anthropic prompt cache TTL must be one of: {allowed}")
3725
+ return normalized
3726
+
3727
+
3728
+ def _raw_compaction_extra_fields(cfg: AppConfig) -> dict[str, Any]:
3729
+ raw = cfg.extra_fields.get("compaction") if isinstance(cfg.extra_fields, dict) else None
3730
+ return dict(raw) if isinstance(raw, dict) else {}
3731
+
3732
+
3733
+ def _cache_aware_compaction_bool(value: Any) -> bool:
3734
+ if value is None:
3735
+ return _CACHE_AWARE_COMPACTION_DEFAULT
3736
+ try:
3737
+ return _normalize_bool_text(value, label="Cache-aware compaction")
3738
+ except ValueError:
3739
+ return _CACHE_AWARE_COMPACTION_DEFAULT
3740
+
3741
+
3742
+ def _cache_aware_compaction_text(value: Any) -> str:
3743
+ return "true" if _cache_aware_compaction_bool(value) else "false"
3744
+
3745
+
3746
+ def _parse_cache_aware_min_trigger_ratio(value: Any) -> float:
3747
+ raw = _CACHE_AWARE_MIN_TRIGGER_RATIO_DEFAULT if value is None else value
3748
+ try:
3749
+ ratio = float(str(raw).strip() or _CACHE_AWARE_MIN_TRIGGER_RATIO_DEFAULT)
3750
+ except (TypeError, ValueError) as exc:
3751
+ raise ValueError(_cache_aware_min_trigger_ratio_error()) from exc
3752
+ if (
3753
+ not math.isfinite(ratio)
3754
+ or ratio <= _CACHE_AWARE_MIN_TRIGGER_RATIO_MIN
3755
+ or ratio >= _CACHE_AWARE_MIN_TRIGGER_RATIO_MAX
3756
+ ):
3757
+ raise ValueError(_cache_aware_min_trigger_ratio_error())
3758
+ return ratio
3759
+
3760
+
3761
+ def _cache_aware_min_trigger_ratio_value(value: Any) -> float:
3762
+ try:
3763
+ return _parse_cache_aware_min_trigger_ratio(value)
3764
+ except ValueError:
3765
+ return _CACHE_AWARE_MIN_TRIGGER_RATIO_DEFAULT
3766
+
3767
+
3768
+ def _cache_aware_min_trigger_ratio_text(value: Any) -> str:
3769
+ return _format_number(_cache_aware_min_trigger_ratio_value(value))
3770
+
3771
+
3772
+ def _normalize_cache_aware_min_trigger_ratio(value: Any) -> str:
3773
+ return _format_number(_parse_cache_aware_min_trigger_ratio(value))
3774
+
3775
+
3776
+ def _cache_aware_min_trigger_ratio_error() -> str:
3777
+ return (
3778
+ "Cache-aware compaction min trigger ratio must be greater than "
3779
+ f"{_CACHE_AWARE_MIN_TRIGGER_RATIO_MIN} and less than "
3780
+ f"{_CACHE_AWARE_MIN_TRIGGER_RATIO_MAX}."
3781
+ )
3782
+
3783
+
3784
+ def _normalize_bool_text(value: Any, *, label: str) -> bool:
3785
+ normalized = str(value or "").strip().lower()
3786
+ if normalized in {"1", "true", "yes", "on"}:
3787
+ return True
3788
+ if normalized in {"0", "false", "no", "off"}:
3789
+ return False
3790
+ raise ValueError(f"{label} must be true or false.")
3791
+
3792
+
3793
+ def _thinking_config_text(value: bool | None) -> str:
3794
+ if value is True:
3795
+ return "true"
3796
+ if value is False:
3797
+ return "false"
3798
+ return "auto"
3799
+
3800
+
3801
+ def _normalized_role_model_values(raw: Any) -> dict[str, str]:
3802
+ source = raw if isinstance(raw, dict) else {}
3803
+ values = {role: "" for role in ROLE_MODEL_ORDER}
3804
+ for key, value in source.items():
3805
+ role = str(key).strip().lower()
3806
+ if role in values:
3807
+ values[role] = str(value).strip()
3808
+ return values
3809
+
3810
+
3811
+ def _non_empty_role_values(
3812
+ values: dict[str, str],
3813
+ *,
3814
+ roles: tuple[str, ...] = ROLE_MODEL_ORDER,
3815
+ ) -> dict[str, str]:
3816
+ out: dict[str, str] = {}
3817
+ for role in roles:
3818
+ value = str(values.get(role, "")).strip()
3819
+ if value:
3820
+ out[role] = value
3821
+ return out
3822
+
3823
+
3824
+ def _role_model_storage_values(
3825
+ raw: Any,
3826
+ values: dict[str, str],
3827
+ *,
3828
+ roles: tuple[str, ...] = ROLE_MODEL_ORDER,
3829
+ ) -> dict[str, str]:
3830
+ out: dict[str, str] = {}
3831
+ source = raw if isinstance(raw, dict) else {}
3832
+ modeled_roles = set(roles)
3833
+ for key, value in source.items():
3834
+ role = str(key).strip().lower()
3835
+ if not role or role in modeled_roles:
3836
+ continue
3837
+ model = str(value).strip()
3838
+ if model:
3839
+ out[role] = model
3840
+ out.update(_non_empty_role_values(values, roles=roles))
3841
+ return out
3842
+
3843
+
3844
+ def _role_text_map_from_snapshot(raw: Any) -> dict[str, str]:
3845
+ source = raw if isinstance(raw, dict) else {}
3846
+ return {role: str(source.get(role, "")) for role in ROLE_MODEL_ORDER}
3847
+
3848
+
3849
+ def _finite_float(raw: Any, *, fallback: float | None) -> float | None:
3850
+ try:
3851
+ value = float(raw)
3852
+ except (TypeError, ValueError):
3853
+ return fallback
3854
+ if not math.isfinite(value):
3855
+ return fallback
3856
+ return value
3857
+
3858
+
3859
+ def _finite_int(raw: Any, *, fallback: int | None) -> int | None:
3860
+ try:
3861
+ text = str(raw).strip()
3862
+ value = int(text)
3863
+ except (TypeError, ValueError):
3864
+ return fallback
3865
+ return value
3866
+
3867
+
3868
+ def _format_number(raw: Any) -> str:
3869
+ value = _finite_float(raw, fallback=None)
3870
+ if value is None:
3871
+ return str(raw or "")
3872
+ if value.is_integer():
3873
+ return str(int(value))
3874
+ text = f"{value:.12g}"
3875
+ return re.sub(r"\.0+$", "", text)
3876
+
3877
+
3878
+ def _format_integer(raw: Any) -> str:
3879
+ value = _finite_int(raw, fallback=None)
3880
+ if value is None:
3881
+ return str(raw or "")
3882
+ return str(value)