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,2409 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import traceback
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
from click import Abort
|
|
15
|
+
from rich.console import Console, Group
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from ..config import (
|
|
21
|
+
AppConfig,
|
|
22
|
+
ConfigError,
|
|
23
|
+
config_path,
|
|
24
|
+
credentials_path,
|
|
25
|
+
default_sessions_dir,
|
|
26
|
+
load_config,
|
|
27
|
+
save_config,
|
|
28
|
+
save_persisted_profile_key,
|
|
29
|
+
set_config_value,
|
|
30
|
+
)
|
|
31
|
+
from ..llm.factory import make_llm_client
|
|
32
|
+
from ..llm.types import LLMError
|
|
33
|
+
from ..profile_presets import (
|
|
34
|
+
NATIVE_PROFILE_PROTOCOLS,
|
|
35
|
+
PROFILE_PRESETS,
|
|
36
|
+
ProfilePreset,
|
|
37
|
+
advanced_provider_selection_presets,
|
|
38
|
+
canonical_model_alias_for_preset,
|
|
39
|
+
make_profile_from_preset,
|
|
40
|
+
model_options_for_preset,
|
|
41
|
+
preset_protocol_summary,
|
|
42
|
+
preset_selection_label,
|
|
43
|
+
provider_selection_presets,
|
|
44
|
+
)
|
|
45
|
+
from ..profiles import (
|
|
46
|
+
SUBSCRIPTION_SELECTION_REQUIRED_KEY,
|
|
47
|
+
ProfileSpec,
|
|
48
|
+
active_subscription_selection_ready,
|
|
49
|
+
add_profile,
|
|
50
|
+
get_profile,
|
|
51
|
+
set_active_profile,
|
|
52
|
+
subscription_selection_supported,
|
|
53
|
+
update_profile,
|
|
54
|
+
)
|
|
55
|
+
from ..provider_diagnostics import provider_diagnostic_warning_lines
|
|
56
|
+
from ..provider_model_catalog import (
|
|
57
|
+
ProviderModelCatalogError,
|
|
58
|
+
ProviderModelOption,
|
|
59
|
+
discover_provider_models,
|
|
60
|
+
)
|
|
61
|
+
from ..reasoning_contracts import (
|
|
62
|
+
UNKNOWN_CONTRACT,
|
|
63
|
+
reasoning_contract_for,
|
|
64
|
+
reasoning_labels_allowed_by_contract,
|
|
65
|
+
)
|
|
66
|
+
from ..sandbox_doctor import (
|
|
67
|
+
BubblewrapInstallPlan,
|
|
68
|
+
SandboxDiagnostic,
|
|
69
|
+
SandboxPullResult,
|
|
70
|
+
detect_bubblewrap_install_plan,
|
|
71
|
+
diagnose_sandbox,
|
|
72
|
+
format_sandbox_problem_message,
|
|
73
|
+
install_bubblewrap,
|
|
74
|
+
pull_sandbox_images,
|
|
75
|
+
)
|
|
76
|
+
from ..sandbox_settings import apply_sandbox_mode_to_config
|
|
77
|
+
from ..surface.console import make_console
|
|
78
|
+
from ..workspace_binding import WorkspaceBindingError, resolve_workspace_binding
|
|
79
|
+
|
|
80
|
+
_DEFAULT_WORKSPACE_KEY = "default_workspace_path"
|
|
81
|
+
# Explicit "the user has completed first-run setup" marker. Persisted here (and
|
|
82
|
+
# read by the first-run gate in commands/startup.py) so that whether to route a
|
|
83
|
+
# launch into setup no longer relies on the unreliable "a model happens to be
|
|
84
|
+
# configured" heuristic.
|
|
85
|
+
_ONBOARDED_KEY = "onboarded"
|
|
86
|
+
_CUSTOM_MODEL_VALUE = "__custom_model__"
|
|
87
|
+
_CUSTOM_PROVIDER_KEY = "custom"
|
|
88
|
+
_NVIDIA_PRESET_KEY = "nvidia"
|
|
89
|
+
_MODEL_REASONING_SETUP_PRESET_KEYS = frozenset({_NVIDIA_PRESET_KEY, "zai-coding-plan"})
|
|
90
|
+
_SETUP_REASONING_LABELS: tuple[str, ...] = (
|
|
91
|
+
"off",
|
|
92
|
+
"minimal",
|
|
93
|
+
"low",
|
|
94
|
+
"medium",
|
|
95
|
+
"high",
|
|
96
|
+
"xhigh",
|
|
97
|
+
"max",
|
|
98
|
+
"ultra",
|
|
99
|
+
"auto",
|
|
100
|
+
)
|
|
101
|
+
_MAX_API_KEY_VALIDATION_ATTEMPTS = 3
|
|
102
|
+
_FALLBACK_VALIDATION_MODEL = "gpt-5.4-mini"
|
|
103
|
+
_VALIDATION_TIMEOUT_S = 8.0
|
|
104
|
+
_GEMINI_VALIDATION_TIMEOUT_S = 20.0
|
|
105
|
+
_GEMINI_VALIDATION_REASONING_EFFORT = "low"
|
|
106
|
+
_ADVANCED_PROVIDER_PRESETS_VALUE = "__advanced_provider_presets__"
|
|
107
|
+
_NATIVE_EXECUTION_VALUE = "__native_execution__"
|
|
108
|
+
_SUBSCRIPTION_EXECUTION_VALUE = "__subscription_execution__"
|
|
109
|
+
_RUNTIME_EXECUTION_PREFIX = "runtime:"
|
|
110
|
+
|
|
111
|
+
_ValidationStatus = Literal[
|
|
112
|
+
"validated",
|
|
113
|
+
"failed",
|
|
114
|
+
"inconclusive",
|
|
115
|
+
"skipped",
|
|
116
|
+
"model_not_found",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _SetupCancelled(Exception):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _GoBack(Exception):
|
|
125
|
+
"""Raised by a wizard step when the user pressed Esc to go back."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class _ExecutionStepResult:
|
|
130
|
+
backend: Literal["native", "delegated"]
|
|
131
|
+
runtime: str | None = None
|
|
132
|
+
label: str = "API key"
|
|
133
|
+
description: str = ""
|
|
134
|
+
auth_hint: str = ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class _DelegatedAuthResult:
|
|
139
|
+
connected: bool
|
|
140
|
+
summary: str
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class _ProfileStepResult:
|
|
145
|
+
profile: ProfileSpec
|
|
146
|
+
label: str
|
|
147
|
+
preset: ProfilePreset | None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class _ApiKeyStepResult:
|
|
152
|
+
api_key: str
|
|
153
|
+
validation_status: _ValidationStatus
|
|
154
|
+
validation_message: str = ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class _ModelStepResult:
|
|
159
|
+
model: str
|
|
160
|
+
custom: bool = False
|
|
161
|
+
reasoning_label: str = "auto"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class _WorkspaceStepResult:
|
|
166
|
+
workspace: str
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class _SandboxStepResult:
|
|
171
|
+
ready: bool
|
|
172
|
+
status: str
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class _ApiKeyValidationResult:
|
|
177
|
+
status: _ValidationStatus
|
|
178
|
+
message: str = ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class _ResolvedValidationModel:
|
|
183
|
+
model: str
|
|
184
|
+
used_fallback: bool
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class _FileSnapshot:
|
|
189
|
+
path: Path
|
|
190
|
+
existed: bool
|
|
191
|
+
data: bytes | None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def run_setup_wizard() -> bool:
|
|
195
|
+
"""Run the first-time setup wizard."""
|
|
196
|
+
console = _resolve_console()
|
|
197
|
+
escape_unavailable_reason = _escape_capture_unavailable_reason()
|
|
198
|
+
if escape_unavailable_reason is not None:
|
|
199
|
+
console.print(
|
|
200
|
+
f"[dim]Note: this terminal does not support Esc/back navigation ({escape_unavailable_reason}). "
|
|
201
|
+
"Use Ctrl+C to cancel.[/dim]"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
execution_result: _ExecutionStepResult | None = None
|
|
205
|
+
profile_result: _ProfileStepResult | None = None
|
|
206
|
+
api_key_result: _ApiKeyStepResult | None = None
|
|
207
|
+
model_result: _ModelStepResult | None = None
|
|
208
|
+
workspace_result: _WorkspaceStepResult | None = None
|
|
209
|
+
step_idx = 0
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
while True:
|
|
213
|
+
steps = _setup_step_names(execution_result)
|
|
214
|
+
if step_idx >= len(steps):
|
|
215
|
+
break
|
|
216
|
+
step_name = steps[step_idx]
|
|
217
|
+
try:
|
|
218
|
+
if step_name == "welcome":
|
|
219
|
+
_print_welcome(console)
|
|
220
|
+
_wait_to_begin()
|
|
221
|
+
elif step_name == "execution":
|
|
222
|
+
previous_execution = execution_result
|
|
223
|
+
execution_result = _prompt_execution(
|
|
224
|
+
console,
|
|
225
|
+
previous=previous_execution,
|
|
226
|
+
)
|
|
227
|
+
if previous_execution is not None and execution_result != previous_execution:
|
|
228
|
+
profile_result = None
|
|
229
|
+
api_key_result = None
|
|
230
|
+
model_result = None
|
|
231
|
+
elif step_name == "profile":
|
|
232
|
+
previous_profile = profile_result
|
|
233
|
+
new_profile = _prompt_profile(console, previous=previous_profile)
|
|
234
|
+
if previous_profile is not None and new_profile != previous_profile:
|
|
235
|
+
api_key_result = None
|
|
236
|
+
model_result = None
|
|
237
|
+
profile_result = new_profile
|
|
238
|
+
elif step_name == "api_key":
|
|
239
|
+
if profile_result is None:
|
|
240
|
+
raise ConfigError("Provider profile step was skipped.")
|
|
241
|
+
api_key_result = _prompt_api_key(
|
|
242
|
+
console,
|
|
243
|
+
profile_result,
|
|
244
|
+
previous=api_key_result,
|
|
245
|
+
)
|
|
246
|
+
elif step_name == "model":
|
|
247
|
+
if profile_result is None or api_key_result is None:
|
|
248
|
+
raise ConfigError("Provider profile or API key step was skipped.")
|
|
249
|
+
model_result, api_key_result = _prompt_model(
|
|
250
|
+
console,
|
|
251
|
+
profile_result,
|
|
252
|
+
api_key_result=api_key_result,
|
|
253
|
+
previous=model_result,
|
|
254
|
+
)
|
|
255
|
+
elif step_name == "workspace":
|
|
256
|
+
workspace_result = _prompt_workspace(console, previous=workspace_result)
|
|
257
|
+
step_idx += 1
|
|
258
|
+
except _GoBack:
|
|
259
|
+
if step_idx == 0:
|
|
260
|
+
if _confirm_cancel(console, prompt="Cancel setup? [y/N]"):
|
|
261
|
+
raise _SetupCancelled() from None
|
|
262
|
+
continue
|
|
263
|
+
step_idx -= 1
|
|
264
|
+
continue
|
|
265
|
+
except (KeyboardInterrupt, Abort, EOFError):
|
|
266
|
+
if _confirm_cancel(console):
|
|
267
|
+
raise _SetupCancelled() from None
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
if execution_result is None or workspace_result is None:
|
|
271
|
+
raise ConfigError("Setup did not collect all required inputs.")
|
|
272
|
+
|
|
273
|
+
if execution_result.backend == "delegated":
|
|
274
|
+
cfg = _commit_delegated_setup(
|
|
275
|
+
execution_result=execution_result,
|
|
276
|
+
workspace_result=workspace_result,
|
|
277
|
+
console=console,
|
|
278
|
+
)
|
|
279
|
+
auth_result = _maybe_connect_delegated_runtime(
|
|
280
|
+
console=console,
|
|
281
|
+
cfg=cfg,
|
|
282
|
+
execution_result=execution_result,
|
|
283
|
+
)
|
|
284
|
+
if cfg.execution.backend == "native":
|
|
285
|
+
_print_provider_diagnostic_warnings(console, cfg)
|
|
286
|
+
_prompt_and_check_sandbox(console, cfg)
|
|
287
|
+
_print_delegated_setup_complete(
|
|
288
|
+
console=console,
|
|
289
|
+
execution_result=execution_result,
|
|
290
|
+
workspace_result=workspace_result,
|
|
291
|
+
auth_result=auth_result,
|
|
292
|
+
cfg=cfg,
|
|
293
|
+
)
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
if profile_result is None or api_key_result is None or model_result is None:
|
|
297
|
+
raise ConfigError("Setup did not collect all required native-provider inputs.")
|
|
298
|
+
|
|
299
|
+
cfg = _commit_setup(
|
|
300
|
+
profile_result=profile_result,
|
|
301
|
+
api_key_result=api_key_result,
|
|
302
|
+
model_result=model_result,
|
|
303
|
+
workspace_result=workspace_result,
|
|
304
|
+
console=console,
|
|
305
|
+
)
|
|
306
|
+
_print_provider_diagnostic_warnings(console, cfg)
|
|
307
|
+
sandbox_result = _prompt_and_check_sandbox(console, cfg)
|
|
308
|
+
_print_setup_complete(
|
|
309
|
+
console=console,
|
|
310
|
+
profile_result=profile_result,
|
|
311
|
+
api_key_result=api_key_result,
|
|
312
|
+
model_result=model_result,
|
|
313
|
+
workspace_result=workspace_result,
|
|
314
|
+
sandbox_result=sandbox_result,
|
|
315
|
+
)
|
|
316
|
+
_maybe_offer_alysis_login(console, profile_result=profile_result, cfg=cfg)
|
|
317
|
+
return True
|
|
318
|
+
except _SetupCancelled:
|
|
319
|
+
console.print()
|
|
320
|
+
console.print("[yellow]Setup cancelled. No changes saved.[/yellow]")
|
|
321
|
+
return False
|
|
322
|
+
except (ConfigError, OSError) as exc:
|
|
323
|
+
console.print(f"[red]Setup failed:[/red] {exc}")
|
|
324
|
+
return False
|
|
325
|
+
except Exception as exc: # noqa: BLE001 - boundary translates unknown failures
|
|
326
|
+
log_path = _write_exception_log("setup_wizard", exc)
|
|
327
|
+
console.print(f"[red]Setup failed:[/red] {exc}")
|
|
328
|
+
if log_path is not None:
|
|
329
|
+
console.print(f"[dim]Details saved to: {log_path}[/dim]")
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _maybe_offer_alysis_login(
|
|
334
|
+
console: Console, *, profile_result: _ProfileStepResult, cfg: AppConfig
|
|
335
|
+
) -> None:
|
|
336
|
+
"""If the user chose the hosted MiMo (login-based) preset, offer to connect now.
|
|
337
|
+
|
|
338
|
+
That preset authenticates via `alysis login` instead of an API key.
|
|
339
|
+
Running the handshake here saves a separate step; declining is harmless (the
|
|
340
|
+
profile is already configured and `alysis login` can be run any time).
|
|
341
|
+
"""
|
|
342
|
+
from ..alysis_cloud import PROFILE_KEY
|
|
343
|
+
|
|
344
|
+
preset = profile_result.preset
|
|
345
|
+
if preset is None or preset.key != PROFILE_KEY:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
console.print()
|
|
349
|
+
try:
|
|
350
|
+
connect = _prompt_yes_no(
|
|
351
|
+
"Connect your Alysis Code account now? [Y/n]",
|
|
352
|
+
default=True,
|
|
353
|
+
)
|
|
354
|
+
except _GoBack:
|
|
355
|
+
connect = False
|
|
356
|
+
|
|
357
|
+
if not connect:
|
|
358
|
+
console.print("[dim]Run `alysis login` whenever you're ready to connect.[/dim]")
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
from .. import account_login
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
result = account_login.login(
|
|
365
|
+
cfg, output_write=lambda message: console.print(message, highlight=False)
|
|
366
|
+
)
|
|
367
|
+
except (account_login.AlysisLoginError, ConfigError) as exc:
|
|
368
|
+
console.print(f"[yellow]{exc}[/yellow]")
|
|
369
|
+
console.print("[dim]You can finish this later with `alysis login`.[/dim]")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
who = f" as [bold]{result.email}[/bold]" if result.email else ""
|
|
373
|
+
console.print(f"[green]Logged in{who}.[/green] Your Alysis Code account is connected.")
|
|
374
|
+
console.print(
|
|
375
|
+
f"[dim]Default model: [/dim][bold]{result.model}[/bold][dim] · "
|
|
376
|
+
"switch with /model in chat or `alysis config`.[/dim]"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _resolve_console() -> Console:
|
|
381
|
+
return make_console()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _print_welcome(console: Console) -> None:
|
|
385
|
+
body = Group(
|
|
386
|
+
Text("Alysis Code is a coding agent that runs in your terminal."),
|
|
387
|
+
Text("First choose how you want to connect Alysis Code to AI models."),
|
|
388
|
+
Text(""),
|
|
389
|
+
Text(" API key: connect directly to a supported model provider."),
|
|
390
|
+
Text(" AI subscription: sign in through a supported provider's official client."),
|
|
391
|
+
Text(""),
|
|
392
|
+
Text("Both paths ask for the workspace folder you want to work on."),
|
|
393
|
+
Text(""),
|
|
394
|
+
Text("[Enter] begin [Esc] cancel", style="dim"),
|
|
395
|
+
)
|
|
396
|
+
console.print()
|
|
397
|
+
console.print(Panel(body, title="Welcome to Alysis Code", border_style="cyan"))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _wait_to_begin() -> None:
|
|
401
|
+
value = _esc_aware_text_input("Press Enter to begin", default="", show_default=False)
|
|
402
|
+
if _is_cancel_token(value):
|
|
403
|
+
raise _GoBack()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _runtime_setup_options() -> tuple[Any, ...]:
|
|
407
|
+
from ..provider_auth import provider_auth_setup_options
|
|
408
|
+
|
|
409
|
+
return tuple(provider_auth_setup_options())
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _connection_method_picker_rows() -> list[tuple[str, str, str]]:
|
|
413
|
+
return [
|
|
414
|
+
(
|
|
415
|
+
_NATIVE_EXECUTION_VALUE,
|
|
416
|
+
"Use an API key",
|
|
417
|
+
"Connect directly to a supported model provider.",
|
|
418
|
+
),
|
|
419
|
+
(
|
|
420
|
+
_SUBSCRIPTION_EXECUTION_VALUE,
|
|
421
|
+
"Use an AI subscription",
|
|
422
|
+
"Sign in through a supported provider's official client.",
|
|
423
|
+
),
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _subscription_picker_rows() -> list[tuple[str, str, str]]:
|
|
428
|
+
rows: list[tuple[str, str, str]] = []
|
|
429
|
+
for option in _runtime_setup_options():
|
|
430
|
+
runtime_id = str(getattr(option, "id", "") or "").strip()
|
|
431
|
+
label = str(getattr(option, "label", "") or runtime_id).strip()
|
|
432
|
+
description = str(getattr(option, "description", "") or "").strip()
|
|
433
|
+
if not runtime_id or not label:
|
|
434
|
+
continue
|
|
435
|
+
rows.append((f"{_RUNTIME_EXECUTION_PREFIX}{runtime_id}", label, description))
|
|
436
|
+
return rows
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _connect_provider_picker_rows() -> list[tuple[str, str, str]]:
|
|
440
|
+
"""One merged "Connect a provider" list.
|
|
441
|
+
|
|
442
|
+
Subscription sign-ins and API-key providers appear side by side, so the
|
|
443
|
+
auth method is a property of the row rather than an upfront branch.
|
|
444
|
+
Subscription rows lead (including the Alysis Code account itself), then the
|
|
445
|
+
hosted providers in preset order; the advanced sub-list (local endpoints,
|
|
446
|
+
custom URLs, and legacy presets) stays behind the final row. Values are
|
|
447
|
+
either a preset key, a ``runtime:<id>`` subscription value, or
|
|
448
|
+
:data:`_ADVANCED_PROVIDER_PRESETS_VALUE`.
|
|
449
|
+
"""
|
|
450
|
+
rows: list[tuple[str, str, str]] = []
|
|
451
|
+
# The Alysis Code account leads: browser sign-in, no API key, free daily
|
|
452
|
+
# hosted DeepSeek included — the zero-friction way in.
|
|
453
|
+
rows.append(
|
|
454
|
+
(
|
|
455
|
+
"alysis",
|
|
456
|
+
"Alysis Code account",
|
|
457
|
+
"Free daily DeepSeek included — browser sign-in, no API key.",
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
for value, label, description in _subscription_picker_rows():
|
|
461
|
+
rows.append((value, label, description or "Browser sign-in with your subscription."))
|
|
462
|
+
rows.extend(
|
|
463
|
+
(preset.key, preset_selection_label(preset), "API key") for preset in _setup_presets()
|
|
464
|
+
)
|
|
465
|
+
rows.append(
|
|
466
|
+
(
|
|
467
|
+
_ADVANCED_PROVIDER_PRESETS_VALUE,
|
|
468
|
+
"Advanced / local / compatibility providers",
|
|
469
|
+
"Local endpoints (Ollama, LM Studio, vLLM), custom URLs, and legacy "
|
|
470
|
+
"OpenAI-compatible presets.",
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
return rows
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _runtime_setup_option(runtime_id: str) -> Any:
|
|
477
|
+
normalized = str(runtime_id or "").strip()
|
|
478
|
+
for option in _runtime_setup_options():
|
|
479
|
+
if str(getattr(option, "id", "") or "").strip() == normalized:
|
|
480
|
+
return option
|
|
481
|
+
raise ConfigError(f"Unknown AI subscription connection: {normalized or '(empty)'}")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _execution_result_from_value(value: str) -> _ExecutionStepResult:
|
|
485
|
+
selected = str(value or "").strip()
|
|
486
|
+
if selected == _NATIVE_EXECUTION_VALUE:
|
|
487
|
+
return _ExecutionStepResult(backend="native")
|
|
488
|
+
if not selected.startswith(_RUNTIME_EXECUTION_PREFIX):
|
|
489
|
+
raise ConfigError(f"Unknown connection method: {selected or '(empty)'}")
|
|
490
|
+
runtime_id = selected.removeprefix(_RUNTIME_EXECUTION_PREFIX).strip()
|
|
491
|
+
option = _runtime_setup_option(runtime_id)
|
|
492
|
+
return _ExecutionStepResult(
|
|
493
|
+
backend="delegated",
|
|
494
|
+
runtime=runtime_id,
|
|
495
|
+
label=str(getattr(option, "label", "") or runtime_id).strip(),
|
|
496
|
+
description=str(getattr(option, "description", "") or "").strip(),
|
|
497
|
+
auth_hint=str(getattr(option, "auth_hint", "") or "").strip(),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _prompt_execution(
|
|
502
|
+
console: Console,
|
|
503
|
+
*,
|
|
504
|
+
previous: _ExecutionStepResult | None = None,
|
|
505
|
+
) -> _ExecutionStepResult:
|
|
506
|
+
current_method = (
|
|
507
|
+
_SUBSCRIPTION_EXECUTION_VALUE
|
|
508
|
+
if previous is not None and previous.backend == "delegated"
|
|
509
|
+
else _NATIVE_EXECUTION_VALUE
|
|
510
|
+
)
|
|
511
|
+
while True:
|
|
512
|
+
selected_method = _run_wizard_picker(
|
|
513
|
+
console=console,
|
|
514
|
+
title="Connection Method",
|
|
515
|
+
subtitle="How would you like to connect Alysis Code to AI models?",
|
|
516
|
+
rows=_connection_method_picker_rows(),
|
|
517
|
+
current_value=current_method,
|
|
518
|
+
cancel_hint="back",
|
|
519
|
+
invalid_hint="Pick a connection method or press Esc to go back.",
|
|
520
|
+
)
|
|
521
|
+
if selected_method is None:
|
|
522
|
+
raise _GoBack()
|
|
523
|
+
if selected_method == _NATIVE_EXECUTION_VALUE:
|
|
524
|
+
result = _execution_result_from_value(selected_method)
|
|
525
|
+
console.print("[green]Connection method selected:[/green] Use an API key")
|
|
526
|
+
return result
|
|
527
|
+
if selected_method != _SUBSCRIPTION_EXECUTION_VALUE:
|
|
528
|
+
raise ConfigError(f"Unknown connection method: {selected_method or '(empty)'}")
|
|
529
|
+
|
|
530
|
+
rows = _subscription_picker_rows()
|
|
531
|
+
if not rows:
|
|
532
|
+
raise ConfigError("No AI subscription connections are available.")
|
|
533
|
+
current_runtime = rows[0][0]
|
|
534
|
+
if previous is not None and previous.backend == "delegated" and previous.runtime:
|
|
535
|
+
previous_runtime = f"{_RUNTIME_EXECUTION_PREFIX}{previous.runtime}"
|
|
536
|
+
if any(value == previous_runtime for value, _label, _description in rows):
|
|
537
|
+
current_runtime = previous_runtime
|
|
538
|
+
selected_runtime = _run_wizard_picker(
|
|
539
|
+
console=console,
|
|
540
|
+
title="AI Subscription",
|
|
541
|
+
subtitle="Choose the subscription you want to connect.",
|
|
542
|
+
rows=rows,
|
|
543
|
+
current_value=current_runtime,
|
|
544
|
+
cancel_hint="connection methods",
|
|
545
|
+
invalid_hint="Pick an AI subscription or press Esc to go back.",
|
|
546
|
+
)
|
|
547
|
+
if selected_runtime is None:
|
|
548
|
+
current_method = _SUBSCRIPTION_EXECUTION_VALUE
|
|
549
|
+
continue
|
|
550
|
+
result = _execution_result_from_value(selected_runtime)
|
|
551
|
+
console.print("[green]Connection method selected:[/green] Use an AI subscription")
|
|
552
|
+
console.print(f"[green]Subscription selected:[/green] {result.label}")
|
|
553
|
+
return result
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _setup_step_names(
|
|
557
|
+
execution_result: _ExecutionStepResult | None,
|
|
558
|
+
) -> tuple[str, ...]:
|
|
559
|
+
if execution_result is None:
|
|
560
|
+
return ("welcome", "execution")
|
|
561
|
+
if execution_result.backend == "delegated":
|
|
562
|
+
return ("welcome", "execution", "workspace")
|
|
563
|
+
return (
|
|
564
|
+
"welcome",
|
|
565
|
+
"execution",
|
|
566
|
+
"profile",
|
|
567
|
+
"api_key",
|
|
568
|
+
"model",
|
|
569
|
+
"workspace",
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _prompt_profile(
|
|
574
|
+
console: Console,
|
|
575
|
+
*,
|
|
576
|
+
previous: _ProfileStepResult | None = None,
|
|
577
|
+
) -> _ProfileStepResult:
|
|
578
|
+
rows = _provider_picker_rows(_setup_presets())
|
|
579
|
+
current_value = rows[0][0]
|
|
580
|
+
if previous is not None:
|
|
581
|
+
current_value = previous.preset.key if previous.preset is not None else _CUSTOM_PROVIDER_KEY
|
|
582
|
+
selected = _run_wizard_picker(
|
|
583
|
+
console=console,
|
|
584
|
+
title="Provider Profile",
|
|
585
|
+
subtitle="Pick the provider you want Alysis Code to use.",
|
|
586
|
+
rows=rows,
|
|
587
|
+
current_value=current_value,
|
|
588
|
+
cancel_hint="back",
|
|
589
|
+
invalid_hint="Pick a provider first; you'll enter the key in the next step.",
|
|
590
|
+
)
|
|
591
|
+
if selected == _ADVANCED_PROVIDER_PRESETS_VALUE:
|
|
592
|
+
advanced_rows = [
|
|
593
|
+
(preset.key, preset_selection_label(preset), _preset_description(preset))
|
|
594
|
+
for preset in _advanced_setup_presets()
|
|
595
|
+
]
|
|
596
|
+
selected = _run_wizard_picker(
|
|
597
|
+
console=console,
|
|
598
|
+
title="Advanced Provider Profile",
|
|
599
|
+
subtitle="Pick a local, custom, or legacy OpenAI-compatible provider preset.",
|
|
600
|
+
rows=advanced_rows,
|
|
601
|
+
current_value=advanced_rows[0][0] if advanced_rows else "openai",
|
|
602
|
+
cancel_hint="back",
|
|
603
|
+
invalid_hint="Pick a provider preset or press Esc to go back.",
|
|
604
|
+
)
|
|
605
|
+
if selected is None:
|
|
606
|
+
raise _GoBack()
|
|
607
|
+
|
|
608
|
+
preset = _preset_by_key(selected)
|
|
609
|
+
if preset is None:
|
|
610
|
+
raise ConfigError(f"Unknown provider preset: {selected}")
|
|
611
|
+
if preset.key == _CUSTOM_PROVIDER_KEY:
|
|
612
|
+
profile = _prompt_custom_profile(
|
|
613
|
+
previous.profile if previous is not None and previous.preset is None else None
|
|
614
|
+
)
|
|
615
|
+
return _ProfileStepResult(profile=profile, label=profile.name, preset=None)
|
|
616
|
+
|
|
617
|
+
profile = make_profile_from_preset(preset)
|
|
618
|
+
console.print(f"[green]Provider selected:[/green] {preset.label}")
|
|
619
|
+
_print_preset_warning(console, preset)
|
|
620
|
+
return _ProfileStepResult(profile=profile, label=preset.label, preset=preset)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _prompt_custom_profile(previous: ProfileSpec | None = None) -> ProfileSpec:
|
|
624
|
+
while True:
|
|
625
|
+
name = (
|
|
626
|
+
_esc_aware_text_input(
|
|
627
|
+
"Profile name",
|
|
628
|
+
default=(previous.name if previous is not None else "custom"),
|
|
629
|
+
show_default=True,
|
|
630
|
+
)
|
|
631
|
+
.strip()
|
|
632
|
+
.lower()
|
|
633
|
+
)
|
|
634
|
+
if _is_cancel_token(name):
|
|
635
|
+
raise _GoBack()
|
|
636
|
+
if name:
|
|
637
|
+
break
|
|
638
|
+
_resolve_console().print("[red]Profile name is required.[/red]")
|
|
639
|
+
while True:
|
|
640
|
+
base_url = _esc_aware_text_input(
|
|
641
|
+
"Base URL",
|
|
642
|
+
default=(previous.base_url if previous is not None else ""),
|
|
643
|
+
show_default=previous is not None,
|
|
644
|
+
).strip()
|
|
645
|
+
if _is_cancel_token(base_url):
|
|
646
|
+
raise _GoBack()
|
|
647
|
+
if base_url:
|
|
648
|
+
break
|
|
649
|
+
_resolve_console().print("[red]Base URL is required.[/red]")
|
|
650
|
+
headers = _prompt_extra_headers(previous.extra_headers if previous is not None else None)
|
|
651
|
+
return ProfileSpec(
|
|
652
|
+
name=name,
|
|
653
|
+
protocol=previous.protocol if previous is not None else "openai_compat",
|
|
654
|
+
base_url=base_url,
|
|
655
|
+
api_key_env=previous.api_key_env if previous is not None else None,
|
|
656
|
+
auth_provider=previous.auth_provider if previous is not None else None,
|
|
657
|
+
extra_headers=headers,
|
|
658
|
+
default_model=previous.default_model if previous is not None else "",
|
|
659
|
+
reasoning_effort=previous.reasoning_effort if previous is not None else None,
|
|
660
|
+
reasoning_trace_adapter=(
|
|
661
|
+
previous.reasoning_trace_adapter if previous is not None else "auto"
|
|
662
|
+
),
|
|
663
|
+
web_search_adapter=previous.web_search_adapter if previous is not None else "auto",
|
|
664
|
+
web_search_model=previous.web_search_model if previous is not None else "",
|
|
665
|
+
notes=previous.notes if previous is not None else "Custom OpenAI-compatible endpoint.",
|
|
666
|
+
cache_capability=previous.cache_capability if previous is not None else None,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _prompt_extra_headers(previous: dict[str, str] | None = None) -> dict[str, str]:
|
|
671
|
+
previous_text = ", ".join(f"{key}={value}" for key, value in (previous or {}).items())
|
|
672
|
+
while True:
|
|
673
|
+
headers_raw = _esc_aware_text_input(
|
|
674
|
+
"Extra headers (k=v, comma-separated)",
|
|
675
|
+
default=previous_text,
|
|
676
|
+
show_default=bool(previous_text),
|
|
677
|
+
).strip()
|
|
678
|
+
if _is_cancel_token(headers_raw):
|
|
679
|
+
raise _GoBack()
|
|
680
|
+
headers: dict[str, str] = {}
|
|
681
|
+
valid = True
|
|
682
|
+
for item in headers_raw.split(","):
|
|
683
|
+
text = item.strip()
|
|
684
|
+
if not text:
|
|
685
|
+
continue
|
|
686
|
+
if "=" not in text:
|
|
687
|
+
_resolve_console().print("[red]Extra headers must use k=v syntax.[/red]")
|
|
688
|
+
valid = False
|
|
689
|
+
break
|
|
690
|
+
key, value = text.split("=", 1)
|
|
691
|
+
if key.strip() and value.strip():
|
|
692
|
+
headers[key.strip()] = value.strip()
|
|
693
|
+
if valid:
|
|
694
|
+
return headers
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _prompt_api_key(
|
|
698
|
+
console: Console,
|
|
699
|
+
profile_result: _ProfileStepResult,
|
|
700
|
+
*,
|
|
701
|
+
previous: _ApiKeyStepResult | None = None,
|
|
702
|
+
) -> _ApiKeyStepResult:
|
|
703
|
+
profile = profile_result.profile
|
|
704
|
+
console.print()
|
|
705
|
+
console.print(Panel(_api_key_panel_text(profile_result), title="API Key", border_style="cyan"))
|
|
706
|
+
required = bool(profile.api_key_env)
|
|
707
|
+
attempts = 0
|
|
708
|
+
last_key = previous.api_key if previous is not None else ""
|
|
709
|
+
while attempts < _MAX_API_KEY_VALIDATION_ATTEMPTS:
|
|
710
|
+
prompt_label = "Paste your API key" if required else "API key (optional)"
|
|
711
|
+
if previous is not None:
|
|
712
|
+
prompt_label += " (Enter to keep current)"
|
|
713
|
+
value = _esc_aware_text_input(
|
|
714
|
+
prompt_label,
|
|
715
|
+
default="",
|
|
716
|
+
hide_input=True,
|
|
717
|
+
show_default=False,
|
|
718
|
+
).strip()
|
|
719
|
+
if _is_cancel_token(value):
|
|
720
|
+
raise _GoBack()
|
|
721
|
+
if not value and previous is not None:
|
|
722
|
+
return previous
|
|
723
|
+
if not value:
|
|
724
|
+
if required:
|
|
725
|
+
console.print("[red]API key is required to continue.[/red]")
|
|
726
|
+
attempts += 1
|
|
727
|
+
continue
|
|
728
|
+
console.print("[yellow]No API key stored for this profile.[/yellow]")
|
|
729
|
+
return _ApiKeyStepResult(
|
|
730
|
+
api_key="",
|
|
731
|
+
validation_status="skipped",
|
|
732
|
+
validation_message="No API key provided.",
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
last_key = value
|
|
736
|
+
console.print("[dim]Validating...[/dim]")
|
|
737
|
+
validation = _validate_api_key(
|
|
738
|
+
profile=profile,
|
|
739
|
+
api_key=value,
|
|
740
|
+
suggested_models=_suggested_models(profile_result),
|
|
741
|
+
validation_model=_validation_model_hint(profile_result),
|
|
742
|
+
)
|
|
743
|
+
if validation.status == "validated":
|
|
744
|
+
console.print("[green]Key validated.[/green]")
|
|
745
|
+
if validation.message:
|
|
746
|
+
console.print(f"[yellow]{validation.message}[/yellow]")
|
|
747
|
+
return _ApiKeyStepResult(
|
|
748
|
+
api_key=value,
|
|
749
|
+
validation_status="validated",
|
|
750
|
+
validation_message=validation.message,
|
|
751
|
+
)
|
|
752
|
+
if validation.status == "inconclusive":
|
|
753
|
+
console.print(f"[yellow]{validation.message} Continuing without validation.[/yellow]")
|
|
754
|
+
return _ApiKeyStepResult(
|
|
755
|
+
api_key=value,
|
|
756
|
+
validation_status="inconclusive",
|
|
757
|
+
validation_message=validation.message,
|
|
758
|
+
)
|
|
759
|
+
if validation.status == "model_not_found":
|
|
760
|
+
console.print(
|
|
761
|
+
f"[yellow]{validation.message} We'll verify your chosen model next.[/yellow]"
|
|
762
|
+
)
|
|
763
|
+
return _ApiKeyStepResult(
|
|
764
|
+
api_key=value,
|
|
765
|
+
validation_status="model_not_found",
|
|
766
|
+
validation_message=validation.message,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
attempts += 1
|
|
770
|
+
console.print(
|
|
771
|
+
f"[yellow]Key validation failed:[/yellow] {validation.message or 'provider rejected the key'}"
|
|
772
|
+
)
|
|
773
|
+
if attempts >= _MAX_API_KEY_VALIDATION_ATTEMPTS:
|
|
774
|
+
console.print(
|
|
775
|
+
"[yellow]Continuing with the last key. You can fix it later in /config.[/yellow]"
|
|
776
|
+
)
|
|
777
|
+
return _ApiKeyStepResult(
|
|
778
|
+
api_key=last_key,
|
|
779
|
+
validation_status="failed",
|
|
780
|
+
validation_message=validation.message,
|
|
781
|
+
)
|
|
782
|
+
if not _prompt_yes_no("Re-enter API key now? [Y/n]", default=True):
|
|
783
|
+
console.print(
|
|
784
|
+
"[yellow]Continuing without validation. You can fix it later in /config.[/yellow]"
|
|
785
|
+
)
|
|
786
|
+
return _ApiKeyStepResult(
|
|
787
|
+
api_key=value,
|
|
788
|
+
validation_status="failed",
|
|
789
|
+
validation_message=validation.message,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
return _ApiKeyStepResult(
|
|
793
|
+
api_key=last_key,
|
|
794
|
+
validation_status="failed",
|
|
795
|
+
validation_message="Provider rejected the API key.",
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _api_key_panel_text(profile_result: _ProfileStepResult) -> str:
|
|
800
|
+
profile = profile_result.profile
|
|
801
|
+
lines = [f"Alysis Code will use {profile_result.label}."]
|
|
802
|
+
lines.append(
|
|
803
|
+
"Protocol: "
|
|
804
|
+
+ (
|
|
805
|
+
f"native first-party ({profile.protocol})"
|
|
806
|
+
if profile.protocol in NATIVE_PROFILE_PROTOCOLS
|
|
807
|
+
else "OpenAI-compatible"
|
|
808
|
+
)
|
|
809
|
+
)
|
|
810
|
+
if profile.base_url:
|
|
811
|
+
lines.append(f"Provider URL: {profile.base_url}")
|
|
812
|
+
if profile.api_key_env:
|
|
813
|
+
lines.append(f"The key can also be set via env: {profile.api_key_env}")
|
|
814
|
+
else:
|
|
815
|
+
lines.append("This profile does not declare a required API key env variable.")
|
|
816
|
+
lines.append("")
|
|
817
|
+
lines.append("Enter to confirm. Esc to go back. Ctrl+C to cancel.")
|
|
818
|
+
return "\n".join(lines)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _suggested_models(profile_result: _ProfileStepResult) -> tuple[str, ...]:
|
|
822
|
+
if profile_result.preset is None:
|
|
823
|
+
return ()
|
|
824
|
+
return tuple(
|
|
825
|
+
str(model).strip() for model in profile_result.preset.suggested_models if str(model).strip()
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _validation_model_hint(profile_result: _ProfileStepResult) -> str:
|
|
830
|
+
preset = profile_result.preset
|
|
831
|
+
if preset is None:
|
|
832
|
+
return ""
|
|
833
|
+
model = str(preset.validation_model or "").strip()
|
|
834
|
+
if not model:
|
|
835
|
+
return ""
|
|
836
|
+
return canonical_model_alias_for_preset(preset, model)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _validate_api_key(
|
|
840
|
+
*,
|
|
841
|
+
profile: ProfileSpec,
|
|
842
|
+
api_key: str,
|
|
843
|
+
model: str | None = None,
|
|
844
|
+
suggested_models: tuple[str, ...] = (),
|
|
845
|
+
validation_model: str | None = None,
|
|
846
|
+
transport: httpx.BaseTransport | None = None,
|
|
847
|
+
) -> _ApiKeyValidationResult:
|
|
848
|
+
if not api_key.strip():
|
|
849
|
+
return _ApiKeyValidationResult(status="skipped", message="No API key provided.")
|
|
850
|
+
base_url = str(profile.base_url or "").strip().rstrip("/")
|
|
851
|
+
if not base_url:
|
|
852
|
+
return _ApiKeyValidationResult(
|
|
853
|
+
status="inconclusive", message="Could not reach provider: Base URL is missing."
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
resolved_validation_model = _resolve_validation_model(
|
|
857
|
+
profile=profile,
|
|
858
|
+
model=model,
|
|
859
|
+
suggested_models=suggested_models,
|
|
860
|
+
validation_model=validation_model,
|
|
861
|
+
)
|
|
862
|
+
validation_profile = ProfileSpec(
|
|
863
|
+
name=profile.name,
|
|
864
|
+
protocol=profile.protocol,
|
|
865
|
+
base_url=base_url,
|
|
866
|
+
api_key_env=profile.api_key_env,
|
|
867
|
+
extra_headers=dict(profile.extra_headers),
|
|
868
|
+
default_model=resolved_validation_model.model,
|
|
869
|
+
reasoning_effort=profile.reasoning_effort,
|
|
870
|
+
reasoning_trace_adapter=profile.reasoning_trace_adapter,
|
|
871
|
+
web_search_adapter=profile.web_search_adapter,
|
|
872
|
+
web_search_model=profile.web_search_model,
|
|
873
|
+
notes=profile.notes,
|
|
874
|
+
)
|
|
875
|
+
cfg = AppConfig(
|
|
876
|
+
model=resolved_validation_model.model,
|
|
877
|
+
provider_retry_max_retries=0,
|
|
878
|
+
)
|
|
879
|
+
client = make_llm_client(
|
|
880
|
+
cfg=cfg,
|
|
881
|
+
api_key=api_key,
|
|
882
|
+
model=resolved_validation_model.model,
|
|
883
|
+
timeout_s=_validation_timeout_s(validation_profile),
|
|
884
|
+
temperature=0.0,
|
|
885
|
+
reasoning_effort=_validation_reasoning_effort(validation_profile),
|
|
886
|
+
transport=transport,
|
|
887
|
+
profile=validation_profile,
|
|
888
|
+
)
|
|
889
|
+
try:
|
|
890
|
+
client.chat(
|
|
891
|
+
messages=[{"role": "user", "content": "ping"}],
|
|
892
|
+
temperature=0.0,
|
|
893
|
+
)
|
|
894
|
+
except LLMError as exc:
|
|
895
|
+
return _validation_result_from_error(
|
|
896
|
+
exc,
|
|
897
|
+
base_url=base_url,
|
|
898
|
+
model=resolved_validation_model.model,
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
message = ""
|
|
902
|
+
if resolved_validation_model.used_fallback:
|
|
903
|
+
message = (
|
|
904
|
+
f"Validated API key using '{resolved_validation_model.model}'. "
|
|
905
|
+
"The selected model will be verified next."
|
|
906
|
+
)
|
|
907
|
+
return _ApiKeyValidationResult(status="validated", message=message)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _resolve_validation_model(
|
|
911
|
+
*,
|
|
912
|
+
profile: ProfileSpec,
|
|
913
|
+
model: str | None,
|
|
914
|
+
suggested_models: tuple[str, ...],
|
|
915
|
+
validation_model: str | None = None,
|
|
916
|
+
) -> _ResolvedValidationModel:
|
|
917
|
+
explicit_model = str(model or "").strip()
|
|
918
|
+
if explicit_model:
|
|
919
|
+
return _ResolvedValidationModel(model=explicit_model, used_fallback=False)
|
|
920
|
+
validation_candidate = str(validation_model or "").strip()
|
|
921
|
+
if validation_candidate:
|
|
922
|
+
return _ResolvedValidationModel(model=validation_candidate, used_fallback=True)
|
|
923
|
+
for candidate in (profile.default_model, *suggested_models):
|
|
924
|
+
normalized = str(candidate or "").strip()
|
|
925
|
+
if normalized:
|
|
926
|
+
return _ResolvedValidationModel(model=normalized, used_fallback=False)
|
|
927
|
+
return _ResolvedValidationModel(model=_FALLBACK_VALIDATION_MODEL, used_fallback=True)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _validation_timeout_s(profile: ProfileSpec) -> float:
|
|
931
|
+
if _is_gemini_profile(profile):
|
|
932
|
+
return _GEMINI_VALIDATION_TIMEOUT_S
|
|
933
|
+
return _VALIDATION_TIMEOUT_S
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _validation_reasoning_effort(profile: ProfileSpec) -> str | None:
|
|
937
|
+
if _is_gemini_profile(profile):
|
|
938
|
+
return _GEMINI_VALIDATION_REASONING_EFFORT
|
|
939
|
+
if _is_zai_coding_plan_profile(profile):
|
|
940
|
+
# The plan defaults to max. A low-effort one-token validation proves
|
|
941
|
+
# model access while consuming the smallest documented reasoning tier.
|
|
942
|
+
return "low"
|
|
943
|
+
return None
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _is_gemini_profile(profile: ProfileSpec) -> bool:
|
|
947
|
+
name = str(profile.name or "").strip().casefold()
|
|
948
|
+
base_url = str(profile.base_url or "").strip().casefold()
|
|
949
|
+
return name == "gemini" or "generativelanguage.googleapis.com" in base_url
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _is_zai_coding_plan_profile(profile: ProfileSpec) -> bool:
|
|
953
|
+
name = str(profile.name or "").strip().casefold()
|
|
954
|
+
base_url = str(profile.base_url or "").strip().rstrip("/").casefold()
|
|
955
|
+
return name == "zai-coding-plan" or base_url == "https://api.z.ai/api/coding/paas/v4"
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _validation_result_from_error(
|
|
959
|
+
exc: LLMError,
|
|
960
|
+
*,
|
|
961
|
+
base_url: str,
|
|
962
|
+
model: str,
|
|
963
|
+
) -> _ApiKeyValidationResult:
|
|
964
|
+
status_code = _http_status_from_llm_error(exc)
|
|
965
|
+
error_text = str(exc)
|
|
966
|
+
error_text_lower = error_text.casefold()
|
|
967
|
+
|
|
968
|
+
if status_code == 200:
|
|
969
|
+
return _ApiKeyValidationResult(status="validated")
|
|
970
|
+
if status_code in {401, 403}:
|
|
971
|
+
return _ApiKeyValidationResult(
|
|
972
|
+
status="failed",
|
|
973
|
+
message=f"Provider rejected the API key (HTTP {status_code}).",
|
|
974
|
+
)
|
|
975
|
+
if status_code == 400 and _is_model_not_found_error(error_text_lower):
|
|
976
|
+
return _ApiKeyValidationResult(
|
|
977
|
+
status="model_not_found",
|
|
978
|
+
message=f"Model '{model}' not found at this provider. Pick a different model in the next step.",
|
|
979
|
+
)
|
|
980
|
+
if status_code == 404:
|
|
981
|
+
return _ApiKeyValidationResult(
|
|
982
|
+
status="inconclusive",
|
|
983
|
+
message=f"Provider endpoint not found at {base_url}. Check the base URL.",
|
|
984
|
+
)
|
|
985
|
+
if status_code == 429:
|
|
986
|
+
return _ApiKeyValidationResult(
|
|
987
|
+
status="inconclusive",
|
|
988
|
+
message="Provider rate-limited the validation request. Continuing without validation.",
|
|
989
|
+
)
|
|
990
|
+
if status_code is not None and status_code >= 500:
|
|
991
|
+
return _ApiKeyValidationResult(
|
|
992
|
+
status="inconclusive",
|
|
993
|
+
message=f"Provider returned HTTP {status_code} during validation.",
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
cause = _root_cause(exc)
|
|
997
|
+
if isinstance(cause, httpx.TimeoutException):
|
|
998
|
+
return _ApiKeyValidationResult(
|
|
999
|
+
status="inconclusive",
|
|
1000
|
+
message=f"Could not reach {base_url}: validation request timed out.",
|
|
1001
|
+
)
|
|
1002
|
+
if isinstance(cause, httpx.HTTPError):
|
|
1003
|
+
return _ApiKeyValidationResult(
|
|
1004
|
+
status="inconclusive",
|
|
1005
|
+
message=f"Could not reach {base_url}: {cause}",
|
|
1006
|
+
)
|
|
1007
|
+
return _ApiKeyValidationResult(
|
|
1008
|
+
status="inconclusive",
|
|
1009
|
+
message=f"Could not verify {base_url}: {exc}",
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _is_model_not_found_error(error_text_lower: str) -> bool:
|
|
1014
|
+
if "not_found" in error_text_lower and "model" in error_text_lower:
|
|
1015
|
+
return True
|
|
1016
|
+
if "model_not_found" in error_text_lower:
|
|
1017
|
+
return True
|
|
1018
|
+
if "model not found" in error_text_lower:
|
|
1019
|
+
return True
|
|
1020
|
+
if re.search(
|
|
1021
|
+
r"\bmodel\b.{0,80}\b(?:does not exist|not exist|not found|unknown)", error_text_lower
|
|
1022
|
+
):
|
|
1023
|
+
return True
|
|
1024
|
+
return (
|
|
1025
|
+
re.search(
|
|
1026
|
+
r"\b(?:does not exist|not exist|not found|unknown)\b.{0,80}\bmodel\b", error_text_lower
|
|
1027
|
+
)
|
|
1028
|
+
is not None
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _http_status_from_llm_error(exc: LLMError) -> int | None:
|
|
1033
|
+
match = re.search(r"\bLLM error\s+(\d{3})\b", str(exc))
|
|
1034
|
+
if match is None:
|
|
1035
|
+
return None
|
|
1036
|
+
try:
|
|
1037
|
+
return int(match.group(1))
|
|
1038
|
+
except ValueError:
|
|
1039
|
+
return None
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _root_cause(exc: BaseException) -> BaseException:
|
|
1043
|
+
cause = exc
|
|
1044
|
+
seen: set[int] = set()
|
|
1045
|
+
while cause.__cause__ is not None and id(cause.__cause__) not in seen:
|
|
1046
|
+
seen.add(id(cause))
|
|
1047
|
+
cause = cause.__cause__
|
|
1048
|
+
return cause
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _prompt_model(
|
|
1052
|
+
console: Console,
|
|
1053
|
+
profile_result: _ProfileStepResult,
|
|
1054
|
+
*,
|
|
1055
|
+
api_key_result: _ApiKeyStepResult,
|
|
1056
|
+
previous: _ModelStepResult | None = None,
|
|
1057
|
+
) -> tuple[_ModelStepResult, _ApiKeyStepResult]:
|
|
1058
|
+
current_previous = previous
|
|
1059
|
+
discovered_models, catalog_warning = _discover_setup_provider_models(
|
|
1060
|
+
profile_result,
|
|
1061
|
+
api_key=api_key_result.api_key,
|
|
1062
|
+
)
|
|
1063
|
+
if catalog_warning:
|
|
1064
|
+
console.print(f"[yellow]{catalog_warning}[/yellow]")
|
|
1065
|
+
while True:
|
|
1066
|
+
model_result = _prompt_model_choice(
|
|
1067
|
+
console,
|
|
1068
|
+
profile_result,
|
|
1069
|
+
previous=current_previous,
|
|
1070
|
+
discovered_models=discovered_models,
|
|
1071
|
+
)
|
|
1072
|
+
updated_api_key_result = _validate_selected_model(
|
|
1073
|
+
console=console,
|
|
1074
|
+
profile_result=profile_result,
|
|
1075
|
+
api_key_result=api_key_result,
|
|
1076
|
+
model_result=model_result,
|
|
1077
|
+
)
|
|
1078
|
+
if updated_api_key_result.validation_status == "model_not_found":
|
|
1079
|
+
current_previous = model_result
|
|
1080
|
+
if model_result.custom and _prompt_yes_no(
|
|
1081
|
+
f"Use custom model '{model_result.model}' anyway? [y/N]",
|
|
1082
|
+
default=False,
|
|
1083
|
+
):
|
|
1084
|
+
warning = (
|
|
1085
|
+
f"Model '{model_result.model}' was not confirmed; "
|
|
1086
|
+
"provider reported it was missing and you chose to use it anyway."
|
|
1087
|
+
)
|
|
1088
|
+
return model_result, replace(
|
|
1089
|
+
updated_api_key_result,
|
|
1090
|
+
validation_status="inconclusive",
|
|
1091
|
+
validation_message=warning,
|
|
1092
|
+
)
|
|
1093
|
+
continue
|
|
1094
|
+
reasoning_label = _prompt_setup_reasoning_label(
|
|
1095
|
+
console,
|
|
1096
|
+
profile_result=profile_result,
|
|
1097
|
+
model=model_result.model,
|
|
1098
|
+
current=model_result.reasoning_label,
|
|
1099
|
+
)
|
|
1100
|
+
return replace(model_result, reasoning_label=reasoning_label), updated_api_key_result
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _prompt_model_choice(
|
|
1104
|
+
console: Console,
|
|
1105
|
+
profile_result: _ProfileStepResult,
|
|
1106
|
+
*,
|
|
1107
|
+
previous: _ModelStepResult | None = None,
|
|
1108
|
+
discovered_models: tuple[ProviderModelOption, ...] = (),
|
|
1109
|
+
) -> _ModelStepResult:
|
|
1110
|
+
rows = _model_picker_rows(profile_result, discovered_models=discovered_models)
|
|
1111
|
+
row_values = {value for value, _label, _description in rows}
|
|
1112
|
+
current_value = rows[0][0]
|
|
1113
|
+
if previous is not None:
|
|
1114
|
+
current_value = previous.model if previous.model in row_values else _CUSTOM_MODEL_VALUE
|
|
1115
|
+
selected = _run_wizard_picker(
|
|
1116
|
+
console=console,
|
|
1117
|
+
title="Default Model",
|
|
1118
|
+
subtitle=f"Pick the model Alysis Code will use by default for {profile_result.label}.",
|
|
1119
|
+
rows=rows,
|
|
1120
|
+
current_value=current_value,
|
|
1121
|
+
cancel_hint="back",
|
|
1122
|
+
)
|
|
1123
|
+
if selected is None:
|
|
1124
|
+
raise _GoBack()
|
|
1125
|
+
if selected == _CUSTOM_MODEL_VALUE:
|
|
1126
|
+
default_model = (
|
|
1127
|
+
previous.model if previous is not None and previous.model not in row_values else ""
|
|
1128
|
+
)
|
|
1129
|
+
model = _prompt_custom_model_name(previous=default_model or None)
|
|
1130
|
+
is_custom = True
|
|
1131
|
+
else:
|
|
1132
|
+
model = selected
|
|
1133
|
+
is_custom = False
|
|
1134
|
+
if profile_result.preset is not None:
|
|
1135
|
+
model = canonical_model_alias_for_preset(profile_result.preset, model)
|
|
1136
|
+
if not model.strip():
|
|
1137
|
+
raise ConfigError("Default model is required.")
|
|
1138
|
+
console.print(f"[green]Default model:[/green] {model}")
|
|
1139
|
+
return _ModelStepResult(model=model.strip(), custom=is_custom)
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _validate_selected_model(
|
|
1143
|
+
*,
|
|
1144
|
+
console: Console,
|
|
1145
|
+
profile_result: _ProfileStepResult,
|
|
1146
|
+
api_key_result: _ApiKeyStepResult,
|
|
1147
|
+
model_result: _ModelStepResult,
|
|
1148
|
+
) -> _ApiKeyStepResult:
|
|
1149
|
+
if not api_key_result.api_key:
|
|
1150
|
+
return api_key_result
|
|
1151
|
+
if api_key_result.validation_status == "failed":
|
|
1152
|
+
console.print(
|
|
1153
|
+
"[yellow]Skipping model validation because the provider already rejected the API key.[/yellow]"
|
|
1154
|
+
)
|
|
1155
|
+
return api_key_result
|
|
1156
|
+
|
|
1157
|
+
console.print("[dim]Validating selected model...[/dim]")
|
|
1158
|
+
validation = _validate_api_key(
|
|
1159
|
+
profile=profile_result.profile,
|
|
1160
|
+
api_key=api_key_result.api_key,
|
|
1161
|
+
model=model_result.model,
|
|
1162
|
+
suggested_models=_suggested_models(profile_result),
|
|
1163
|
+
)
|
|
1164
|
+
if validation.status == "validated":
|
|
1165
|
+
console.print("[green]Model validated.[/green]")
|
|
1166
|
+
elif validation.status == "model_not_found":
|
|
1167
|
+
console.print(f"[red]{validation.message}[/red]")
|
|
1168
|
+
elif validation.status == "failed":
|
|
1169
|
+
console.print(f"[red]{validation.message}[/red]")
|
|
1170
|
+
elif validation.status == "inconclusive":
|
|
1171
|
+
console.print(f"[yellow]{validation.message}[/yellow]")
|
|
1172
|
+
return replace(
|
|
1173
|
+
api_key_result,
|
|
1174
|
+
validation_status=validation.status,
|
|
1175
|
+
validation_message=validation.message,
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _discover_setup_provider_models(
|
|
1180
|
+
profile_result: _ProfileStepResult,
|
|
1181
|
+
*,
|
|
1182
|
+
api_key: str,
|
|
1183
|
+
transport: httpx.BaseTransport | None = None,
|
|
1184
|
+
) -> tuple[tuple[ProviderModelOption, ...], str]:
|
|
1185
|
+
preset = profile_result.preset
|
|
1186
|
+
if preset is None or preset.key != _NVIDIA_PRESET_KEY:
|
|
1187
|
+
return (), ""
|
|
1188
|
+
try:
|
|
1189
|
+
options = discover_provider_models(
|
|
1190
|
+
profile=profile_result.profile,
|
|
1191
|
+
api_key=api_key,
|
|
1192
|
+
transport=transport,
|
|
1193
|
+
)
|
|
1194
|
+
except ProviderModelCatalogError:
|
|
1195
|
+
return (), (
|
|
1196
|
+
"NVIDIA's live model catalog is unavailable. Recommended models and "
|
|
1197
|
+
"custom model entry are still available."
|
|
1198
|
+
)
|
|
1199
|
+
return tuple(sorted(options, key=lambda option: option.id.casefold())), ""
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _model_picker_rows(
|
|
1203
|
+
profile_result: _ProfileStepResult,
|
|
1204
|
+
*,
|
|
1205
|
+
discovered_models: tuple[ProviderModelOption, ...] = (),
|
|
1206
|
+
) -> list[tuple[str, str, str]]:
|
|
1207
|
+
rows: list[tuple[str, str, str]] = []
|
|
1208
|
+
seen: set[str] = set()
|
|
1209
|
+
preset = profile_result.preset
|
|
1210
|
+
if preset is not None:
|
|
1211
|
+
for value, label, description in model_options_for_preset(preset):
|
|
1212
|
+
if value in seen:
|
|
1213
|
+
continue
|
|
1214
|
+
seen.add(value)
|
|
1215
|
+
rows.append((value, label, description))
|
|
1216
|
+
if profile_result.profile.default_model:
|
|
1217
|
+
normalized = str(profile_result.profile.default_model).strip()
|
|
1218
|
+
if preset is not None:
|
|
1219
|
+
normalized = canonical_model_alias_for_preset(preset, normalized)
|
|
1220
|
+
if normalized and normalized not in seen:
|
|
1221
|
+
seen.add(normalized)
|
|
1222
|
+
rows.append((normalized, normalized, "current profile default"))
|
|
1223
|
+
custom_row = (
|
|
1224
|
+
_CUSTOM_MODEL_VALUE,
|
|
1225
|
+
"Type a custom model name",
|
|
1226
|
+
"Use any model supported by this provider",
|
|
1227
|
+
)
|
|
1228
|
+
if preset is not None and preset.key == _NVIDIA_PRESET_KEY:
|
|
1229
|
+
# Keep manual entry reachable without scrolling through NVIDIA's large
|
|
1230
|
+
# live catalog. Curated recommendations remain above it; live inventory
|
|
1231
|
+
# follows below.
|
|
1232
|
+
rows.append(custom_row)
|
|
1233
|
+
for option in discovered_models:
|
|
1234
|
+
model_id = str(option.id or "").strip()
|
|
1235
|
+
if not model_id or model_id in seen:
|
|
1236
|
+
continue
|
|
1237
|
+
seen.add(model_id)
|
|
1238
|
+
description = str(option.description or "").strip()
|
|
1239
|
+
if not description:
|
|
1240
|
+
owner = model_id.partition("/")[0]
|
|
1241
|
+
description = (
|
|
1242
|
+
f"live NVIDIA catalog · hosted model from {owner}"
|
|
1243
|
+
if owner and owner != model_id
|
|
1244
|
+
else "live NVIDIA catalog"
|
|
1245
|
+
)
|
|
1246
|
+
label = option.label or model_id
|
|
1247
|
+
if option.chat_compatibility == "unknown":
|
|
1248
|
+
label = f"{label} (chat compatibility unverified)"
|
|
1249
|
+
description = (
|
|
1250
|
+
f"{description} · provider catalog does not declare endpoint compatibility; "
|
|
1251
|
+
"selection is validated before setup completes"
|
|
1252
|
+
)
|
|
1253
|
+
rows.append((model_id, label, description))
|
|
1254
|
+
if custom_row not in rows:
|
|
1255
|
+
rows.append(custom_row)
|
|
1256
|
+
return rows
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _setup_reasoning_labels(
|
|
1260
|
+
profile_result: _ProfileStepResult,
|
|
1261
|
+
*,
|
|
1262
|
+
model: str,
|
|
1263
|
+
current: str = "auto",
|
|
1264
|
+
) -> tuple[str, ...]:
|
|
1265
|
+
preset = profile_result.preset
|
|
1266
|
+
if preset is None or preset.key not in _MODEL_REASONING_SETUP_PRESET_KEYS:
|
|
1267
|
+
return ("auto",)
|
|
1268
|
+
contract = reasoning_contract_for(
|
|
1269
|
+
preset.provider_key,
|
|
1270
|
+
model,
|
|
1271
|
+
preset_key=preset.key,
|
|
1272
|
+
)
|
|
1273
|
+
if contract is UNKNOWN_CONTRACT:
|
|
1274
|
+
return ("auto",)
|
|
1275
|
+
labels = reasoning_labels_allowed_by_contract(
|
|
1276
|
+
contract,
|
|
1277
|
+
_SETUP_REASONING_LABELS,
|
|
1278
|
+
current=current if current in _SETUP_REASONING_LABELS else "auto",
|
|
1279
|
+
)
|
|
1280
|
+
return tuple(labels) or ("auto",)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _prompt_setup_reasoning_label(
|
|
1284
|
+
console: Console,
|
|
1285
|
+
*,
|
|
1286
|
+
profile_result: _ProfileStepResult,
|
|
1287
|
+
model: str,
|
|
1288
|
+
current: str = "auto",
|
|
1289
|
+
) -> str:
|
|
1290
|
+
preset = profile_result.preset
|
|
1291
|
+
if preset is None or preset.key not in _MODEL_REASONING_SETUP_PRESET_KEYS:
|
|
1292
|
+
return current
|
|
1293
|
+
labels = _setup_reasoning_labels(
|
|
1294
|
+
profile_result,
|
|
1295
|
+
model=model,
|
|
1296
|
+
current=current,
|
|
1297
|
+
)
|
|
1298
|
+
if labels == ("auto",) and preset.key == _NVIDIA_PRESET_KEY:
|
|
1299
|
+
console.print(
|
|
1300
|
+
"[dim]Reasoning controls for this NVIDIA-hosted model are not verified; "
|
|
1301
|
+
"using the provider default without sending a guessed parameter.[/dim]"
|
|
1302
|
+
)
|
|
1303
|
+
return "auto"
|
|
1304
|
+
descriptions = {
|
|
1305
|
+
"off": "disable reasoning using this model's documented provider control",
|
|
1306
|
+
"low": "lower reasoning effort",
|
|
1307
|
+
"medium": "balanced reasoning effort",
|
|
1308
|
+
"high": "full reasoning effort",
|
|
1309
|
+
"max": "maximum reasoning effort",
|
|
1310
|
+
"auto": "use the provider's documented default",
|
|
1311
|
+
}
|
|
1312
|
+
rows = [
|
|
1313
|
+
(label, label, descriptions.get(label, "provider-documented reasoning effort"))
|
|
1314
|
+
for label in labels
|
|
1315
|
+
]
|
|
1316
|
+
selected = _run_wizard_picker(
|
|
1317
|
+
console=console,
|
|
1318
|
+
title="Reasoning Effort",
|
|
1319
|
+
subtitle=f"Choose a verified reasoning mode for {model} on {preset.label}.",
|
|
1320
|
+
rows=rows,
|
|
1321
|
+
current_value=current if current in labels else "auto",
|
|
1322
|
+
cancel_hint="models",
|
|
1323
|
+
)
|
|
1324
|
+
if selected is None:
|
|
1325
|
+
raise _GoBack()
|
|
1326
|
+
return selected
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _prompt_custom_model_name(*, previous: str | None = None) -> str:
|
|
1330
|
+
while True:
|
|
1331
|
+
value = _esc_aware_text_input(
|
|
1332
|
+
"Model",
|
|
1333
|
+
default=previous or "",
|
|
1334
|
+
show_default=previous is not None,
|
|
1335
|
+
).strip()
|
|
1336
|
+
if _is_cancel_token(value):
|
|
1337
|
+
raise _GoBack()
|
|
1338
|
+
if value:
|
|
1339
|
+
return value
|
|
1340
|
+
_resolve_console().print("[red]Model is required.[/red]")
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _prompt_workspace(
|
|
1344
|
+
console: Console,
|
|
1345
|
+
*,
|
|
1346
|
+
previous: _WorkspaceStepResult | None = None,
|
|
1347
|
+
) -> _WorkspaceStepResult:
|
|
1348
|
+
default_workspace = (
|
|
1349
|
+
Path(previous.workspace).expanduser()
|
|
1350
|
+
if previous is not None
|
|
1351
|
+
else _suggest_workspace_default()
|
|
1352
|
+
)
|
|
1353
|
+
console.print()
|
|
1354
|
+
console.print(
|
|
1355
|
+
Panel(
|
|
1356
|
+
Group(
|
|
1357
|
+
Text("Alysis Code reads and edits files inside one folder."),
|
|
1358
|
+
Text(f"Suggestion: {_display_path(default_workspace)}", style="dim"),
|
|
1359
|
+
Text(""),
|
|
1360
|
+
Text("Enter to confirm. Esc to go back. Ctrl+C to cancel.", style="dim"),
|
|
1361
|
+
),
|
|
1362
|
+
title="Workspace",
|
|
1363
|
+
border_style="cyan",
|
|
1364
|
+
)
|
|
1365
|
+
)
|
|
1366
|
+
while True:
|
|
1367
|
+
raw = _esc_aware_text_input(
|
|
1368
|
+
"Workspace folder",
|
|
1369
|
+
default=os.fspath(default_workspace),
|
|
1370
|
+
show_default=True,
|
|
1371
|
+
)
|
|
1372
|
+
if _is_cancel_token(raw):
|
|
1373
|
+
raise _GoBack()
|
|
1374
|
+
selected = Path(str(raw or default_workspace)).expanduser().resolve()
|
|
1375
|
+
try:
|
|
1376
|
+
binding = resolve_workspace_binding(
|
|
1377
|
+
selected,
|
|
1378
|
+
allow_broad_workspace=True,
|
|
1379
|
+
source="setup_wizard",
|
|
1380
|
+
)
|
|
1381
|
+
except WorkspaceBindingError as exc:
|
|
1382
|
+
console.print(f"[red]{exc}[/red]")
|
|
1383
|
+
continue
|
|
1384
|
+
|
|
1385
|
+
workspace = os.fspath(binding.requested_path)
|
|
1386
|
+
console.print(f"[green]Workspace:[/green] {workspace}")
|
|
1387
|
+
return _WorkspaceStepResult(workspace=workspace)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _commit_setup(
|
|
1391
|
+
*,
|
|
1392
|
+
profile_result: _ProfileStepResult,
|
|
1393
|
+
api_key_result: _ApiKeyStepResult,
|
|
1394
|
+
model_result: _ModelStepResult,
|
|
1395
|
+
workspace_result: _WorkspaceStepResult,
|
|
1396
|
+
console: Console,
|
|
1397
|
+
) -> AppConfig:
|
|
1398
|
+
snapshots = [_snapshot_file(config_path()), _snapshot_file(credentials_path())]
|
|
1399
|
+
try:
|
|
1400
|
+
cfg = load_config()
|
|
1401
|
+
cfg.execution.backend = "native"
|
|
1402
|
+
cfg.execution.runtime = None
|
|
1403
|
+
add_profile(cfg, profile_result.profile)
|
|
1404
|
+
set_active_profile(cfg, profile_result.profile.name)
|
|
1405
|
+
set_config_value(cfg, "model", model_result.model)
|
|
1406
|
+
reasoning_label = str(model_result.reasoning_label or "auto").strip().casefold()
|
|
1407
|
+
reasoning_effort = (
|
|
1408
|
+
None
|
|
1409
|
+
if reasoning_label == "auto"
|
|
1410
|
+
else ("none" if reasoning_label == "off" else reasoning_label)
|
|
1411
|
+
)
|
|
1412
|
+
cfg.llm_reasoning_effort = reasoning_effort
|
|
1413
|
+
cfg.llm_enable_thinking = None if reasoning_label == "auto" else reasoning_label != "off"
|
|
1414
|
+
update_profile(
|
|
1415
|
+
cfg,
|
|
1416
|
+
profile_result.profile.name,
|
|
1417
|
+
default_model=model_result.model,
|
|
1418
|
+
reasoning_effort=reasoning_effort,
|
|
1419
|
+
)
|
|
1420
|
+
extra_fields = dict(cfg.extra_fields or {})
|
|
1421
|
+
if reasoning_label not in {"auto", "off"}:
|
|
1422
|
+
extra_fields["llm_thinking_label"] = reasoning_label
|
|
1423
|
+
else:
|
|
1424
|
+
extra_fields.pop("llm_thinking_label", None)
|
|
1425
|
+
extra_fields[_DEFAULT_WORKSPACE_KEY] = workspace_result.workspace
|
|
1426
|
+
extra_fields[_ONBOARDED_KEY] = True
|
|
1427
|
+
extra_fields.pop("subscription_reconnect_required", None)
|
|
1428
|
+
cfg.extra_fields = extra_fields
|
|
1429
|
+
save_config(cfg)
|
|
1430
|
+
if api_key_result.api_key:
|
|
1431
|
+
save_persisted_profile_key(profile_result.profile.name, api_key_result.api_key)
|
|
1432
|
+
console.print("[green]Setup saved.[/green]")
|
|
1433
|
+
return cfg
|
|
1434
|
+
except (ConfigError, OSError) as exc:
|
|
1435
|
+
rollback_errors = _rollback_partial_persistence(snapshots)
|
|
1436
|
+
console.print(f"[red]Failed to save setup:[/red] {exc}")
|
|
1437
|
+
if rollback_errors:
|
|
1438
|
+
for error in rollback_errors:
|
|
1439
|
+
console.print(f"[red]Rollback failed:[/red] {error}")
|
|
1440
|
+
raise ConfigError(
|
|
1441
|
+
f"Failed to save setup: {exc}. Rollback also failed; inspect config and credentials files manually."
|
|
1442
|
+
) from exc
|
|
1443
|
+
raise ConfigError(f"Failed to save setup: {exc}") from exc
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _commit_delegated_setup(
|
|
1447
|
+
*,
|
|
1448
|
+
execution_result: _ExecutionStepResult,
|
|
1449
|
+
workspace_result: _WorkspaceStepResult,
|
|
1450
|
+
console: Console,
|
|
1451
|
+
) -> AppConfig:
|
|
1452
|
+
runtime_id = str(execution_result.runtime or "").strip()
|
|
1453
|
+
if execution_result.backend != "delegated" or not runtime_id:
|
|
1454
|
+
raise ConfigError("AI subscription setup requires a provider connection.")
|
|
1455
|
+
|
|
1456
|
+
snapshots = [_snapshot_file(config_path()), _snapshot_file(credentials_path())]
|
|
1457
|
+
try:
|
|
1458
|
+
from ..config import AgentRuntimeSettings
|
|
1459
|
+
|
|
1460
|
+
option = _runtime_setup_option(runtime_id)
|
|
1461
|
+
cfg = load_config()
|
|
1462
|
+
if _is_direct_subscription(runtime_id):
|
|
1463
|
+
profile = _direct_subscription_profile(cfg, runtime_id)
|
|
1464
|
+
add_profile(cfg, profile, allow_auth_profile_update=True)
|
|
1465
|
+
set_active_profile(cfg, profile.name)
|
|
1466
|
+
cfg.execution.backend = "native"
|
|
1467
|
+
cfg.execution.runtime = None
|
|
1468
|
+
cfg.model = profile.default_model
|
|
1469
|
+
effective_effort = (
|
|
1470
|
+
None if profile.reasoning_effort in {None, "auto"} else profile.reasoning_effort
|
|
1471
|
+
)
|
|
1472
|
+
cfg.llm_reasoning_effort = effective_effort
|
|
1473
|
+
cfg.llm_enable_thinking = (
|
|
1474
|
+
None if effective_effort is None else effective_effort != "none"
|
|
1475
|
+
)
|
|
1476
|
+
else:
|
|
1477
|
+
cfg.execution.backend = "delegated"
|
|
1478
|
+
cfg.execution.runtime = runtime_id
|
|
1479
|
+
if runtime_id not in cfg.agent_runtimes:
|
|
1480
|
+
cfg.agent_runtimes[runtime_id] = AgentRuntimeSettings(
|
|
1481
|
+
adapter=str(getattr(option, "adapter", "") or "").strip(),
|
|
1482
|
+
executable=str(getattr(option, "default_executable", "") or "").strip(),
|
|
1483
|
+
)
|
|
1484
|
+
extra_fields = dict(cfg.extra_fields or {})
|
|
1485
|
+
extra_fields[_DEFAULT_WORKSPACE_KEY] = workspace_result.workspace
|
|
1486
|
+
if _is_direct_subscription(runtime_id):
|
|
1487
|
+
extra_fields[_ONBOARDED_KEY] = False
|
|
1488
|
+
extra_fields["subscription_reconnect_required"] = True
|
|
1489
|
+
if not profile.default_model or profile.reasoning_effort is None:
|
|
1490
|
+
extra_fields[SUBSCRIPTION_SELECTION_REQUIRED_KEY] = runtime_id
|
|
1491
|
+
else:
|
|
1492
|
+
extra_fields[_ONBOARDED_KEY] = True
|
|
1493
|
+
extra_fields.pop("subscription_reconnect_required", None)
|
|
1494
|
+
cfg.extra_fields = extra_fields
|
|
1495
|
+
save_config(cfg)
|
|
1496
|
+
console.print("[green]Setup saved.[/green]")
|
|
1497
|
+
return cfg
|
|
1498
|
+
except (ConfigError, OSError, ValueError) as exc:
|
|
1499
|
+
rollback_errors = _rollback_partial_persistence(snapshots)
|
|
1500
|
+
console.print(f"[red]Failed to save setup:[/red] {exc}")
|
|
1501
|
+
if rollback_errors:
|
|
1502
|
+
for error in rollback_errors:
|
|
1503
|
+
console.print(f"[red]Rollback failed:[/red] {error}")
|
|
1504
|
+
raise ConfigError(
|
|
1505
|
+
f"Failed to save setup: {exc}. Rollback also failed; inspect config and credentials files manually."
|
|
1506
|
+
) from exc
|
|
1507
|
+
raise ConfigError(f"Failed to save setup: {exc}") from exc
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def _delegated_runtime_id(execution_result: _ExecutionStepResult) -> str:
|
|
1511
|
+
runtime_id = str(execution_result.runtime or "").strip()
|
|
1512
|
+
if execution_result.backend != "delegated" or not runtime_id:
|
|
1513
|
+
raise ConfigError("AI subscription setup requires a provider connection.")
|
|
1514
|
+
return runtime_id
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _delegated_auth_result(account: Any) -> _DelegatedAuthResult:
|
|
1518
|
+
connected = bool(
|
|
1519
|
+
getattr(account, "verified", True)
|
|
1520
|
+
and (getattr(account, "authenticated", False) or getattr(account, "connected", False))
|
|
1521
|
+
)
|
|
1522
|
+
label = str(getattr(account, "account_label", "") or "").strip()
|
|
1523
|
+
detail = str(getattr(account, "detail", "") or "").strip()
|
|
1524
|
+
if connected:
|
|
1525
|
+
summary = f"connected as {label}" if label else "connected"
|
|
1526
|
+
if detail and detail.casefold() not in summary.casefold():
|
|
1527
|
+
summary += f" ({detail})"
|
|
1528
|
+
return _DelegatedAuthResult(connected=True, summary=summary)
|
|
1529
|
+
summary = "not connected"
|
|
1530
|
+
if detail:
|
|
1531
|
+
summary += f" ({detail})"
|
|
1532
|
+
return _DelegatedAuthResult(connected=False, summary=summary)
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
def _check_delegated_runtime_connection(
|
|
1536
|
+
cfg: AppConfig,
|
|
1537
|
+
execution_result: _ExecutionStepResult,
|
|
1538
|
+
) -> _DelegatedAuthResult:
|
|
1539
|
+
"""Read the provider runtime's opaque account status without accessing credentials."""
|
|
1540
|
+
|
|
1541
|
+
runtime_id = _delegated_runtime_id(execution_result)
|
|
1542
|
+
try:
|
|
1543
|
+
if _is_direct_subscription(runtime_id):
|
|
1544
|
+
from ..provider_auth import create_provider_auth
|
|
1545
|
+
|
|
1546
|
+
return _delegated_auth_result(create_provider_auth(runtime_id).account_status())
|
|
1547
|
+
from ..agent_runtimes.service import runtime_connection_snapshot
|
|
1548
|
+
|
|
1549
|
+
snapshot = runtime_connection_snapshot(cfg, runtime_id)
|
|
1550
|
+
except Exception as exc: # noqa: BLE001 - setup must survive unavailable runtimes
|
|
1551
|
+
return _DelegatedAuthResult(
|
|
1552
|
+
connected=False,
|
|
1553
|
+
summary=f"not connected (status check failed: {exc})",
|
|
1554
|
+
)
|
|
1555
|
+
return _delegated_auth_result(snapshot.account)
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def _login_delegated_runtime(
|
|
1559
|
+
cfg: AppConfig,
|
|
1560
|
+
execution_result: _ExecutionStepResult,
|
|
1561
|
+
) -> _DelegatedAuthResult:
|
|
1562
|
+
"""Run provider-owned browser login and return only its opaque account status."""
|
|
1563
|
+
|
|
1564
|
+
runtime_id = _delegated_runtime_id(execution_result)
|
|
1565
|
+
try:
|
|
1566
|
+
if _is_direct_subscription(runtime_id):
|
|
1567
|
+
from ..provider_auth import create_provider_auth
|
|
1568
|
+
|
|
1569
|
+
adapter = create_provider_auth(runtime_id)
|
|
1570
|
+
account = adapter.login(method="browser")
|
|
1571
|
+
if account.connected:
|
|
1572
|
+
_sync_direct_subscription_model(cfg, runtime_id, adapter=adapter)
|
|
1573
|
+
return _delegated_auth_result(account)
|
|
1574
|
+
from ..agent_runtimes.service import login_runtime
|
|
1575
|
+
|
|
1576
|
+
account = login_runtime(cfg, runtime_id, method_id="browser")
|
|
1577
|
+
except Exception as exc: # noqa: BLE001 - login is optional after config was saved
|
|
1578
|
+
return _DelegatedAuthResult(
|
|
1579
|
+
connected=False,
|
|
1580
|
+
summary=f"not connected ({exc})",
|
|
1581
|
+
)
|
|
1582
|
+
return _delegated_auth_result(account)
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _maybe_connect_delegated_runtime(
|
|
1586
|
+
*,
|
|
1587
|
+
console: Console,
|
|
1588
|
+
cfg: AppConfig,
|
|
1589
|
+
execution_result: _ExecutionStepResult,
|
|
1590
|
+
) -> _DelegatedAuthResult:
|
|
1591
|
+
runtime_id = _delegated_runtime_id(execution_result)
|
|
1592
|
+
auth_result = _check_delegated_runtime_connection(cfg, execution_result)
|
|
1593
|
+
if auth_result.connected:
|
|
1594
|
+
if _is_direct_subscription(runtime_id):
|
|
1595
|
+
try:
|
|
1596
|
+
_sync_direct_subscription_model(cfg, runtime_id)
|
|
1597
|
+
except Exception as exc: # noqa: BLE001
|
|
1598
|
+
return _DelegatedAuthResult(
|
|
1599
|
+
connected=False,
|
|
1600
|
+
summary=f"connected, but model discovery failed ({exc})",
|
|
1601
|
+
)
|
|
1602
|
+
console.print(f"[green]Subscription account already {auth_result.summary}.[/green]")
|
|
1603
|
+
return auth_result
|
|
1604
|
+
|
|
1605
|
+
console.print()
|
|
1606
|
+
console.print(
|
|
1607
|
+
"[yellow]Provider login is an immediate external side effect. "
|
|
1608
|
+
"Cancelling or going back later does not undo it.[/yellow]"
|
|
1609
|
+
)
|
|
1610
|
+
try:
|
|
1611
|
+
connect = _prompt_yes_no("Connect now? [Y/n]", default=True)
|
|
1612
|
+
except (_GoBack, KeyboardInterrupt, Abort, EOFError):
|
|
1613
|
+
console.print(
|
|
1614
|
+
"[yellow]Your model access settings are already saved; provider sign-in was skipped.[/yellow]"
|
|
1615
|
+
)
|
|
1616
|
+
console.print(f"[dim]Run `alysis auth login {runtime_id}` whenever you're ready.[/dim]")
|
|
1617
|
+
return auth_result
|
|
1618
|
+
if not connect:
|
|
1619
|
+
console.print(f"[dim]Run `alysis auth login {runtime_id}` whenever you're ready.[/dim]")
|
|
1620
|
+
return auth_result
|
|
1621
|
+
|
|
1622
|
+
console.print("[dim]Starting the provider's browser login…[/dim]")
|
|
1623
|
+
auth_result = _login_delegated_runtime(cfg, execution_result)
|
|
1624
|
+
if auth_result.connected:
|
|
1625
|
+
console.print(f"[green]Subscription account {auth_result.summary}.[/green]")
|
|
1626
|
+
else:
|
|
1627
|
+
console.print(f"[yellow]Subscription account {auth_result.summary}.[/yellow]")
|
|
1628
|
+
console.print(f"[dim]Finish later with `alysis auth login {runtime_id}`.[/dim]")
|
|
1629
|
+
return auth_result
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _snapshot_file(path: Path) -> _FileSnapshot:
|
|
1633
|
+
try:
|
|
1634
|
+
if path.exists():
|
|
1635
|
+
return _FileSnapshot(path=path, existed=True, data=path.read_bytes())
|
|
1636
|
+
return _FileSnapshot(path=path, existed=False, data=None)
|
|
1637
|
+
except OSError as exc:
|
|
1638
|
+
raise ConfigError(f"Failed to snapshot {path}: {exc}") from exc
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _rollback_partial_persistence(snapshots: list[_FileSnapshot]) -> list[str]:
|
|
1642
|
+
errors: list[str] = []
|
|
1643
|
+
for snapshot in snapshots:
|
|
1644
|
+
try:
|
|
1645
|
+
if snapshot.existed:
|
|
1646
|
+
snapshot.path.parent.mkdir(parents=True, exist_ok=True)
|
|
1647
|
+
snapshot.path.write_bytes(snapshot.data or b"")
|
|
1648
|
+
elif snapshot.path.exists():
|
|
1649
|
+
snapshot.path.unlink()
|
|
1650
|
+
except OSError as exc:
|
|
1651
|
+
errors.append(f"{snapshot.path}: {exc}")
|
|
1652
|
+
return errors
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _prompt_and_check_sandbox(console: Console, cfg: AppConfig) -> _SandboxStepResult:
|
|
1656
|
+
console.print()
|
|
1657
|
+
console.print(
|
|
1658
|
+
Panel(
|
|
1659
|
+
"Alysis Code runs shell commands in a sandbox to keep your system safe.\n"
|
|
1660
|
+
"Checking sandbox readiness...",
|
|
1661
|
+
title="Sandbox",
|
|
1662
|
+
border_style="cyan",
|
|
1663
|
+
)
|
|
1664
|
+
)
|
|
1665
|
+
try:
|
|
1666
|
+
result = diagnose_sandbox(cfg, include_smoke=False)
|
|
1667
|
+
except (_GoBack, KeyboardInterrupt, Abort, EOFError):
|
|
1668
|
+
console.print(
|
|
1669
|
+
"[yellow]Sandbox skipped. You can finish this later with `alysis sandbox setup`.[/yellow]"
|
|
1670
|
+
)
|
|
1671
|
+
return _SandboxStepResult(ready=False, status="skipped")
|
|
1672
|
+
except Exception as exc: # noqa: BLE001 - sandbox backends fail in environment-specific ways
|
|
1673
|
+
console.print(
|
|
1674
|
+
f"[yellow]Sandbox check failed:[/yellow] {exc}. Re-run with `alysis sandbox doctor`."
|
|
1675
|
+
)
|
|
1676
|
+
return _SandboxStepResult(ready=False, status="check failed")
|
|
1677
|
+
|
|
1678
|
+
_print_sandbox_status(console, result)
|
|
1679
|
+
if result.ready:
|
|
1680
|
+
return _SandboxStepResult(ready=True, status=result.selected_backend or result.status)
|
|
1681
|
+
|
|
1682
|
+
console.print(Panel(_sandbox_problem_text(result), title="Sandbox", border_style="yellow"))
|
|
1683
|
+
if result.can_pull:
|
|
1684
|
+
try:
|
|
1685
|
+
should_pull = _prompt_yes_no(
|
|
1686
|
+
"Sandbox image not found. Pull it now? (~50MB) [Y/n]", default=True
|
|
1687
|
+
)
|
|
1688
|
+
except (_GoBack, KeyboardInterrupt, Abort, EOFError):
|
|
1689
|
+
console.print(
|
|
1690
|
+
"[yellow]Sandbox skipped. You can finish this later with `alysis sandbox setup`.[/yellow]"
|
|
1691
|
+
)
|
|
1692
|
+
return _SandboxStepResult(ready=False, status="skipped")
|
|
1693
|
+
if should_pull:
|
|
1694
|
+
try:
|
|
1695
|
+
return _pull_sandbox(console, cfg, result)
|
|
1696
|
+
except (_GoBack, KeyboardInterrupt, Abort, EOFError):
|
|
1697
|
+
console.print(
|
|
1698
|
+
"[yellow]Sandbox skipped. You can finish this later with `alysis sandbox setup`.[/yellow]"
|
|
1699
|
+
)
|
|
1700
|
+
return _SandboxStepResult(ready=False, status="skipped")
|
|
1701
|
+
console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
|
|
1702
|
+
return _SandboxStepResult(ready=False, status="not ready")
|
|
1703
|
+
|
|
1704
|
+
return _offer_sandbox_choice(console, cfg, result)
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _offer_sandbox_choice(
|
|
1708
|
+
console: Console, cfg: AppConfig, result: SandboxDiagnostic
|
|
1709
|
+
) -> _SandboxStepResult:
|
|
1710
|
+
"""No usable backend was found: let the user install one, opt out, or defer.
|
|
1711
|
+
|
|
1712
|
+
The secure default stays strict; this only surfaces an explicit, discoverable
|
|
1713
|
+
opt-out so a brand-new user is not silently blocked on their first task.
|
|
1714
|
+
"""
|
|
1715
|
+
del result # diagnosis already printed; we only need the decision now
|
|
1716
|
+
plan = detect_bubblewrap_install_plan()
|
|
1717
|
+
rows: list[tuple[str, str, str]] = []
|
|
1718
|
+
if plan is not None:
|
|
1719
|
+
rows.append(
|
|
1720
|
+
(
|
|
1721
|
+
"install_bwrap",
|
|
1722
|
+
"Install bubblewrap now (keeps sandbox on)",
|
|
1723
|
+
f"Runs: {plan.display}",
|
|
1724
|
+
)
|
|
1725
|
+
)
|
|
1726
|
+
rows.append(
|
|
1727
|
+
(
|
|
1728
|
+
"disable",
|
|
1729
|
+
"Run without sandbox for now (less safe)",
|
|
1730
|
+
"Commands run on the host shell. Re-enable anytime in /config.",
|
|
1731
|
+
)
|
|
1732
|
+
)
|
|
1733
|
+
rows.append(
|
|
1734
|
+
(
|
|
1735
|
+
"later",
|
|
1736
|
+
"Decide later",
|
|
1737
|
+
"Finish with `alysis sandbox setup`.",
|
|
1738
|
+
)
|
|
1739
|
+
)
|
|
1740
|
+
try:
|
|
1741
|
+
selected = _run_wizard_picker(
|
|
1742
|
+
console=console,
|
|
1743
|
+
title="Sandbox",
|
|
1744
|
+
subtitle=(
|
|
1745
|
+
"No sandbox backend (bubblewrap or Docker) was found. How do you want to proceed?"
|
|
1746
|
+
),
|
|
1747
|
+
rows=rows,
|
|
1748
|
+
current_value=rows[0][0],
|
|
1749
|
+
cancel_hint="skip",
|
|
1750
|
+
)
|
|
1751
|
+
except (KeyboardInterrupt, Abort, EOFError):
|
|
1752
|
+
console.print(
|
|
1753
|
+
"[yellow]Sandbox skipped. You can finish this later with `alysis sandbox setup`.[/yellow]"
|
|
1754
|
+
)
|
|
1755
|
+
return _SandboxStepResult(ready=False, status="skipped")
|
|
1756
|
+
|
|
1757
|
+
if selected == "install_bwrap":
|
|
1758
|
+
return _install_bwrap_and_recheck(console, cfg, plan)
|
|
1759
|
+
if selected == "disable":
|
|
1760
|
+
return _disable_sandbox(console, cfg)
|
|
1761
|
+
console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
|
|
1762
|
+
return _SandboxStepResult(ready=False, status="not ready")
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def _disable_sandbox(console: Console, cfg: AppConfig) -> _SandboxStepResult:
|
|
1766
|
+
apply_sandbox_mode_to_config(cfg, "off")
|
|
1767
|
+
try:
|
|
1768
|
+
save_config(cfg)
|
|
1769
|
+
except (ConfigError, OSError) as exc:
|
|
1770
|
+
console.print(f"[yellow]Could not save the sandbox setting:[/yellow] {exc}")
|
|
1771
|
+
return _SandboxStepResult(ready=False, status="not ready")
|
|
1772
|
+
console.print(
|
|
1773
|
+
"[yellow]Sandbox disabled - Alysis Code will run commands directly on the host shell. "
|
|
1774
|
+
"Re-enable anytime with /config -> Sandbox.[/yellow]"
|
|
1775
|
+
)
|
|
1776
|
+
return _SandboxStepResult(ready=False, status="disabled")
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _install_bwrap_and_recheck(
|
|
1780
|
+
console: Console, cfg: AppConfig, plan: BubblewrapInstallPlan | None
|
|
1781
|
+
) -> _SandboxStepResult:
|
|
1782
|
+
display = plan.display if plan is not None else "auto"
|
|
1783
|
+
console.print(f"[dim]Installing bubblewrap ({display})...[/dim]")
|
|
1784
|
+
console.print("[dim]You may be prompted for your sudo password.[/dim]")
|
|
1785
|
+
install_result = install_bubblewrap(plan=plan)
|
|
1786
|
+
if not install_result.ok:
|
|
1787
|
+
console.print(
|
|
1788
|
+
f"[yellow]Bubblewrap install did not complete:[/yellow] {install_result.detail}"
|
|
1789
|
+
)
|
|
1790
|
+
console.print(
|
|
1791
|
+
"[yellow]You can finish this later with `alysis sandbox setup`, "
|
|
1792
|
+
"or choose to run without sandbox in /config.[/yellow]"
|
|
1793
|
+
)
|
|
1794
|
+
return _SandboxStepResult(ready=False, status="not ready")
|
|
1795
|
+
console.print("[green]Bubblewrap installed.[/green]")
|
|
1796
|
+
try:
|
|
1797
|
+
recheck = diagnose_sandbox(cfg, include_smoke=True)
|
|
1798
|
+
except Exception as exc: # noqa: BLE001 - sandbox backends fail in environment-specific ways
|
|
1799
|
+
console.print(
|
|
1800
|
+
f"[yellow]Sandbox check failed after install:[/yellow] {exc}. "
|
|
1801
|
+
"Re-run with `alysis sandbox doctor`."
|
|
1802
|
+
)
|
|
1803
|
+
return _SandboxStepResult(ready=False, status="check failed")
|
|
1804
|
+
_print_sandbox_status(console, recheck)
|
|
1805
|
+
if recheck.ready:
|
|
1806
|
+
return _SandboxStepResult(ready=True, status=recheck.selected_backend or recheck.status)
|
|
1807
|
+
console.print(Panel(_sandbox_problem_text(recheck), title="Sandbox", border_style="yellow"))
|
|
1808
|
+
console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
|
|
1809
|
+
return _SandboxStepResult(ready=False, status="not ready")
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def _pull_sandbox(
|
|
1813
|
+
console: Console,
|
|
1814
|
+
cfg: AppConfig,
|
|
1815
|
+
initial_result: SandboxDiagnostic,
|
|
1816
|
+
) -> _SandboxStepResult:
|
|
1817
|
+
console.print("[dim]Pulling Alysis Code sandbox image...[/dim]")
|
|
1818
|
+
try:
|
|
1819
|
+
pull_result = pull_sandbox_images(timeout_s=900)
|
|
1820
|
+
except (KeyboardInterrupt, Abort, EOFError):
|
|
1821
|
+
raise
|
|
1822
|
+
except Exception as exc: # noqa: BLE001
|
|
1823
|
+
log_path = _write_exception_log("sandbox_pull", exc)
|
|
1824
|
+
console.print(f"[yellow]Sandbox image pull failed:[/yellow] {exc}")
|
|
1825
|
+
if log_path is not None:
|
|
1826
|
+
console.print(f"[dim]Details saved to: {log_path}[/dim]")
|
|
1827
|
+
console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
|
|
1828
|
+
return _SandboxStepResult(ready=False, status="pull failed")
|
|
1829
|
+
|
|
1830
|
+
if not pull_result.ok:
|
|
1831
|
+
log_path = _write_sandbox_pull_log(pull_result)
|
|
1832
|
+
console.print(
|
|
1833
|
+
Panel(_sandbox_problem_text(initial_result), title="Sandbox", border_style="yellow")
|
|
1834
|
+
)
|
|
1835
|
+
console.print(
|
|
1836
|
+
f"[yellow]Sandbox image pull failed:[/yellow] {pull_result.error or 'image pull failed'}"
|
|
1837
|
+
)
|
|
1838
|
+
if log_path is not None:
|
|
1839
|
+
console.print(f"[dim]Raw pull output saved to: {log_path}[/dim]")
|
|
1840
|
+
console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
|
|
1841
|
+
return _SandboxStepResult(ready=False, status="pull failed")
|
|
1842
|
+
|
|
1843
|
+
console.print("[green]Sandbox image pulled.[/green]")
|
|
1844
|
+
try:
|
|
1845
|
+
final_result = diagnose_sandbox(cfg, include_smoke=True)
|
|
1846
|
+
except Exception as exc: # noqa: BLE001
|
|
1847
|
+
console.print(
|
|
1848
|
+
f"[yellow]Sandbox check failed after pull:[/yellow] {exc}. "
|
|
1849
|
+
"Re-run with `alysis sandbox doctor`."
|
|
1850
|
+
)
|
|
1851
|
+
return _SandboxStepResult(ready=False, status="check failed")
|
|
1852
|
+
_print_sandbox_status(console, final_result)
|
|
1853
|
+
if final_result.ready:
|
|
1854
|
+
return _SandboxStepResult(
|
|
1855
|
+
ready=True, status=final_result.selected_backend or final_result.status
|
|
1856
|
+
)
|
|
1857
|
+
console.print(
|
|
1858
|
+
Panel(_sandbox_problem_text(final_result), title="Sandbox", border_style="yellow")
|
|
1859
|
+
)
|
|
1860
|
+
console.print("[yellow]You can finish this later with `alysis sandbox setup`.[/yellow]")
|
|
1861
|
+
return _SandboxStepResult(ready=False, status="not ready")
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def _print_sandbox_status(console: Console, result: SandboxDiagnostic) -> None:
|
|
1865
|
+
backend = result.selected_backend or result.status
|
|
1866
|
+
console.print(f"Backend detected: {backend}")
|
|
1867
|
+
console.print(f"Image present: {result.docker_image}")
|
|
1868
|
+
if result.ready:
|
|
1869
|
+
console.print("[green]Sandbox ready[/green]")
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def _sandbox_problem_text(result: SandboxDiagnostic) -> str:
|
|
1873
|
+
return format_sandbox_problem_message(result)
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
def _print_setup_complete(
|
|
1877
|
+
*,
|
|
1878
|
+
console: Console,
|
|
1879
|
+
profile_result: _ProfileStepResult,
|
|
1880
|
+
api_key_result: _ApiKeyStepResult,
|
|
1881
|
+
model_result: _ModelStepResult,
|
|
1882
|
+
workspace_result: _WorkspaceStepResult,
|
|
1883
|
+
sandbox_result: _SandboxStepResult,
|
|
1884
|
+
) -> None:
|
|
1885
|
+
api_key_label = _api_key_summary_label(api_key_result)
|
|
1886
|
+
sandbox_label = _sandbox_summary_label(sandbox_result)
|
|
1887
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
1888
|
+
table.add_column("label", no_wrap=True, style="bold")
|
|
1889
|
+
table.add_column("value")
|
|
1890
|
+
table.add_row("Profile:", f"{profile_result.label} (active)")
|
|
1891
|
+
table.add_row("API key:", api_key_label)
|
|
1892
|
+
table.add_row("Model:", model_result.model)
|
|
1893
|
+
if (
|
|
1894
|
+
profile_result.preset is not None
|
|
1895
|
+
and profile_result.preset.key in _MODEL_REASONING_SETUP_PRESET_KEYS
|
|
1896
|
+
):
|
|
1897
|
+
table.add_row("Reasoning:", model_result.reasoning_label)
|
|
1898
|
+
table.add_row("Workspace:", workspace_result.workspace)
|
|
1899
|
+
table.add_row("Sandbox:", sandbox_label)
|
|
1900
|
+
console.print()
|
|
1901
|
+
console.print(
|
|
1902
|
+
Panel(
|
|
1903
|
+
Group(table, Text(""), Text("Type your first message to start chatting.", style="dim")),
|
|
1904
|
+
title="Setup complete",
|
|
1905
|
+
border_style="green",
|
|
1906
|
+
)
|
|
1907
|
+
)
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def _print_delegated_setup_complete(
|
|
1911
|
+
*,
|
|
1912
|
+
console: Console,
|
|
1913
|
+
execution_result: _ExecutionStepResult,
|
|
1914
|
+
workspace_result: _WorkspaceStepResult,
|
|
1915
|
+
auth_result: _DelegatedAuthResult,
|
|
1916
|
+
cfg: AppConfig | None = None,
|
|
1917
|
+
) -> None:
|
|
1918
|
+
runtime_id = str(execution_result.runtime or "").strip()
|
|
1919
|
+
auth_hint = execution_result.auth_hint
|
|
1920
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
1921
|
+
table.add_column("label", no_wrap=True, style="bold")
|
|
1922
|
+
table.add_column("value")
|
|
1923
|
+
table.add_row("Connection:", "AI subscription")
|
|
1924
|
+
table.add_row("Subscription:", execution_result.label)
|
|
1925
|
+
table.add_row("Workspace:", workspace_result.workspace)
|
|
1926
|
+
if cfg is not None and cfg.execution.backend == "native":
|
|
1927
|
+
if active_subscription_selection_ready(cfg):
|
|
1928
|
+
active = get_profile(cfg, str(cfg.extra_fields.get("active_profile") or ""))
|
|
1929
|
+
table.add_row("Model:", active.default_model if active is not None else cfg.model)
|
|
1930
|
+
if active is not None and active.reasoning_effort is not None:
|
|
1931
|
+
table.add_row("Reasoning:", active.reasoning_effort)
|
|
1932
|
+
else:
|
|
1933
|
+
table.add_row("Model setup:", "Choose model and reasoning in /config")
|
|
1934
|
+
table.add_row(
|
|
1935
|
+
"Authentication:",
|
|
1936
|
+
Text(auth_result.summary, style="green" if auth_result.connected else "yellow"),
|
|
1937
|
+
)
|
|
1938
|
+
next_step = (
|
|
1939
|
+
Text("Your AI subscription is connected.", style="dim")
|
|
1940
|
+
if auth_result.connected
|
|
1941
|
+
else Text(
|
|
1942
|
+
f"Next: run `alysis auth login {runtime_id}` before using this subscription.",
|
|
1943
|
+
style="yellow",
|
|
1944
|
+
)
|
|
1945
|
+
)
|
|
1946
|
+
console.print()
|
|
1947
|
+
console.print(
|
|
1948
|
+
Panel(
|
|
1949
|
+
Group(
|
|
1950
|
+
table,
|
|
1951
|
+
Text(""),
|
|
1952
|
+
next_step,
|
|
1953
|
+
Text(auth_hint, style="dim") if auth_hint else Text(""),
|
|
1954
|
+
Text(
|
|
1955
|
+
"Use /config → Default Model to choose the subscription model and reasoning effort.",
|
|
1956
|
+
style="dim",
|
|
1957
|
+
),
|
|
1958
|
+
),
|
|
1959
|
+
title="Setup complete",
|
|
1960
|
+
border_style="green",
|
|
1961
|
+
)
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
def _is_direct_subscription(runtime_id: str) -> bool:
|
|
1966
|
+
from ..provider_auth import provider_auth_ids
|
|
1967
|
+
|
|
1968
|
+
return str(runtime_id or "").strip() in provider_auth_ids()
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
def _direct_subscription_profile(cfg: AppConfig, runtime_id: str) -> ProfileSpec:
|
|
1972
|
+
if not _is_direct_subscription(runtime_id):
|
|
1973
|
+
raise ConfigError(f"No native subscription profile is registered for {runtime_id!r}.")
|
|
1974
|
+
from ..provider_auth import create_provider_auth
|
|
1975
|
+
|
|
1976
|
+
adapter = create_provider_auth(runtime_id)
|
|
1977
|
+
existing = get_profile(cfg, adapter.profile_name)
|
|
1978
|
+
if existing is not None and existing.auth_provider == runtime_id:
|
|
1979
|
+
return ProfileSpec(
|
|
1980
|
+
name=existing.name,
|
|
1981
|
+
protocol=adapter.protocol,
|
|
1982
|
+
base_url=adapter.base_url,
|
|
1983
|
+
api_key_env=existing.api_key_env,
|
|
1984
|
+
auth_provider=runtime_id,
|
|
1985
|
+
extra_headers=dict(existing.extra_headers),
|
|
1986
|
+
default_model=existing.default_model,
|
|
1987
|
+
reasoning_effort=existing.reasoning_effort,
|
|
1988
|
+
reasoning_trace_adapter=existing.reasoning_trace_adapter,
|
|
1989
|
+
web_search_adapter=existing.web_search_adapter,
|
|
1990
|
+
web_search_model=existing.web_search_model,
|
|
1991
|
+
notes=existing.notes,
|
|
1992
|
+
cache_capability=existing.cache_capability,
|
|
1993
|
+
)
|
|
1994
|
+
return ProfileSpec(
|
|
1995
|
+
name=adapter.profile_name,
|
|
1996
|
+
protocol=adapter.protocol,
|
|
1997
|
+
base_url=adapter.base_url,
|
|
1998
|
+
auth_provider=runtime_id,
|
|
1999
|
+
notes=f"{adapter.display_name}. Uses Alysis Code's native agent loop.",
|
|
2000
|
+
)
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _sync_direct_subscription_model(
|
|
2004
|
+
cfg: AppConfig,
|
|
2005
|
+
runtime_id: str,
|
|
2006
|
+
*,
|
|
2007
|
+
adapter: Any | None = None,
|
|
2008
|
+
) -> None:
|
|
2009
|
+
"""Refresh the catalog and preserve only a /config-confirmed selection."""
|
|
2010
|
+
|
|
2011
|
+
if not _is_direct_subscription(runtime_id):
|
|
2012
|
+
return
|
|
2013
|
+
from ..provider_auth import create_provider_auth
|
|
2014
|
+
|
|
2015
|
+
resolved_adapter = adapter or create_provider_auth(runtime_id)
|
|
2016
|
+
models = resolved_adapter.list_models(refresh=True)
|
|
2017
|
+
if not models:
|
|
2018
|
+
raise ConfigError("The connected subscription account did not advertise any models.")
|
|
2019
|
+
profile_name = str(getattr(resolved_adapter, "profile_name", "") or "").strip()
|
|
2020
|
+
if not profile_name:
|
|
2021
|
+
raise ConfigError(f"Subscription adapter {runtime_id!r} has no profile name.")
|
|
2022
|
+
profile = get_profile(cfg, profile_name)
|
|
2023
|
+
if profile is None:
|
|
2024
|
+
raise ConfigError(f"Subscription profile {profile_name!r} is missing.")
|
|
2025
|
+
confirmation_pending = str(
|
|
2026
|
+
cfg.extra_fields.get(SUBSCRIPTION_SELECTION_REQUIRED_KEY) or ""
|
|
2027
|
+
).strip().lower() in {"true", runtime_id}
|
|
2028
|
+
selection_ready = subscription_selection_supported(profile, models) and not confirmation_pending
|
|
2029
|
+
cfg.extra_fields[_ONBOARDED_KEY] = selection_ready
|
|
2030
|
+
cfg.extra_fields.pop("subscription_reconnect_required", None)
|
|
2031
|
+
if not selection_ready:
|
|
2032
|
+
cfg.extra_fields[SUBSCRIPTION_SELECTION_REQUIRED_KEY] = runtime_id
|
|
2033
|
+
save_config(cfg)
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def _api_key_summary_label(api_key_result: _ApiKeyStepResult) -> Text:
|
|
2037
|
+
if not api_key_result.api_key:
|
|
2038
|
+
return Text("not stored", style="yellow")
|
|
2039
|
+
|
|
2040
|
+
reason = str(api_key_result.validation_message or "").strip()
|
|
2041
|
+
if api_key_result.validation_status == "validated":
|
|
2042
|
+
return Text("stored, validated", style="green")
|
|
2043
|
+
if api_key_result.validation_status == "inconclusive":
|
|
2044
|
+
suffix = f" ({reason})" if reason else ""
|
|
2045
|
+
return Text(f"stored, validation inconclusive{suffix}", style="yellow")
|
|
2046
|
+
if api_key_result.validation_status == "failed":
|
|
2047
|
+
suffix = f" ({reason})" if reason else ""
|
|
2048
|
+
return Text(
|
|
2049
|
+
f"stored, but provider rejected it{suffix}. Re-enter via /config.",
|
|
2050
|
+
style="red",
|
|
2051
|
+
)
|
|
2052
|
+
if api_key_result.validation_status == "skipped":
|
|
2053
|
+
return Text("stored, validation skipped", style="yellow")
|
|
2054
|
+
if api_key_result.validation_status == "model_not_found":
|
|
2055
|
+
suffix = f" ({reason})" if reason else ""
|
|
2056
|
+
return Text(f"stored, model validation failed{suffix}", style="red")
|
|
2057
|
+
return Text(
|
|
2058
|
+
f"stored, unknown validation status: {api_key_result.validation_status}", style="red"
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def _sandbox_summary_label(sandbox_result: _SandboxStepResult) -> Text:
|
|
2063
|
+
if sandbox_result.ready:
|
|
2064
|
+
return Text(f"ready ({sandbox_result.status})", style="green")
|
|
2065
|
+
if sandbox_result.status == "disabled":
|
|
2066
|
+
return Text("off - host execution (less safe); re-enable via /config", style="yellow")
|
|
2067
|
+
if sandbox_result.status == "skipped":
|
|
2068
|
+
return Text("skipped - run `alysis sandbox setup` to finish", style="yellow")
|
|
2069
|
+
return Text(
|
|
2070
|
+
f"{sandbox_result.status} - run `alysis sandbox setup` to finish",
|
|
2071
|
+
style="yellow",
|
|
2072
|
+
)
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _run_wizard_picker(
|
|
2076
|
+
*,
|
|
2077
|
+
console: Console,
|
|
2078
|
+
title: str,
|
|
2079
|
+
subtitle: str,
|
|
2080
|
+
rows: list[tuple[str, str, str]],
|
|
2081
|
+
current_value: str,
|
|
2082
|
+
cancel_hint: str,
|
|
2083
|
+
invalid_hint: str = "Choose one of the numbered options.",
|
|
2084
|
+
) -> str | None:
|
|
2085
|
+
if not rows:
|
|
2086
|
+
return None
|
|
2087
|
+
footer_hint = (
|
|
2088
|
+
f"↑/↓ navigate Enter select 1-{min(len(rows), 9)} jump Esc {cancel_hint} Ctrl+C cancel"
|
|
2089
|
+
)
|
|
2090
|
+
try:
|
|
2091
|
+
from .config_menu import _run_config_picker
|
|
2092
|
+
except Exception as exc: # noqa: BLE001
|
|
2093
|
+
console.print(f"[dim]Interactive picker unavailable: {exc}. Using numeric input.[/dim]")
|
|
2094
|
+
return _run_wizard_picker_fallback(
|
|
2095
|
+
console=console,
|
|
2096
|
+
title=title,
|
|
2097
|
+
subtitle=subtitle,
|
|
2098
|
+
rows=rows,
|
|
2099
|
+
current_value=current_value,
|
|
2100
|
+
footer_hint=footer_hint,
|
|
2101
|
+
invalid_hint=invalid_hint,
|
|
2102
|
+
)
|
|
2103
|
+
try:
|
|
2104
|
+
return _run_config_picker(
|
|
2105
|
+
console=console,
|
|
2106
|
+
title=title,
|
|
2107
|
+
subtitle=subtitle,
|
|
2108
|
+
rows=rows,
|
|
2109
|
+
current_value=current_value,
|
|
2110
|
+
footer_hint=footer_hint,
|
|
2111
|
+
)
|
|
2112
|
+
except (KeyboardInterrupt, Abort, EOFError):
|
|
2113
|
+
raise
|
|
2114
|
+
except Exception as exc: # noqa: BLE001
|
|
2115
|
+
console.print(f"[dim]Interactive picker unavailable: {exc}. Using numeric input.[/dim]")
|
|
2116
|
+
return _run_wizard_picker_fallback(
|
|
2117
|
+
console=console,
|
|
2118
|
+
title=title,
|
|
2119
|
+
subtitle=subtitle,
|
|
2120
|
+
rows=rows,
|
|
2121
|
+
current_value=current_value,
|
|
2122
|
+
footer_hint=footer_hint,
|
|
2123
|
+
invalid_hint=invalid_hint,
|
|
2124
|
+
)
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
def _run_wizard_picker_fallback(
|
|
2128
|
+
*,
|
|
2129
|
+
console: Console,
|
|
2130
|
+
title: str,
|
|
2131
|
+
subtitle: str,
|
|
2132
|
+
rows: list[tuple[str, str, str]],
|
|
2133
|
+
current_value: str,
|
|
2134
|
+
footer_hint: str,
|
|
2135
|
+
invalid_hint: str,
|
|
2136
|
+
) -> str | None:
|
|
2137
|
+
current = str(current_value or "")
|
|
2138
|
+
while True:
|
|
2139
|
+
console.print()
|
|
2140
|
+
console.rule(f"[bold]{title}[/bold]")
|
|
2141
|
+
console.print(f"[dim]{subtitle}[/dim]")
|
|
2142
|
+
console.print()
|
|
2143
|
+
for index, (value, label, description) in enumerate(rows, start=1):
|
|
2144
|
+
suffix = " [dim](current)[/dim]" if value == current else ""
|
|
2145
|
+
detail = f" [dim]{description}[/dim]" if description else ""
|
|
2146
|
+
console.print(f" {index}) {label}{suffix}{detail}")
|
|
2147
|
+
console.print()
|
|
2148
|
+
console.print(f"[dim]{footer_hint}[/dim]")
|
|
2149
|
+
raw = _prompt_text("Choice", default="", show_default=False).strip()
|
|
2150
|
+
if not raw or _is_cancel_token(raw):
|
|
2151
|
+
return None
|
|
2152
|
+
if raw.isdigit():
|
|
2153
|
+
idx = int(raw) - 1
|
|
2154
|
+
if 0 <= idx < len(rows):
|
|
2155
|
+
return rows[idx][0]
|
|
2156
|
+
matched = _match_row_by_key_or_label(rows, raw)
|
|
2157
|
+
if matched is not None:
|
|
2158
|
+
return matched
|
|
2159
|
+
console.print(f"[yellow]{invalid_hint}[/yellow]")
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def _match_row_by_key_or_label(rows: list[tuple[str, str, str]], raw_value: str) -> str | None:
|
|
2163
|
+
value = str(raw_value or "").strip().casefold()
|
|
2164
|
+
if not value:
|
|
2165
|
+
return None
|
|
2166
|
+
for row_value, label, _description in rows:
|
|
2167
|
+
if value in {row_value.casefold(), label.casefold()}:
|
|
2168
|
+
return row_value
|
|
2169
|
+
return None
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
def _setup_presets() -> list[ProfilePreset]:
|
|
2173
|
+
return provider_selection_presets()
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def _advanced_setup_presets() -> list[ProfilePreset]:
|
|
2177
|
+
return advanced_provider_selection_presets()
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
def _provider_picker_rows(presets: list[ProfilePreset]) -> list[tuple[str, str, str]]:
|
|
2181
|
+
rows = [
|
|
2182
|
+
(preset.key, preset_selection_label(preset), _preset_description(preset))
|
|
2183
|
+
for preset in presets
|
|
2184
|
+
]
|
|
2185
|
+
rows.append(
|
|
2186
|
+
(
|
|
2187
|
+
_ADVANCED_PROVIDER_PRESETS_VALUE,
|
|
2188
|
+
"Advanced / local / compatibility providers",
|
|
2189
|
+
"Show local endpoints (Ollama, LM Studio, vLLM), custom URLs, and legacy "
|
|
2190
|
+
"OpenAI-compatible first-party presets.",
|
|
2191
|
+
)
|
|
2192
|
+
)
|
|
2193
|
+
return rows
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def _preset_by_key(key: str) -> ProfilePreset | None:
|
|
2197
|
+
normalized = str(key or "").strip().lower()
|
|
2198
|
+
for preset in PROFILE_PRESETS:
|
|
2199
|
+
if preset.key == normalized:
|
|
2200
|
+
return preset
|
|
2201
|
+
return None
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
def _preset_description(preset: ProfilePreset) -> str:
|
|
2205
|
+
prefix = preset_protocol_summary(preset)
|
|
2206
|
+
if preset.notes:
|
|
2207
|
+
return f"{prefix}. {preset.notes}"
|
|
2208
|
+
parsed = urlparse(preset.base_url)
|
|
2209
|
+
if parsed.netloc:
|
|
2210
|
+
return f"{prefix}. Host: {parsed.netloc}"
|
|
2211
|
+
return f"{prefix}. Use any OpenAI-compatible base URL"
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
def _print_preset_warning(console: Console, preset: ProfilePreset) -> None:
|
|
2215
|
+
warning = str(preset.setup_warning or "").strip()
|
|
2216
|
+
if warning:
|
|
2217
|
+
console.print(f"[yellow]{warning}[/yellow]")
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
def _print_provider_diagnostic_warnings(console: Console, cfg: AppConfig) -> None:
|
|
2221
|
+
try:
|
|
2222
|
+
issues = provider_diagnostic_warning_lines(cfg)
|
|
2223
|
+
except ConfigError:
|
|
2224
|
+
return
|
|
2225
|
+
for issue in issues:
|
|
2226
|
+
console.print(f"[yellow]Provider diagnostic: {issue}[/yellow]")
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
def _suggest_workspace_default() -> Path:
|
|
2230
|
+
home = Path.home()
|
|
2231
|
+
for candidate in (home / "projects", home / "code"):
|
|
2232
|
+
if candidate.exists() and candidate.is_dir():
|
|
2233
|
+
return candidate
|
|
2234
|
+
return home
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _display_path(path: Path) -> str:
|
|
2238
|
+
home = Path.home()
|
|
2239
|
+
if path == home:
|
|
2240
|
+
return "~"
|
|
2241
|
+
if path.is_relative_to(home):
|
|
2242
|
+
return "~" + os.fspath(path).removeprefix(os.fspath(home))
|
|
2243
|
+
return os.fspath(path)
|
|
2244
|
+
|
|
2245
|
+
|
|
2246
|
+
def _escape_capture_unavailable_reason() -> str | None:
|
|
2247
|
+
try:
|
|
2248
|
+
from ..cli import _is_non_interactive_terminal
|
|
2249
|
+
except Exception as exc: # noqa: BLE001 - capability checks should degrade clearly
|
|
2250
|
+
return f"terminal capability check failed: {exc}"
|
|
2251
|
+
if _is_non_interactive_terminal():
|
|
2252
|
+
return "non-interactive terminal"
|
|
2253
|
+
try:
|
|
2254
|
+
from prompt_toolkit import PromptSession # noqa: F401
|
|
2255
|
+
from prompt_toolkit.key_binding import KeyBindings # noqa: F401
|
|
2256
|
+
except Exception as exc: # noqa: BLE001
|
|
2257
|
+
return f"prompt_toolkit unavailable: {exc}"
|
|
2258
|
+
return None
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
def _can_capture_escape() -> bool:
|
|
2262
|
+
return _escape_capture_unavailable_reason() is None
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
def _esc_aware_text_input(
|
|
2266
|
+
prompt: str,
|
|
2267
|
+
*,
|
|
2268
|
+
default: str = "",
|
|
2269
|
+
show_default: bool = True,
|
|
2270
|
+
hide_input: bool = False,
|
|
2271
|
+
) -> str:
|
|
2272
|
+
if not _can_capture_escape():
|
|
2273
|
+
return _prompt_text(
|
|
2274
|
+
prompt,
|
|
2275
|
+
default=default,
|
|
2276
|
+
show_default=show_default,
|
|
2277
|
+
hide_input=hide_input,
|
|
2278
|
+
)
|
|
2279
|
+
try:
|
|
2280
|
+
from prompt_toolkit import PromptSession
|
|
2281
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
2282
|
+
except Exception as exc: # noqa: BLE001
|
|
2283
|
+
_resolve_console().print(
|
|
2284
|
+
f"[dim]Esc/back navigation unavailable: {exc}. Using line input.[/dim]"
|
|
2285
|
+
)
|
|
2286
|
+
return _prompt_text(
|
|
2287
|
+
prompt,
|
|
2288
|
+
default=default,
|
|
2289
|
+
show_default=show_default,
|
|
2290
|
+
hide_input=hide_input,
|
|
2291
|
+
)
|
|
2292
|
+
|
|
2293
|
+
bindings = KeyBindings()
|
|
2294
|
+
|
|
2295
|
+
@bindings.add("escape", eager=True)
|
|
2296
|
+
def _go_back(event: Any) -> None:
|
|
2297
|
+
event.app.exit(exception=_GoBack())
|
|
2298
|
+
|
|
2299
|
+
session: PromptSession[str] = PromptSession(
|
|
2300
|
+
key_bindings=bindings,
|
|
2301
|
+
is_password=hide_input,
|
|
2302
|
+
)
|
|
2303
|
+
try:
|
|
2304
|
+
return str(
|
|
2305
|
+
session.prompt(
|
|
2306
|
+
f"{prompt}: ",
|
|
2307
|
+
default=default,
|
|
2308
|
+
is_password=hide_input,
|
|
2309
|
+
)
|
|
2310
|
+
)
|
|
2311
|
+
except _GoBack:
|
|
2312
|
+
raise
|
|
2313
|
+
except (KeyboardInterrupt, EOFError):
|
|
2314
|
+
raise
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
def _confirm_cancel(
|
|
2318
|
+
console: Console, *, prompt: str = "Cancel setup and discard inputs? [y/N]"
|
|
2319
|
+
) -> bool:
|
|
2320
|
+
while True:
|
|
2321
|
+
try:
|
|
2322
|
+
value = _esc_aware_text_input(prompt, default="n", show_default=False).strip().lower()
|
|
2323
|
+
except _GoBack:
|
|
2324
|
+
return False
|
|
2325
|
+
except (Abort, EOFError, KeyboardInterrupt):
|
|
2326
|
+
return True
|
|
2327
|
+
if not value:
|
|
2328
|
+
return False
|
|
2329
|
+
if value in {"y", "yes"}:
|
|
2330
|
+
return True
|
|
2331
|
+
if value in {"n", "no"}:
|
|
2332
|
+
return False
|
|
2333
|
+
console.print("[red]Enter y or n.[/red]")
|
|
2334
|
+
|
|
2335
|
+
|
|
2336
|
+
def _prompt_text(
|
|
2337
|
+
prompt: str,
|
|
2338
|
+
*,
|
|
2339
|
+
default: str,
|
|
2340
|
+
show_default: bool,
|
|
2341
|
+
hide_input: bool = False,
|
|
2342
|
+
) -> str:
|
|
2343
|
+
return str(
|
|
2344
|
+
typer.prompt(
|
|
2345
|
+
prompt,
|
|
2346
|
+
default=default,
|
|
2347
|
+
show_default=show_default,
|
|
2348
|
+
hide_input=hide_input,
|
|
2349
|
+
)
|
|
2350
|
+
)
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
def _prompt_yes_no(prompt: str, *, default: bool) -> bool:
|
|
2354
|
+
default_text = "y" if default else "n"
|
|
2355
|
+
while True:
|
|
2356
|
+
value = (
|
|
2357
|
+
_esc_aware_text_input(prompt, default=default_text, show_default=False).strip().lower()
|
|
2358
|
+
)
|
|
2359
|
+
if _is_cancel_token(value):
|
|
2360
|
+
raise _GoBack()
|
|
2361
|
+
if not value:
|
|
2362
|
+
return default
|
|
2363
|
+
if value in {"y", "yes"}:
|
|
2364
|
+
return True
|
|
2365
|
+
if value in {"n", "no"}:
|
|
2366
|
+
return False
|
|
2367
|
+
_resolve_console().print("[red]Enter y or n.[/red]")
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def _is_cancel_token(value: str) -> bool:
|
|
2371
|
+
normalized = str(value or "").strip().casefold()
|
|
2372
|
+
return normalized in {"\x1b", "esc", "escape", "cancel", "q", "quit"}
|
|
2373
|
+
|
|
2374
|
+
|
|
2375
|
+
def _write_sandbox_pull_log(result: SandboxPullResult) -> Path | None:
|
|
2376
|
+
lines = [
|
|
2377
|
+
f"ok={result.ok}",
|
|
2378
|
+
f"error={result.error or ''}",
|
|
2379
|
+
"",
|
|
2380
|
+
]
|
|
2381
|
+
for item in result.results:
|
|
2382
|
+
lines.extend(
|
|
2383
|
+
[
|
|
2384
|
+
f"image={item.image}",
|
|
2385
|
+
f"ok={item.ok}",
|
|
2386
|
+
"output:",
|
|
2387
|
+
item.output,
|
|
2388
|
+
"",
|
|
2389
|
+
]
|
|
2390
|
+
)
|
|
2391
|
+
return _write_log("sandbox_pull", "\n".join(lines))
|
|
2392
|
+
|
|
2393
|
+
|
|
2394
|
+
def _write_exception_log(prefix: str, exc: BaseException) -> Path | None:
|
|
2395
|
+
content = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
2396
|
+
return _write_log(prefix, content)
|
|
2397
|
+
|
|
2398
|
+
|
|
2399
|
+
def _write_log(prefix: str, content: str) -> Path | None:
|
|
2400
|
+
try:
|
|
2401
|
+
logs_dir = default_sessions_dir().parent / "logs"
|
|
2402
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
2403
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
|
2404
|
+
path = logs_dir / f"{prefix}_{timestamp}.log"
|
|
2405
|
+
path.write_text(content, encoding="utf-8")
|
|
2406
|
+
return path
|
|
2407
|
+
except OSError as exc:
|
|
2408
|
+
_resolve_console().print(f"[yellow]Could not write diagnostic log:[/yellow] {exc}")
|
|
2409
|
+
return None
|