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