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,774 @@
|
|
|
1
|
+
"""Sampling determinism controls and provider-response provenance.
|
|
2
|
+
|
|
3
|
+
Two production incidents motivate this module, and both were *unprovable* after
|
|
4
|
+
the fact rather than merely unfixed.
|
|
5
|
+
|
|
6
|
+
*Silent model drift.* Three Terminal-Bench trials of one pinned build under one
|
|
7
|
+
pinned config disagreed on 30 of 89 tasks. Two SWE-bench runs of a
|
|
8
|
+
byte-identical build scored 80 and then 71 four days apart, with the delegation
|
|
9
|
+
pattern changing beyond recognition (one subagent went from 128 invocations to
|
|
10
|
+
zero). Server-side drift was the obvious suspect and stayed a suspicion,
|
|
11
|
+
because nothing in the run recorded which model actually answered. The response
|
|
12
|
+
``model`` field, the ``system_fingerprint`` and a small set of routing headers
|
|
13
|
+
are the only evidence a client can collect without the provider's cooperation,
|
|
14
|
+
so this module selects them and hands them to provider telemetry.
|
|
15
|
+
|
|
16
|
+
*Unpinned sampling.* The client pinned no seed, requested no ``top_p``, and let
|
|
17
|
+
``temperature`` follow whichever code path built the request, so two runs of one
|
|
18
|
+
build were free to sample differently from an identical prompt. The three
|
|
19
|
+
settings here are opt-in precisely because switching them on changes the wire
|
|
20
|
+
request: when none is configured the payload is left exactly as the transport
|
|
21
|
+
built it. :func:`apply_sampling_to_payload` guarantees that by construction --
|
|
22
|
+
it is the single place any of the three fields can enter a payload -- and
|
|
23
|
+
``tests/test_sampling_config.py`` asserts it byte-for-byte.
|
|
24
|
+
|
|
25
|
+
Stdlib only, no package imports: the transport, the telemetry recorder and the
|
|
26
|
+
session bootstrap all pull this in, and the tests load it straight from this
|
|
27
|
+
file path in a bare interpreter.
|
|
28
|
+
|
|
29
|
+
Redaction boundary
|
|
30
|
+
------------------
|
|
31
|
+
This module *shapes* values, it never persists them. Every caller writes
|
|
32
|
+
through a sink that funnels its serialized line through
|
|
33
|
+
``logging_redaction.redact_log_text`` immediately before the write
|
|
34
|
+
(``SessionStore.append`` and ``provider_telemetry._append_to_sink``), and
|
|
35
|
+
``provider_telemetry`` redacts each selected header value a second time before
|
|
36
|
+
it reaches the in-memory history. Independently of redaction, the header
|
|
37
|
+
allowlist here is bounded by a deny list that no user-supplied allowlist can
|
|
38
|
+
widen, so a credential-bearing header cannot be selected in the first place.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import fnmatch
|
|
44
|
+
import math
|
|
45
|
+
import os
|
|
46
|
+
import threading
|
|
47
|
+
from collections.abc import Mapping
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Sampling determinism controls
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
SAMPLING_TEMPERATURE_ENV = "ALYSIS_SAMPLING_TEMPERATURE"
|
|
56
|
+
SAMPLING_TOP_P_ENV = "ALYSIS_SAMPLING_TOP_P"
|
|
57
|
+
SAMPLING_SEED_ENV = "ALYSIS_SAMPLING_SEED"
|
|
58
|
+
|
|
59
|
+
SAMPLING_TEMPERATURE_CONFIG_KEY = "sampling_temperature"
|
|
60
|
+
SAMPLING_TOP_P_CONFIG_KEY = "sampling_top_p"
|
|
61
|
+
SAMPLING_SEED_CONFIG_KEY = "sampling_seed"
|
|
62
|
+
|
|
63
|
+
#: Accepted ranges. Deliberately the widest range every OpenAI-compatible
|
|
64
|
+
#: endpoint we target documents, so a legitimate value is never dropped; a
|
|
65
|
+
#: provider that is stricter still rejects it on the wire, which is visible.
|
|
66
|
+
TEMPERATURE_RANGE = (0.0, 2.0)
|
|
67
|
+
TOP_P_RANGE = (0.0, 1.0)
|
|
68
|
+
#: Signed 64-bit, the widest seed any of these endpoints accepts.
|
|
69
|
+
SEED_RANGE = (-(2**63), 2**63 - 1)
|
|
70
|
+
|
|
71
|
+
#: Longest raw value echoed back in a warning event. A misconfigured variable
|
|
72
|
+
#: is sometimes a pasted credential, and while the sink redacts on write, a
|
|
73
|
+
#: bounded echo keeps the blast radius small even before that.
|
|
74
|
+
MAX_WARNING_VALUE_CHARS = 40
|
|
75
|
+
|
|
76
|
+
_SAMPLING_SETTING_NAMES = ("temperature", "top_p", "seed")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class SamplingWarning:
|
|
81
|
+
"""One sampling value that was rejected and ignored.
|
|
82
|
+
|
|
83
|
+
An invalid sampling setting must never abort a run: an operator typo in a
|
|
84
|
+
benchmark harness would otherwise take down the whole campaign. The value
|
|
85
|
+
is dropped, the default behavior stands, and this record explains why.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
setting: str
|
|
89
|
+
source: str
|
|
90
|
+
reason: str
|
|
91
|
+
raw: str
|
|
92
|
+
|
|
93
|
+
def message(self) -> str:
|
|
94
|
+
return (
|
|
95
|
+
f"ignoring {self.source}={self.raw!r}: {self.reason}; "
|
|
96
|
+
f"sampling {self.setting} is left unset"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def payload(self) -> dict[str, str]:
|
|
100
|
+
return {
|
|
101
|
+
"setting": self.setting,
|
|
102
|
+
"source": self.source,
|
|
103
|
+
"reason": self.reason,
|
|
104
|
+
"raw": self.raw,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class SamplingSettings:
|
|
110
|
+
"""The effective sampling overrides for a run.
|
|
111
|
+
|
|
112
|
+
``None`` means "not configured", which is not the same as "configured to
|
|
113
|
+
the provider default": an unconfigured setting is never written to a
|
|
114
|
+
request at all.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
temperature: float | None = None
|
|
118
|
+
top_p: float | None = None
|
|
119
|
+
seed: int | None = None
|
|
120
|
+
sources: Mapping[str, str] = field(default_factory=dict)
|
|
121
|
+
warnings: tuple[SamplingWarning, ...] = ()
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def is_configured(self) -> bool:
|
|
125
|
+
return self.temperature is not None or self.top_p is not None or self.seed is not None
|
|
126
|
+
|
|
127
|
+
def telemetry_payload(self) -> dict[str, Any]:
|
|
128
|
+
"""Per-request sampling record. Present on every call, configured or not.
|
|
129
|
+
|
|
130
|
+
The absence of sampling controls is itself the finding when two runs
|
|
131
|
+
of one build diverge, so this never collapses to ``None``.
|
|
132
|
+
"""
|
|
133
|
+
return {
|
|
134
|
+
"configured": self.is_configured,
|
|
135
|
+
"temperature": self.temperature,
|
|
136
|
+
"top_p": self.top_p,
|
|
137
|
+
"seed": self.seed,
|
|
138
|
+
"sources": {str(k): str(v) for k, v in sorted(dict(self.sources).items())},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def session_event_payload(self) -> dict[str, Any]:
|
|
142
|
+
"""Once-per-run record, including anything that was rejected."""
|
|
143
|
+
payload = self.telemetry_payload()
|
|
144
|
+
payload["warnings"] = [warning.payload() for warning in self.warnings]
|
|
145
|
+
payload["warning_count"] = len(self.warnings)
|
|
146
|
+
return payload
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _clean_text(value: Any) -> str:
|
|
150
|
+
if value is None:
|
|
151
|
+
return ""
|
|
152
|
+
return str(value).strip()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _truncate(value: str, limit: int) -> str:
|
|
156
|
+
if len(value) <= limit:
|
|
157
|
+
return value
|
|
158
|
+
return value[:limit] + "..."
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_bounded_float(
|
|
162
|
+
raw: str,
|
|
163
|
+
*,
|
|
164
|
+
setting: str,
|
|
165
|
+
source: str,
|
|
166
|
+
low: float,
|
|
167
|
+
high: float,
|
|
168
|
+
) -> tuple[float | None, SamplingWarning | None]:
|
|
169
|
+
echo = _truncate(raw, MAX_WARNING_VALUE_CHARS)
|
|
170
|
+
try:
|
|
171
|
+
value = float(raw)
|
|
172
|
+
except (TypeError, ValueError):
|
|
173
|
+
return None, SamplingWarning(setting, source, "not a number", echo)
|
|
174
|
+
if not math.isfinite(value):
|
|
175
|
+
return None, SamplingWarning(setting, source, "not finite", echo)
|
|
176
|
+
if value < low or value > high:
|
|
177
|
+
return None, SamplingWarning(
|
|
178
|
+
setting,
|
|
179
|
+
source,
|
|
180
|
+
f"outside the accepted range [{low}, {high}]",
|
|
181
|
+
echo,
|
|
182
|
+
)
|
|
183
|
+
return value, None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_bounded_int(
|
|
187
|
+
raw: str,
|
|
188
|
+
*,
|
|
189
|
+
setting: str,
|
|
190
|
+
source: str,
|
|
191
|
+
low: int,
|
|
192
|
+
high: int,
|
|
193
|
+
) -> tuple[int | None, SamplingWarning | None]:
|
|
194
|
+
echo = _truncate(raw, MAX_WARNING_VALUE_CHARS)
|
|
195
|
+
try:
|
|
196
|
+
value = int(raw, 10) if isinstance(raw, str) else int(raw)
|
|
197
|
+
except (TypeError, ValueError):
|
|
198
|
+
return None, SamplingWarning(setting, source, "not an integer", echo)
|
|
199
|
+
if value < low or value > high:
|
|
200
|
+
return None, SamplingWarning(
|
|
201
|
+
setting,
|
|
202
|
+
source,
|
|
203
|
+
"outside the accepted 64-bit range",
|
|
204
|
+
echo,
|
|
205
|
+
)
|
|
206
|
+
return value, None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _pick_source(
|
|
210
|
+
*,
|
|
211
|
+
env_name: str,
|
|
212
|
+
config_key: str,
|
|
213
|
+
environ: Mapping[str, str],
|
|
214
|
+
config_values: Mapping[str, Any],
|
|
215
|
+
) -> tuple[str, str] | None:
|
|
216
|
+
"""Return ``(raw_value, source_label)``, env winning over config.
|
|
217
|
+
|
|
218
|
+
Matches the house precedence used everywhere else in the CLI: an
|
|
219
|
+
environment variable is how a benchmark harness pins a run, and it must
|
|
220
|
+
beat whatever happens to be in the operator's ``config.json``.
|
|
221
|
+
"""
|
|
222
|
+
resolved_env_name = env_name
|
|
223
|
+
raw_env = _clean_text(environ.get(env_name))
|
|
224
|
+
if not raw_env and env_name.startswith("ALYSIS_"):
|
|
225
|
+
legacy_env_name = "SYLLIPTOR_" + env_name.removeprefix("ALYSIS_")
|
|
226
|
+
raw_env = _clean_text(environ.get(legacy_env_name))
|
|
227
|
+
if raw_env:
|
|
228
|
+
resolved_env_name = legacy_env_name
|
|
229
|
+
if raw_env:
|
|
230
|
+
return raw_env, f"env:{resolved_env_name}"
|
|
231
|
+
if config_key in config_values:
|
|
232
|
+
raw_config = _clean_text(config_values.get(config_key))
|
|
233
|
+
if raw_config:
|
|
234
|
+
return raw_config, f"config:{config_key}"
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def resolve_sampling_settings(
|
|
239
|
+
*,
|
|
240
|
+
config_values: Mapping[str, Any] | None = None,
|
|
241
|
+
environ: Mapping[str, str] | None = None,
|
|
242
|
+
) -> SamplingSettings:
|
|
243
|
+
"""Resolve the three sampling controls from env, then config.
|
|
244
|
+
|
|
245
|
+
Every failure mode -- unparseable, non-finite, out of range -- resolves to
|
|
246
|
+
"not configured" plus a warning. Nothing here can raise.
|
|
247
|
+
"""
|
|
248
|
+
source_env: Mapping[str, str] = os.environ if environ is None else environ
|
|
249
|
+
values: Mapping[str, Any] = {} if config_values is None else config_values
|
|
250
|
+
|
|
251
|
+
resolved: dict[str, Any] = {}
|
|
252
|
+
sources: dict[str, str] = {}
|
|
253
|
+
warnings: list[SamplingWarning] = []
|
|
254
|
+
|
|
255
|
+
plan = (
|
|
256
|
+
("temperature", SAMPLING_TEMPERATURE_ENV, SAMPLING_TEMPERATURE_CONFIG_KEY),
|
|
257
|
+
("top_p", SAMPLING_TOP_P_ENV, SAMPLING_TOP_P_CONFIG_KEY),
|
|
258
|
+
("seed", SAMPLING_SEED_ENV, SAMPLING_SEED_CONFIG_KEY),
|
|
259
|
+
)
|
|
260
|
+
for setting, env_name, config_key in plan:
|
|
261
|
+
picked = _pick_source(
|
|
262
|
+
env_name=env_name,
|
|
263
|
+
config_key=config_key,
|
|
264
|
+
environ=source_env,
|
|
265
|
+
config_values=values,
|
|
266
|
+
)
|
|
267
|
+
if picked is None:
|
|
268
|
+
continue
|
|
269
|
+
raw, source = picked
|
|
270
|
+
if setting == "seed":
|
|
271
|
+
value, warning = _parse_bounded_int(
|
|
272
|
+
raw,
|
|
273
|
+
setting=setting,
|
|
274
|
+
source=source,
|
|
275
|
+
low=SEED_RANGE[0],
|
|
276
|
+
high=SEED_RANGE[1],
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
low, high = TEMPERATURE_RANGE if setting == "temperature" else TOP_P_RANGE
|
|
280
|
+
value, warning = _parse_bounded_float(
|
|
281
|
+
raw,
|
|
282
|
+
setting=setting,
|
|
283
|
+
source=source,
|
|
284
|
+
low=low,
|
|
285
|
+
high=high,
|
|
286
|
+
)
|
|
287
|
+
if warning is not None:
|
|
288
|
+
warnings.append(warning)
|
|
289
|
+
continue
|
|
290
|
+
resolved[setting] = value
|
|
291
|
+
sources[setting] = source
|
|
292
|
+
|
|
293
|
+
return SamplingSettings(
|
|
294
|
+
temperature=resolved.get("temperature"),
|
|
295
|
+
top_p=resolved.get("top_p"),
|
|
296
|
+
seed=resolved.get("seed"),
|
|
297
|
+
sources=sources,
|
|
298
|
+
warnings=tuple(warnings),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def apply_sampling_to_payload(
|
|
303
|
+
payload: dict[str, Any],
|
|
304
|
+
settings: SamplingSettings,
|
|
305
|
+
*,
|
|
306
|
+
allow_temperature_override: bool = True,
|
|
307
|
+
) -> tuple[str, ...]:
|
|
308
|
+
"""Write the configured sampling fields into an already-built payload.
|
|
309
|
+
|
|
310
|
+
This is the *only* place any of the three fields may enter a
|
|
311
|
+
chat-completions request, which is what makes the "unset means unchanged"
|
|
312
|
+
guarantee checkable rather than aspirational: with an unconfigured
|
|
313
|
+
``settings`` this returns ``()`` and leaves ``payload`` untouched -- same
|
|
314
|
+
object, same keys, same insertion order, hence the same serialized bytes
|
|
315
|
+
as the pre-PR6 transport produced.
|
|
316
|
+
|
|
317
|
+
``temperature`` is only *overridden*, never introduced. When the transport
|
|
318
|
+
deliberately omitted it (a documented model policy, or a cached provider
|
|
319
|
+
rejection) that omission is load-bearing, and re-adding the field would
|
|
320
|
+
reintroduce the 400 the omission exists to avoid. ``allow_temperature_override``
|
|
321
|
+
lets the caller additionally decline when it has already rewritten the
|
|
322
|
+
value for provider-compatibility reasons.
|
|
323
|
+
|
|
324
|
+
Returns the field names actually written, in insertion order.
|
|
325
|
+
"""
|
|
326
|
+
applied: list[str] = []
|
|
327
|
+
override_temperature = (
|
|
328
|
+
settings.temperature is not None and allow_temperature_override and "temperature" in payload
|
|
329
|
+
)
|
|
330
|
+
if override_temperature:
|
|
331
|
+
payload["temperature"] = settings.temperature
|
|
332
|
+
applied.append("temperature")
|
|
333
|
+
if settings.top_p is not None:
|
|
334
|
+
payload["top_p"] = settings.top_p
|
|
335
|
+
applied.append("top_p")
|
|
336
|
+
if settings.seed is not None:
|
|
337
|
+
payload["seed"] = settings.seed
|
|
338
|
+
applied.append("seed")
|
|
339
|
+
return tuple(applied)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# The process-wide active settings. The transport is several call layers below
|
|
343
|
+
# the place that knows the effective config, and threading a settings object
|
|
344
|
+
# through every constructor would be the refactor this change is meant to
|
|
345
|
+
# avoid; this mirrors the existing process-wide provider-telemetry sink.
|
|
346
|
+
_ACTIVE_LOCK = threading.Lock()
|
|
347
|
+
_ACTIVE_SETTINGS: SamplingSettings | None = None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def set_active_sampling_settings(settings: SamplingSettings | None) -> None:
|
|
351
|
+
"""Install the run's resolved sampling settings (``None`` restores env-only)."""
|
|
352
|
+
global _ACTIVE_SETTINGS
|
|
353
|
+
with _ACTIVE_LOCK:
|
|
354
|
+
_ACTIVE_SETTINGS = settings
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def active_sampling_settings() -> SamplingSettings:
|
|
358
|
+
"""Return the installed settings, falling back to an env-only resolution.
|
|
359
|
+
|
|
360
|
+
The fallback matters for every entry point that reaches the transport
|
|
361
|
+
without building a session (``config`` subcommands, doctors, probes): those
|
|
362
|
+
still honor the environment, and still report "not configured" when the
|
|
363
|
+
environment is empty.
|
|
364
|
+
"""
|
|
365
|
+
global _ACTIVE_SETTINGS
|
|
366
|
+
with _ACTIVE_LOCK:
|
|
367
|
+
if _ACTIVE_SETTINGS is None:
|
|
368
|
+
_ACTIVE_SETTINGS = resolve_sampling_settings()
|
|
369
|
+
return _ACTIVE_SETTINGS
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def reset_active_sampling_settings_for_tests() -> None:
|
|
373
|
+
global _ACTIVE_SETTINGS
|
|
374
|
+
with _ACTIVE_LOCK:
|
|
375
|
+
_ACTIVE_SETTINGS = None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Response fingerprint capture
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
#: Headers worth keeping by name. Each one has, at some point, been the only
|
|
383
|
+
#: thing distinguishing two responses that claimed the same model.
|
|
384
|
+
DEFAULT_RESPONSE_HEADER_NAMES = (
|
|
385
|
+
"openai-organization",
|
|
386
|
+
"server",
|
|
387
|
+
"via",
|
|
388
|
+
"x-model-version",
|
|
389
|
+
"x-request-id",
|
|
390
|
+
"x-served-by",
|
|
391
|
+
)
|
|
392
|
+
#: Plus anything a custom endpoint chose to call a model or a version. Custom
|
|
393
|
+
#: OpenAI-compatible gateways name these fields freely; the MiMo endpoint that
|
|
394
|
+
#: exposed the drift is exactly such a gateway.
|
|
395
|
+
DEFAULT_RESPONSE_HEADER_PATTERNS = ("*model*", "*version*")
|
|
396
|
+
|
|
397
|
+
RESPONSE_HEADER_ALLOWLIST_ENV = "ALYSIS_RESPONSE_HEADER_ALLOWLIST"
|
|
398
|
+
|
|
399
|
+
#: Never captured, whatever the allowlist says. An operator who sets the
|
|
400
|
+
#: allowlist to ``*`` is asking for provenance, not for their bearer token in
|
|
401
|
+
#: a JSONL file, and PR1 exists because that exact class of mistake already
|
|
402
|
+
#: put a live credential on disk once.
|
|
403
|
+
RESPONSE_HEADER_DENY_PATTERNS = (
|
|
404
|
+
"*auth*",
|
|
405
|
+
"*cookie*",
|
|
406
|
+
"*credential*",
|
|
407
|
+
"*key*",
|
|
408
|
+
"*password*",
|
|
409
|
+
"*secret*",
|
|
410
|
+
"*token*",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
MAX_RESPONSE_HEADER_VALUE_CHARS = 200
|
|
414
|
+
MAX_RESPONSE_HEADERS = 24
|
|
415
|
+
|
|
416
|
+
_ALLOWLIST_DISABLED_WORDS = frozenset({"none", "off", "-"})
|
|
417
|
+
_GLOB_CHARS = ("*", "?", "[")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _normalize_header_name(name: Any) -> str:
|
|
421
|
+
return _clean_text(name).casefold()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def header_name_is_denied(name: str) -> bool:
|
|
425
|
+
"""True when a header may never be captured, allowlist notwithstanding."""
|
|
426
|
+
normalized = _normalize_header_name(name)
|
|
427
|
+
if not normalized:
|
|
428
|
+
return True
|
|
429
|
+
return any(
|
|
430
|
+
fnmatch.fnmatchcase(normalized, pattern) for pattern in RESPONSE_HEADER_DENY_PATTERNS
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@dataclass(frozen=True)
|
|
435
|
+
class ResponseHeaderAllowlist:
|
|
436
|
+
"""Exact names plus glob patterns, matched case-insensitively."""
|
|
437
|
+
|
|
438
|
+
names: frozenset[str] = frozenset()
|
|
439
|
+
patterns: tuple[str, ...] = ()
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def is_empty(self) -> bool:
|
|
443
|
+
return not self.names and not self.patterns
|
|
444
|
+
|
|
445
|
+
def matches(self, header_name: str) -> bool:
|
|
446
|
+
normalized = _normalize_header_name(header_name)
|
|
447
|
+
if not normalized or header_name_is_denied(normalized):
|
|
448
|
+
return False
|
|
449
|
+
if normalized in self.names:
|
|
450
|
+
return True
|
|
451
|
+
return any(fnmatch.fnmatchcase(normalized, pattern) for pattern in self.patterns)
|
|
452
|
+
|
|
453
|
+
def describe(self) -> dict[str, Any]:
|
|
454
|
+
return {
|
|
455
|
+
"names": sorted(self.names),
|
|
456
|
+
"patterns": list(self.patterns),
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
DEFAULT_RESPONSE_HEADER_ALLOWLIST = ResponseHeaderAllowlist(
|
|
461
|
+
names=frozenset(DEFAULT_RESPONSE_HEADER_NAMES),
|
|
462
|
+
patterns=DEFAULT_RESPONSE_HEADER_PATTERNS,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def parse_response_header_allowlist(raw: str | None) -> ResponseHeaderAllowlist:
|
|
467
|
+
"""Parse a comma/whitespace-separated allowlist spec.
|
|
468
|
+
|
|
469
|
+
Unset or blank keeps the default. The words ``none``/``off``/``-`` disable
|
|
470
|
+
header capture entirely, which is the escape hatch for an operator who
|
|
471
|
+
considers response headers sensitive in their deployment.
|
|
472
|
+
"""
|
|
473
|
+
text = _clean_text(raw)
|
|
474
|
+
if not text:
|
|
475
|
+
return DEFAULT_RESPONSE_HEADER_ALLOWLIST
|
|
476
|
+
entries = [
|
|
477
|
+
_normalize_header_name(part)
|
|
478
|
+
for chunk in text.split(",")
|
|
479
|
+
for part in chunk.split()
|
|
480
|
+
if _clean_text(part)
|
|
481
|
+
]
|
|
482
|
+
entries = [entry for entry in entries if entry]
|
|
483
|
+
if not entries:
|
|
484
|
+
return DEFAULT_RESPONSE_HEADER_ALLOWLIST
|
|
485
|
+
if len(entries) == 1 and entries[0] in _ALLOWLIST_DISABLED_WORDS:
|
|
486
|
+
return ResponseHeaderAllowlist()
|
|
487
|
+
names = {entry for entry in entries if not any(char in entry for char in _GLOB_CHARS)}
|
|
488
|
+
patterns = tuple(
|
|
489
|
+
dict.fromkeys(entry for entry in entries if any(char in entry for char in _GLOB_CHARS))
|
|
490
|
+
)
|
|
491
|
+
return ResponseHeaderAllowlist(names=frozenset(names), patterns=patterns)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def resolve_response_header_allowlist(
|
|
495
|
+
*,
|
|
496
|
+
environ: Mapping[str, str] | None = None,
|
|
497
|
+
) -> ResponseHeaderAllowlist:
|
|
498
|
+
source: Mapping[str, str] = os.environ if environ is None else environ
|
|
499
|
+
return parse_response_header_allowlist(source.get(RESPONSE_HEADER_ALLOWLIST_ENV))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def select_response_headers(
|
|
503
|
+
headers: Any,
|
|
504
|
+
*,
|
|
505
|
+
allowlist: ResponseHeaderAllowlist | None = None,
|
|
506
|
+
) -> dict[str, str]:
|
|
507
|
+
"""Return the allowlisted response headers, lowercased, bounded and sorted.
|
|
508
|
+
|
|
509
|
+
Accepts anything mapping-like, including ``httpx.Headers``, and tolerates a
|
|
510
|
+
``None`` or a broken object -- provenance capture must never be the reason
|
|
511
|
+
a provider call fails.
|
|
512
|
+
"""
|
|
513
|
+
active = DEFAULT_RESPONSE_HEADER_ALLOWLIST if allowlist is None else allowlist
|
|
514
|
+
if active.is_empty:
|
|
515
|
+
return {}
|
|
516
|
+
try:
|
|
517
|
+
items = list(headers.items())
|
|
518
|
+
except Exception: # noqa: BLE001 - never let capture break a provider call
|
|
519
|
+
return {}
|
|
520
|
+
selected: dict[str, str] = {}
|
|
521
|
+
for raw_name, raw_value in items:
|
|
522
|
+
name = _normalize_header_name(raw_name)
|
|
523
|
+
if not active.matches(name):
|
|
524
|
+
continue
|
|
525
|
+
selected[name] = _truncate(_clean_text(raw_value), MAX_RESPONSE_HEADER_VALUE_CHARS)
|
|
526
|
+
if len(selected) >= MAX_RESPONSE_HEADERS:
|
|
527
|
+
break
|
|
528
|
+
return dict(sorted(selected.items()))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def extract_system_fingerprint(raw: Any) -> str | None:
|
|
532
|
+
"""Pull ``system_fingerprint`` out of a parsed response body, if present."""
|
|
533
|
+
if not isinstance(raw, Mapping):
|
|
534
|
+
return None
|
|
535
|
+
value = _clean_text(raw.get("system_fingerprint"))
|
|
536
|
+
return value or None
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def response_fingerprint_payload(
|
|
540
|
+
*,
|
|
541
|
+
response_model: str | None = None,
|
|
542
|
+
system_fingerprint: str | None = None,
|
|
543
|
+
headers: Mapping[str, str] | None = None,
|
|
544
|
+
) -> dict[str, Any]:
|
|
545
|
+
"""Shape the per-response provenance record for provider telemetry.
|
|
546
|
+
|
|
547
|
+
Always returns every key. A run where ``system_fingerprint`` is
|
|
548
|
+
consistently absent is a different finding from a run where it changed
|
|
549
|
+
mid-flight, and only an always-present field can tell them apart.
|
|
550
|
+
|
|
551
|
+
The header key is ``response_headers``, not ``headers``: provider
|
|
552
|
+
telemetry's own redactor replaces any value under a key named ``headers``
|
|
553
|
+
with ``[omitted]``, so the plain name would silently discard exactly the
|
|
554
|
+
evidence this record exists to carry.
|
|
555
|
+
"""
|
|
556
|
+
safe_headers = {str(k): str(v) for k, v in dict(headers or {}).items()}
|
|
557
|
+
return {
|
|
558
|
+
"response_model": _clean_text(response_model) or None,
|
|
559
|
+
"system_fingerprint": _clean_text(system_fingerprint) or None,
|
|
560
|
+
"response_headers": safe_headers,
|
|
561
|
+
"response_header_count": len(safe_headers),
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def fingerprint_drift_payload(calls: Any) -> dict[str, Any]:
|
|
566
|
+
"""Roll per-call fingerprints up into a drift verdict for one run.
|
|
567
|
+
|
|
568
|
+
This is the question the earlier investigations could not answer. Two runs
|
|
569
|
+
of a byte-identical build scored 80 and then 71, and the hypothesis --
|
|
570
|
+
that the endpoint had quietly started serving something else -- could
|
|
571
|
+
neither be confirmed nor dismissed. Grouping by the model *requested*
|
|
572
|
+
answers it directly: one requested model that came back under two different
|
|
573
|
+
``system_fingerprint`` values, or under two different response ``model``
|
|
574
|
+
names, is drift observed rather than inferred.
|
|
575
|
+
|
|
576
|
+
Pure over a list of already-recorded provider-call payloads, so it can be
|
|
577
|
+
run against the in-memory history or against a replayed telemetry JSONL.
|
|
578
|
+
"""
|
|
579
|
+
groups: dict[str, dict[str, Any]] = {}
|
|
580
|
+
models: set[str] = set()
|
|
581
|
+
fingerprints: set[str] = set()
|
|
582
|
+
call_count = 0
|
|
583
|
+
fingerprint_present = 0
|
|
584
|
+
|
|
585
|
+
for call in calls or []:
|
|
586
|
+
if not isinstance(call, Mapping):
|
|
587
|
+
continue
|
|
588
|
+
call_count += 1
|
|
589
|
+
requested = _clean_text(call.get("model"))
|
|
590
|
+
record = call.get("response_fingerprint")
|
|
591
|
+
record = record if isinstance(record, Mapping) else {}
|
|
592
|
+
response_model = _clean_text(record.get("response_model"))
|
|
593
|
+
fingerprint = _clean_text(record.get("system_fingerprint"))
|
|
594
|
+
if fingerprint:
|
|
595
|
+
fingerprint_present += 1
|
|
596
|
+
fingerprints.add(fingerprint)
|
|
597
|
+
if response_model:
|
|
598
|
+
models.add(response_model)
|
|
599
|
+
group = groups.setdefault(
|
|
600
|
+
requested,
|
|
601
|
+
{"requested_model": requested, "call_count": 0, "_models": set(), "_fps": set()},
|
|
602
|
+
)
|
|
603
|
+
group["call_count"] += 1
|
|
604
|
+
if response_model:
|
|
605
|
+
group["_models"].add(response_model)
|
|
606
|
+
if fingerprint:
|
|
607
|
+
group["_fps"].add(fingerprint)
|
|
608
|
+
|
|
609
|
+
by_requested_model = []
|
|
610
|
+
drift = False
|
|
611
|
+
for _key, group in sorted(groups.items()):
|
|
612
|
+
group_models = sorted(group.pop("_models"))
|
|
613
|
+
group_fps = sorted(group.pop("_fps"))
|
|
614
|
+
group_drift = len(group_models) > 1 or len(group_fps) > 1
|
|
615
|
+
drift = drift or group_drift
|
|
616
|
+
group["response_models"] = group_models
|
|
617
|
+
group["system_fingerprints"] = group_fps
|
|
618
|
+
group["drift_detected"] = group_drift
|
|
619
|
+
by_requested_model.append(group)
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
"window_call_count": call_count,
|
|
623
|
+
"response_models": sorted(models),
|
|
624
|
+
"system_fingerprints": sorted(fingerprints),
|
|
625
|
+
"distinct_response_model_count": len(models),
|
|
626
|
+
"distinct_system_fingerprint_count": len(fingerprints),
|
|
627
|
+
"system_fingerprint_present_call_count": fingerprint_present,
|
|
628
|
+
# Absence is a finding too: an endpoint that never sends a fingerprint
|
|
629
|
+
# cannot be monitored this way, and saying so beats an empty field that
|
|
630
|
+
# reads like "no drift".
|
|
631
|
+
"system_fingerprint_absent_call_count": max(0, call_count - fingerprint_present),
|
|
632
|
+
"drift_detected": drift,
|
|
633
|
+
"by_requested_model": by_requested_model,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
# ---------------------------------------------------------------------------
|
|
638
|
+
# Effective-configuration snapshot
|
|
639
|
+
# ---------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
CONFIG_SNAPSHOT_EVENT = "config_snapshot"
|
|
642
|
+
CONFIG_SNAPSHOT_SCHEMA_VERSION = 1
|
|
643
|
+
|
|
644
|
+
#: Config/env key names whose *value* is masked structurally, before any
|
|
645
|
+
#: text-level redaction. Exact names first so that ordinary settings which
|
|
646
|
+
#: merely contain a scary substring -- ``max_tokens``, ``reasoning_tokens`` --
|
|
647
|
+
#: survive intact and stay analyzable.
|
|
648
|
+
_SECRET_EXACT_KEYS = frozenset(
|
|
649
|
+
{
|
|
650
|
+
"api_key",
|
|
651
|
+
"apikey",
|
|
652
|
+
"authorization",
|
|
653
|
+
"bearer_token",
|
|
654
|
+
"cookie",
|
|
655
|
+
"credential",
|
|
656
|
+
"id_token",
|
|
657
|
+
"passwd",
|
|
658
|
+
"password",
|
|
659
|
+
"secret",
|
|
660
|
+
"token",
|
|
661
|
+
}
|
|
662
|
+
)
|
|
663
|
+
_SECRET_KEY_FRAGMENTS = (
|
|
664
|
+
"access_token",
|
|
665
|
+
"api_key",
|
|
666
|
+
"apikey",
|
|
667
|
+
"auth_token",
|
|
668
|
+
"client_secret",
|
|
669
|
+
"credential",
|
|
670
|
+
"passwd",
|
|
671
|
+
"password",
|
|
672
|
+
"private_key",
|
|
673
|
+
"refresh_token",
|
|
674
|
+
"secret",
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
MASKED_VALUE = "[secret]"
|
|
678
|
+
|
|
679
|
+
#: Only Alysis Code's own variables are snapshotted. The full environment is
|
|
680
|
+
#: both enormous and the exact thing that leaked a credential into a session
|
|
681
|
+
#: log before PR1 existed.
|
|
682
|
+
CONFIG_SNAPSHOT_ENV_PREFIX = "ALYSIS_"
|
|
683
|
+
MAX_SNAPSHOT_ENV_VARS = 200
|
|
684
|
+
MAX_SNAPSHOT_ENV_VALUE_CHARS = 300
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _key_is_secret(key: Any) -> bool:
|
|
688
|
+
normalized = _clean_text(key).casefold().replace("-", "_")
|
|
689
|
+
if not normalized:
|
|
690
|
+
return False
|
|
691
|
+
if normalized in _SECRET_EXACT_KEYS:
|
|
692
|
+
return True
|
|
693
|
+
return any(fragment in normalized for fragment in _SECRET_KEY_FRAGMENTS)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def scrub_config_values(value: Any, *, key: Any = None) -> Any:
|
|
697
|
+
"""Recursively mask secret-named entries in an already-dumped config tree.
|
|
698
|
+
|
|
699
|
+
Structural masking, deliberately independent of the write-path text
|
|
700
|
+
redactor: a value that never looked credential-shaped -- a short shared
|
|
701
|
+
token, a passphrase of dictionary words -- is caught by its *key* here even
|
|
702
|
+
though no entropy heuristic would flag it.
|
|
703
|
+
"""
|
|
704
|
+
if key is not None and _key_is_secret(key):
|
|
705
|
+
return MASKED_VALUE
|
|
706
|
+
if isinstance(value, Mapping):
|
|
707
|
+
return {str(k): scrub_config_values(v, key=k) for k, v in value.items()}
|
|
708
|
+
if isinstance(value, (list, tuple)):
|
|
709
|
+
return [scrub_config_values(item) for item in value]
|
|
710
|
+
if isinstance(value, (bool, int, float)) or value is None:
|
|
711
|
+
return value
|
|
712
|
+
if isinstance(value, str):
|
|
713
|
+
return value
|
|
714
|
+
return str(value)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def snapshot_environment(
|
|
718
|
+
*,
|
|
719
|
+
environ: Mapping[str, str] | None = None,
|
|
720
|
+
prefix: str = CONFIG_SNAPSHOT_ENV_PREFIX,
|
|
721
|
+
) -> dict[str, str]:
|
|
722
|
+
"""Capture the Alysis Code environment variables that are actually set.
|
|
723
|
+
|
|
724
|
+
A benchmark run configures almost everything by environment, so the file on
|
|
725
|
+
disk is not the effective configuration; without this, reconstructing a run
|
|
726
|
+
means guessing at the harness.
|
|
727
|
+
"""
|
|
728
|
+
source: Mapping[str, str] = os.environ if environ is None else environ
|
|
729
|
+
captured: dict[str, str] = {}
|
|
730
|
+
for name in sorted(source):
|
|
731
|
+
if not str(name).startswith(prefix):
|
|
732
|
+
continue
|
|
733
|
+
if _key_is_secret(name[len(prefix) :]) or _key_is_secret(name):
|
|
734
|
+
captured[str(name)] = MASKED_VALUE
|
|
735
|
+
else:
|
|
736
|
+
captured[str(name)] = _truncate(
|
|
737
|
+
_clean_text(source.get(name)),
|
|
738
|
+
MAX_SNAPSHOT_ENV_VALUE_CHARS,
|
|
739
|
+
)
|
|
740
|
+
if len(captured) >= MAX_SNAPSHOT_ENV_VARS:
|
|
741
|
+
break
|
|
742
|
+
return captured
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def config_snapshot_payload(
|
|
746
|
+
*,
|
|
747
|
+
config_values: Mapping[str, Any] | None = None,
|
|
748
|
+
version: str = "",
|
|
749
|
+
build_info: Mapping[str, Any] | None = None,
|
|
750
|
+
sampling: SamplingSettings | None = None,
|
|
751
|
+
response_header_allowlist: ResponseHeaderAllowlist | None = None,
|
|
752
|
+
environ: Mapping[str, str] | None = None,
|
|
753
|
+
) -> dict[str, Any]:
|
|
754
|
+
"""Build the once-per-run ``config_snapshot`` session-log event payload.
|
|
755
|
+
|
|
756
|
+
Takes an already-dumped config mapping (``AppConfig.model_dump()``) rather
|
|
757
|
+
than the model itself, which keeps this module free of pydantic and lets
|
|
758
|
+
the tests exercise it in a bare interpreter.
|
|
759
|
+
"""
|
|
760
|
+
allowlist = (
|
|
761
|
+
DEFAULT_RESPONSE_HEADER_ALLOWLIST
|
|
762
|
+
if response_header_allowlist is None
|
|
763
|
+
else response_header_allowlist
|
|
764
|
+
)
|
|
765
|
+
effective_sampling = resolve_sampling_settings() if sampling is None else sampling
|
|
766
|
+
return {
|
|
767
|
+
"schema_version": CONFIG_SNAPSHOT_SCHEMA_VERSION,
|
|
768
|
+
"version": _clean_text(version),
|
|
769
|
+
"build": dict(build_info or {}),
|
|
770
|
+
"sampling": effective_sampling.session_event_payload(),
|
|
771
|
+
"response_header_allowlist": allowlist.describe(),
|
|
772
|
+
"config": scrub_config_values(dict(config_values or {})),
|
|
773
|
+
"environment": snapshot_environment(environ=environ),
|
|
774
|
+
}
|