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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- 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)
|