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,1699 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import warnings
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from contextvars import ContextVar
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from .failure_category import is_provider_throttling_error, is_provider_unavailable_error
|
|
19
|
+
from .llm.types import LLMResponse, LLMUsage
|
|
20
|
+
from .logging_redaction import redact_log_text
|
|
21
|
+
from .run_provenance import (
|
|
22
|
+
extract_system_fingerprint,
|
|
23
|
+
fingerprint_drift_payload,
|
|
24
|
+
resolve_response_header_allowlist,
|
|
25
|
+
response_fingerprint_payload,
|
|
26
|
+
select_response_headers,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
_MAX_HISTORY = 50
|
|
31
|
+
_HISTORY_LOCK = threading.Lock()
|
|
32
|
+
_PROVIDER_CALL_HISTORY: deque[dict[str, Any]] = deque(maxlen=_MAX_HISTORY)
|
|
33
|
+
_WEB_SEARCH_HISTORY: deque[dict[str, Any]] = deque(maxlen=_MAX_HISTORY)
|
|
34
|
+
|
|
35
|
+
# Durable, process-wide JSONL sink for provider/web-search telemetry. The in-memory
|
|
36
|
+
# deques above evaporate on process exit -- exactly when a crashed run is being
|
|
37
|
+
# investigated -- so a registered sink persists each already-redacted summary to disk
|
|
38
|
+
# for post-mortem reconstruction. Guarded by its own lock so concurrent provider calls
|
|
39
|
+
# append complete lines.
|
|
40
|
+
_SINK_LOCK = threading.Lock()
|
|
41
|
+
_TELEMETRY_SINK_PATH: Path | None = None
|
|
42
|
+
_SINK_WRITE_FAILURES = 0
|
|
43
|
+
# Resolved once per process: the allowlist is environment-driven and constant
|
|
44
|
+
# for a run, and re-parsing it on every provider call would be pure overhead.
|
|
45
|
+
_RESPONSE_HEADER_LOCK = threading.Lock()
|
|
46
|
+
_RESPONSE_HEADER_ALLOWLIST: Any = None
|
|
47
|
+
_ALYSIS_WEB_SEARCH_TOOL_NAME = "web_search"
|
|
48
|
+
_PROVIDER_OPERATION_OVERRIDE: ContextVar[str | None] = ContextVar(
|
|
49
|
+
"provider_operation_override",
|
|
50
|
+
default=None,
|
|
51
|
+
)
|
|
52
|
+
_PROVIDER_RECORD_COUNT: ContextVar[int] = ContextVar(
|
|
53
|
+
"provider_record_count",
|
|
54
|
+
default=0,
|
|
55
|
+
)
|
|
56
|
+
_CACHE_USAGE_TOTAL_FIELDS = (
|
|
57
|
+
"prompt_tokens",
|
|
58
|
+
"completion_tokens",
|
|
59
|
+
"total_tokens",
|
|
60
|
+
"cached_prompt_tokens",
|
|
61
|
+
"input_tokens_uncached",
|
|
62
|
+
"cache_read_input_tokens",
|
|
63
|
+
"cache_creation_input_tokens",
|
|
64
|
+
"cache_creation_5m_input_tokens",
|
|
65
|
+
"cache_creation_1h_input_tokens",
|
|
66
|
+
)
|
|
67
|
+
_SENSITIVE_EXACT_KEYS = {
|
|
68
|
+
"api_key",
|
|
69
|
+
"apikey",
|
|
70
|
+
"authorization",
|
|
71
|
+
"auth_token",
|
|
72
|
+
"bearer_token",
|
|
73
|
+
"credential",
|
|
74
|
+
"id_token",
|
|
75
|
+
"password",
|
|
76
|
+
"refresh_token",
|
|
77
|
+
"secret",
|
|
78
|
+
"token",
|
|
79
|
+
"x_api_key",
|
|
80
|
+
"x_goog_api_key",
|
|
81
|
+
}
|
|
82
|
+
_SENSITIVE_KEY_FRAGMENTS = ("client_secret", "private_key")
|
|
83
|
+
_SAFE_REDACTION_KEYS = {"api_key_present", "api_key_source"}
|
|
84
|
+
_HIDDEN_PAYLOAD_KEYS = {
|
|
85
|
+
"arguments",
|
|
86
|
+
"body",
|
|
87
|
+
"content",
|
|
88
|
+
"contents",
|
|
89
|
+
"headers",
|
|
90
|
+
"input",
|
|
91
|
+
"messages",
|
|
92
|
+
"parameters",
|
|
93
|
+
"provider_metadata",
|
|
94
|
+
"raw",
|
|
95
|
+
"tool_calls",
|
|
96
|
+
"tools",
|
|
97
|
+
}
|
|
98
|
+
_SECRET_VALUE_RE = re.compile(
|
|
99
|
+
r"(?i)\b(?:sk|tvly|ghp|github_pat|xoxb|xapp|ya29|AIza|key|token)[-_A-Za-z0-9]{10,}\b"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def telemetry_clock_ms() -> float:
|
|
104
|
+
return time.monotonic() * 1000.0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@contextmanager
|
|
108
|
+
def provider_telemetry_operation(operation: str):
|
|
109
|
+
"""Label provider calls made by a non-turn control-plane operation."""
|
|
110
|
+
token = _PROVIDER_OPERATION_OVERRIDE.set(str(operation or "").strip() or None)
|
|
111
|
+
try:
|
|
112
|
+
yield
|
|
113
|
+
finally:
|
|
114
|
+
_PROVIDER_OPERATION_OVERRIDE.reset(token)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def provider_telemetry_record_count() -> int:
|
|
118
|
+
"""Return this execution context's provider-record count."""
|
|
119
|
+
return _PROVIDER_RECORD_COUNT.get()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def base_url_host(base_url: str | None) -> str:
|
|
123
|
+
raw = str(base_url or "").strip()
|
|
124
|
+
if not raw:
|
|
125
|
+
return ""
|
|
126
|
+
try:
|
|
127
|
+
parsed = urlsplit(raw)
|
|
128
|
+
except Exception:
|
|
129
|
+
return ""
|
|
130
|
+
host = (parsed.hostname or "").rstrip(".").casefold()
|
|
131
|
+
return host
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def reset_provider_telemetry_for_tests() -> None:
|
|
135
|
+
global _TELEMETRY_SINK_PATH, _SINK_WRITE_FAILURES, _RESPONSE_HEADER_ALLOWLIST
|
|
136
|
+
with _HISTORY_LOCK:
|
|
137
|
+
_PROVIDER_CALL_HISTORY.clear()
|
|
138
|
+
_WEB_SEARCH_HISTORY.clear()
|
|
139
|
+
with _SINK_LOCK:
|
|
140
|
+
_TELEMETRY_SINK_PATH = None
|
|
141
|
+
_SINK_WRITE_FAILURES = 0
|
|
142
|
+
with _RESPONSE_HEADER_LOCK:
|
|
143
|
+
_RESPONSE_HEADER_ALLOWLIST = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def response_header_allowlist():
|
|
147
|
+
"""The process's response-header allowlist, resolved once from the environment."""
|
|
148
|
+
global _RESPONSE_HEADER_ALLOWLIST
|
|
149
|
+
with _RESPONSE_HEADER_LOCK:
|
|
150
|
+
if _RESPONSE_HEADER_ALLOWLIST is None:
|
|
151
|
+
_RESPONSE_HEADER_ALLOWLIST = resolve_response_header_allowlist()
|
|
152
|
+
return _RESPONSE_HEADER_ALLOWLIST
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def capture_response_headers(headers: Any) -> dict[str, str]:
|
|
156
|
+
"""Select the allowlisted response headers and redact each value.
|
|
157
|
+
|
|
158
|
+
Redaction is belt-and-braces: the allowlist is already bounded by a deny
|
|
159
|
+
list that keeps credential-bearing headers out, and both sinks redact the
|
|
160
|
+
serialized line on write. This third pass covers the in-memory history and
|
|
161
|
+
the structured log record, which are not write-path sinks and so are not
|
|
162
|
+
otherwise covered by PR1's boundary.
|
|
163
|
+
"""
|
|
164
|
+
selected = select_response_headers(headers, allowlist=response_header_allowlist())
|
|
165
|
+
return {name: redact_log_text(value) for name, value in selected.items()}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def set_provider_telemetry_sink(path: str | Path | None) -> None:
|
|
169
|
+
"""Route each recorded provider/web-search summary to a durable JSONL file.
|
|
170
|
+
|
|
171
|
+
Process-wide and idempotent (last writer wins); pass ``None`` to disable. The
|
|
172
|
+
persisted payload is already secret-redacted. This is what lets an autonomous
|
|
173
|
+
fix-loop recover the retry/throttle/latency history of a run whose process has
|
|
174
|
+
already exited.
|
|
175
|
+
"""
|
|
176
|
+
global _TELEMETRY_SINK_PATH
|
|
177
|
+
with _SINK_LOCK:
|
|
178
|
+
_TELEMETRY_SINK_PATH = Path(path) if path else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def provider_telemetry_sink_path() -> Path | None:
|
|
182
|
+
with _SINK_LOCK:
|
|
183
|
+
return _TELEMETRY_SINK_PATH
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _append_to_sink(payload: Mapping[str, Any]) -> None:
|
|
187
|
+
global _SINK_WRITE_FAILURES
|
|
188
|
+
with _SINK_LOCK:
|
|
189
|
+
path = _TELEMETRY_SINK_PATH
|
|
190
|
+
if path is None:
|
|
191
|
+
return
|
|
192
|
+
record = {"recorded_at_epoch": round(time.time(), 3), **dict(payload)}
|
|
193
|
+
line = redact_log_text(
|
|
194
|
+
json.dumps(record, ensure_ascii=True, sort_keys=True, default=str) + "\n"
|
|
195
|
+
)
|
|
196
|
+
with _SINK_LOCK:
|
|
197
|
+
try:
|
|
198
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
200
|
+
fh.write(line)
|
|
201
|
+
except Exception as exc: # noqa: BLE001 - telemetry must not crash a provider call
|
|
202
|
+
# Non-silent: a broken telemetry sink during an autonomous run must be
|
|
203
|
+
# observable rather than quietly capturing nothing.
|
|
204
|
+
_SINK_WRITE_FAILURES += 1
|
|
205
|
+
if _SINK_WRITE_FAILURES == 1:
|
|
206
|
+
warnings.warn(
|
|
207
|
+
f"provider telemetry sink write to {path} failed "
|
|
208
|
+
f"({type(exc).__name__}: {exc}); subsequent failures are counted but "
|
|
209
|
+
"not re-warned.",
|
|
210
|
+
RuntimeWarning,
|
|
211
|
+
stacklevel=2,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def provider_call_history_snapshot(*, limit: int | None = None) -> list[dict[str, Any]]:
|
|
216
|
+
with _HISTORY_LOCK:
|
|
217
|
+
items = list(_PROVIDER_CALL_HISTORY)
|
|
218
|
+
if limit is not None:
|
|
219
|
+
items = items[-max(0, int(limit)) :]
|
|
220
|
+
return [copy.deepcopy(item) for item in items]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def web_search_history_snapshot(*, limit: int | None = None) -> list[dict[str, Any]]:
|
|
224
|
+
with _HISTORY_LOCK:
|
|
225
|
+
items = list(_WEB_SEARCH_HISTORY)
|
|
226
|
+
if limit is not None:
|
|
227
|
+
items = items[-max(0, int(limit)) :]
|
|
228
|
+
return [copy.deepcopy(item) for item in items]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def last_provider_call_summary() -> dict[str, Any] | None:
|
|
232
|
+
with _HISTORY_LOCK:
|
|
233
|
+
item = _PROVIDER_CALL_HISTORY[-1] if _PROVIDER_CALL_HISTORY else None
|
|
234
|
+
return copy.deepcopy(item) if item is not None else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def last_web_search_summary() -> dict[str, Any] | None:
|
|
238
|
+
with _HISTORY_LOCK:
|
|
239
|
+
item = _WEB_SEARCH_HISTORY[-1] if _WEB_SEARCH_HISTORY else None
|
|
240
|
+
return copy.deepcopy(item) if item is not None else None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def provider_cache_effectiveness_snapshot(*, limit: int | None = None) -> dict[str, Any]:
|
|
244
|
+
return _cache_effectiveness_payload(provider_call_history_snapshot(limit=limit))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def provider_cache_diagnostics_snapshot(*, limit: int | None = None) -> dict[str, Any]:
|
|
248
|
+
return _cache_diagnostics_payload(provider_call_history_snapshot(limit=limit))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def provider_token_reconciliation_snapshot(*, limit: int | None = None) -> dict[str, Any]:
|
|
252
|
+
return _token_reconciliation_aggregate_payload(provider_call_history_snapshot(limit=limit))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def provider_fingerprint_drift_snapshot(*, limit: int | None = None) -> dict[str, Any]:
|
|
256
|
+
"""Did one requested model come back as more than one thing this run?"""
|
|
257
|
+
return fingerprint_drift_payload(provider_call_history_snapshot(limit=limit))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class ProviderCallTelemetryRecorder:
|
|
261
|
+
"""Accumulates one safe provider-call summary.
|
|
262
|
+
|
|
263
|
+
The recorder stores only derived fields: counts, booleans, host names, latency,
|
|
264
|
+
and token totals. It never stores request bodies, tool arguments, raw provider
|
|
265
|
+
payloads, or hidden provider metadata.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
*,
|
|
271
|
+
provider_key: str | None,
|
|
272
|
+
protocol: str,
|
|
273
|
+
model: str,
|
|
274
|
+
base_url: str,
|
|
275
|
+
stream: bool,
|
|
276
|
+
tools: list[dict[str, Any]] | None,
|
|
277
|
+
web_search_mode: str | None = None,
|
|
278
|
+
web_search_adapter: str | None = None,
|
|
279
|
+
native_web_search: bool = False,
|
|
280
|
+
cache_policy: Mapping[str, Any] | None = None,
|
|
281
|
+
request_plan: Mapping[str, Any] | None = None,
|
|
282
|
+
request_shape: Mapping[str, Any] | None = None,
|
|
283
|
+
token_reconciliation: Mapping[str, Any] | None = None,
|
|
284
|
+
operation: str = "chat",
|
|
285
|
+
sampling: Any | None = None,
|
|
286
|
+
) -> None:
|
|
287
|
+
self.provider_key = _safe_label(provider_key)
|
|
288
|
+
self.protocol = _safe_label(protocol)
|
|
289
|
+
self.model = _safe_label(model)
|
|
290
|
+
self.base_url_host = base_url_host(base_url)
|
|
291
|
+
self.stream = bool(stream)
|
|
292
|
+
self.operation = _safe_label(_PROVIDER_OPERATION_OVERRIDE.get() or operation)
|
|
293
|
+
self.tool_count = len([tool for tool in tools or [] if isinstance(tool, dict)])
|
|
294
|
+
self.web_search_exposed = tools_expose_web_search(tools) or bool(native_web_search)
|
|
295
|
+
self.web_search_mode = _safe_label(web_search_mode or "off")
|
|
296
|
+
self.web_search_adapter = _safe_label(web_search_adapter or "")
|
|
297
|
+
self.provider_hosted_search = bool(native_web_search)
|
|
298
|
+
self.cache_policy = _safe_cache_policy(cache_policy)
|
|
299
|
+
self.request_plan = _safe_request_plan(request_plan)
|
|
300
|
+
self.request_shape = _safe_request_shape(request_shape)
|
|
301
|
+
self.token_reconciliation = _safe_token_reconciliation(token_reconciliation)
|
|
302
|
+
self.external_search_provider = _external_search_provider(
|
|
303
|
+
exposed=self.web_search_exposed,
|
|
304
|
+
native=native_web_search,
|
|
305
|
+
adapter=web_search_adapter,
|
|
306
|
+
)
|
|
307
|
+
self.web_search_backend_kind = _chat_web_search_backend_kind(
|
|
308
|
+
exposed=self.web_search_exposed,
|
|
309
|
+
native=native_web_search,
|
|
310
|
+
mode=web_search_mode,
|
|
311
|
+
)
|
|
312
|
+
self.sampling = _safe_sampling(sampling)
|
|
313
|
+
self._started_ms = telemetry_clock_ms()
|
|
314
|
+
self._first_text_delta_ms: float | None = None
|
|
315
|
+
self._text_delta_count = 0
|
|
316
|
+
self._first_reasoning_delta_ms: float | None = None
|
|
317
|
+
self._reasoning_delta_count = 0
|
|
318
|
+
self._retry_count = 0
|
|
319
|
+
self._retry_reasons: list[str] = []
|
|
320
|
+
# Captured from the live HTTP response, so it survives into the error
|
|
321
|
+
# payload too: the request id on a 429 is often the only handle a
|
|
322
|
+
# provider will accept when asked what happened.
|
|
323
|
+
self._response_headers: dict[str, str] = {}
|
|
324
|
+
|
|
325
|
+
def wrap_text_delta(
|
|
326
|
+
self,
|
|
327
|
+
callback: Callable[[str], None] | None,
|
|
328
|
+
) -> Callable[[str], None] | None:
|
|
329
|
+
if not self.stream:
|
|
330
|
+
return callback
|
|
331
|
+
|
|
332
|
+
def _wrapped(delta: str) -> None:
|
|
333
|
+
if delta:
|
|
334
|
+
self._text_delta_count += 1
|
|
335
|
+
if self._first_text_delta_ms is None:
|
|
336
|
+
self._first_text_delta_ms = telemetry_clock_ms()
|
|
337
|
+
if callback is not None:
|
|
338
|
+
callback(delta)
|
|
339
|
+
|
|
340
|
+
return _wrapped
|
|
341
|
+
|
|
342
|
+
def wrap_reasoning_delta(
|
|
343
|
+
self,
|
|
344
|
+
callback: Callable[[str], None] | None,
|
|
345
|
+
) -> Callable[[str], None] | None:
|
|
346
|
+
if not self.stream:
|
|
347
|
+
return callback
|
|
348
|
+
|
|
349
|
+
def _wrapped(delta: str) -> None:
|
|
350
|
+
if delta:
|
|
351
|
+
self._reasoning_delta_count += 1
|
|
352
|
+
if self._first_reasoning_delta_ms is None:
|
|
353
|
+
self._first_reasoning_delta_ms = telemetry_clock_ms()
|
|
354
|
+
if callback is not None:
|
|
355
|
+
callback(delta)
|
|
356
|
+
|
|
357
|
+
return _wrapped
|
|
358
|
+
|
|
359
|
+
def set_cache_policy(self, cache_policy: Mapping[str, Any] | None) -> None:
|
|
360
|
+
self.cache_policy = _safe_cache_policy(cache_policy)
|
|
361
|
+
|
|
362
|
+
def set_request_plan(self, request_plan: Mapping[str, Any] | None) -> None:
|
|
363
|
+
self.request_plan = _safe_request_plan(request_plan)
|
|
364
|
+
|
|
365
|
+
def set_request_shape(self, request_shape: Mapping[str, Any] | None) -> None:
|
|
366
|
+
self.request_shape = _safe_request_shape(request_shape)
|
|
367
|
+
|
|
368
|
+
def set_token_reconciliation(self, token_reconciliation: Mapping[str, Any] | None) -> None:
|
|
369
|
+
self.token_reconciliation = _safe_token_reconciliation(token_reconciliation)
|
|
370
|
+
|
|
371
|
+
def set_response_headers(self, headers: Any) -> None:
|
|
372
|
+
"""Record the allowlisted provenance headers from a live HTTP response.
|
|
373
|
+
|
|
374
|
+
Never raises: an unreadable header mapping yields an empty capture, and
|
|
375
|
+
a provider call must not fail because provenance could not be collected.
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
self._response_headers = capture_response_headers(headers)
|
|
379
|
+
except Exception: # noqa: BLE001 - provenance must not break a call
|
|
380
|
+
self._response_headers = {}
|
|
381
|
+
|
|
382
|
+
def on_retry(self, attempt: int, reason: str, _wait_seconds: float) -> None:
|
|
383
|
+
try:
|
|
384
|
+
self._retry_count = max(self._retry_count, int(attempt))
|
|
385
|
+
except (TypeError, ValueError):
|
|
386
|
+
self._retry_count += 1
|
|
387
|
+
normalized_reason = _safe_label(reason)
|
|
388
|
+
if normalized_reason and normalized_reason not in self._retry_reasons:
|
|
389
|
+
self._retry_reasons.append(normalized_reason)
|
|
390
|
+
|
|
391
|
+
def run(self, call: Callable[[], LLMResponse]) -> LLMResponse:
|
|
392
|
+
try:
|
|
393
|
+
response = call()
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
self.record_error(exc)
|
|
396
|
+
raise
|
|
397
|
+
self.record_success(response)
|
|
398
|
+
return response
|
|
399
|
+
|
|
400
|
+
def record_success(self, response: LLMResponse) -> None:
|
|
401
|
+
latency_ms = _duration_ms(self._started_ms)
|
|
402
|
+
payload = self._base_payload(
|
|
403
|
+
latency_ms=latency_ms,
|
|
404
|
+
status_category="success",
|
|
405
|
+
)
|
|
406
|
+
payload["usage"] = _usage_payload(response.usage)
|
|
407
|
+
payload["token_reconciliation"] = _token_reconciliation_payload(
|
|
408
|
+
self.token_reconciliation,
|
|
409
|
+
response.usage,
|
|
410
|
+
)
|
|
411
|
+
payload["provider_metadata_present"] = bool(response.provider_metadata)
|
|
412
|
+
payload["tool_call_provider_metadata_count"] = sum(
|
|
413
|
+
1 for tool_call in response.tool_calls if tool_call.provider_metadata
|
|
414
|
+
)
|
|
415
|
+
payload["streaming"] = self._streaming_payload(
|
|
416
|
+
raw=response.raw,
|
|
417
|
+
final_latency_ms=latency_ms,
|
|
418
|
+
)
|
|
419
|
+
payload["response_fingerprint"] = self._response_fingerprint(response)
|
|
420
|
+
payload["web_search"].update(_provider_metadata_web_search_counts(response))
|
|
421
|
+
record_provider_call(payload)
|
|
422
|
+
|
|
423
|
+
def record_error(self, exc: Exception) -> None:
|
|
424
|
+
latency_ms = _duration_ms(self._started_ms)
|
|
425
|
+
failure_category = _status_category(exc)
|
|
426
|
+
payload = self._base_payload(
|
|
427
|
+
latency_ms=latency_ms,
|
|
428
|
+
status_category=("failed" if self.operation == "cache_keepalive" else failure_category),
|
|
429
|
+
)
|
|
430
|
+
if self.operation == "cache_keepalive":
|
|
431
|
+
payload["failure_category"] = failure_category
|
|
432
|
+
payload["error_type"] = type(exc).__name__
|
|
433
|
+
payload["usage"] = _usage_payload(None)
|
|
434
|
+
payload["token_reconciliation"] = _token_reconciliation_payload(
|
|
435
|
+
self.token_reconciliation,
|
|
436
|
+
None,
|
|
437
|
+
)
|
|
438
|
+
payload["provider_metadata_present"] = False
|
|
439
|
+
payload["tool_call_provider_metadata_count"] = 0
|
|
440
|
+
payload["streaming"] = self._streaming_payload(
|
|
441
|
+
raw=None,
|
|
442
|
+
final_latency_ms=latency_ms,
|
|
443
|
+
error_type=type(exc).__name__ if self.stream else None,
|
|
444
|
+
)
|
|
445
|
+
record_provider_call(payload)
|
|
446
|
+
|
|
447
|
+
def _base_payload(self, *, latency_ms: int, status_category: str) -> dict[str, Any]:
|
|
448
|
+
return {
|
|
449
|
+
"kind": "provider_call",
|
|
450
|
+
"operation": self.operation,
|
|
451
|
+
"provider_key": self.provider_key,
|
|
452
|
+
"protocol": self.protocol,
|
|
453
|
+
"model": self.model,
|
|
454
|
+
"base_url_host": self.base_url_host,
|
|
455
|
+
"stream": self.stream,
|
|
456
|
+
"tool_count": self.tool_count,
|
|
457
|
+
"web_search_exposed": self.web_search_exposed,
|
|
458
|
+
"web_search": {
|
|
459
|
+
"web_search_mode": self.web_search_mode,
|
|
460
|
+
"web_search_adapter": self.web_search_adapter,
|
|
461
|
+
"backend_kind": self.web_search_backend_kind,
|
|
462
|
+
"provider_hosted_search": self.provider_hosted_search,
|
|
463
|
+
"external_provider_name": self.external_search_provider,
|
|
464
|
+
"source_count": 0,
|
|
465
|
+
"citation_count": 0,
|
|
466
|
+
"query_count": 0,
|
|
467
|
+
"fallback_occurred": False,
|
|
468
|
+
},
|
|
469
|
+
"cache_policy": copy.deepcopy(self.cache_policy),
|
|
470
|
+
"request_plan": copy.deepcopy(self.request_plan),
|
|
471
|
+
"request_shape": copy.deepcopy(self.request_shape),
|
|
472
|
+
"token_reconciliation": _token_reconciliation_payload(
|
|
473
|
+
self.token_reconciliation,
|
|
474
|
+
None,
|
|
475
|
+
),
|
|
476
|
+
"retry_count": self._retry_count,
|
|
477
|
+
"retry_reasons": list(self._retry_reasons),
|
|
478
|
+
"status_category": status_category,
|
|
479
|
+
"latency_ms": latency_ms,
|
|
480
|
+
"sampling": copy.deepcopy(self.sampling),
|
|
481
|
+
"response_fingerprint": self._response_fingerprint(),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
def _response_fingerprint(self, response: LLMResponse | None = None) -> dict[str, Any]:
|
|
485
|
+
"""Which model actually answered, as far as the wire can tell.
|
|
486
|
+
|
|
487
|
+
Recorded on every call, successful or not, and with every field always
|
|
488
|
+
present: a run where ``system_fingerprint`` was never sent is a
|
|
489
|
+
different finding from one where it changed mid-run, and only an
|
|
490
|
+
always-present field distinguishes them.
|
|
491
|
+
"""
|
|
492
|
+
response_model = None
|
|
493
|
+
system_fingerprint = None
|
|
494
|
+
if response is not None:
|
|
495
|
+
response_model = getattr(response, "response_model", None)
|
|
496
|
+
system_fingerprint = extract_system_fingerprint(response.raw)
|
|
497
|
+
return response_fingerprint_payload(
|
|
498
|
+
response_model=_safe_label(response_model) or None,
|
|
499
|
+
system_fingerprint=_safe_label(system_fingerprint) or None,
|
|
500
|
+
headers=dict(self._response_headers),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def _streaming_payload(
|
|
504
|
+
self,
|
|
505
|
+
*,
|
|
506
|
+
raw: Mapping[str, Any] | None,
|
|
507
|
+
final_latency_ms: int,
|
|
508
|
+
error_type: str | None = None,
|
|
509
|
+
) -> dict[str, Any]:
|
|
510
|
+
if not self.stream:
|
|
511
|
+
return {
|
|
512
|
+
"enabled": False,
|
|
513
|
+
"event_count": 0,
|
|
514
|
+
"text_delta_count": 0,
|
|
515
|
+
"reasoning_delta_count": 0,
|
|
516
|
+
"first_token_latency_ms": None,
|
|
517
|
+
"first_reasoning_latency_ms": None,
|
|
518
|
+
"final_latency_ms": final_latency_ms,
|
|
519
|
+
"stream_error_type": None,
|
|
520
|
+
"unknown_event_count": 0,
|
|
521
|
+
"stream_restart_count": 0,
|
|
522
|
+
"stream_restart_reason": "",
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
"enabled": True,
|
|
526
|
+
"event_count": _stream_event_count(raw),
|
|
527
|
+
"text_delta_count": self._text_delta_count,
|
|
528
|
+
"reasoning_delta_count": self._reasoning_delta_count,
|
|
529
|
+
"first_token_latency_ms": _first_token_latency_ms(
|
|
530
|
+
started_ms=self._started_ms,
|
|
531
|
+
first_delta_ms=self._first_text_delta_ms,
|
|
532
|
+
),
|
|
533
|
+
"first_reasoning_latency_ms": _first_token_latency_ms(
|
|
534
|
+
started_ms=self._started_ms,
|
|
535
|
+
first_delta_ms=self._first_reasoning_delta_ms,
|
|
536
|
+
),
|
|
537
|
+
"final_latency_ms": final_latency_ms,
|
|
538
|
+
"stream_error_type": error_type,
|
|
539
|
+
"unknown_event_count": _stream_unknown_event_count(raw),
|
|
540
|
+
"stream_restart_count": _stream_restart_count(raw),
|
|
541
|
+
"stream_restart_reason": _stream_restart_reason(raw),
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def record_provider_call(payload: Mapping[str, Any]) -> None:
|
|
546
|
+
safe_payload = redact_telemetry_payload(payload)
|
|
547
|
+
_PROVIDER_RECORD_COUNT.set(_PROVIDER_RECORD_COUNT.get() + 1)
|
|
548
|
+
with _HISTORY_LOCK:
|
|
549
|
+
_PROVIDER_CALL_HISTORY.append(safe_payload)
|
|
550
|
+
_LOGGER.info("provider_call", extra={"alysis_provider_call": safe_payload})
|
|
551
|
+
_append_to_sink(safe_payload)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def record_web_search_call(
|
|
555
|
+
*,
|
|
556
|
+
protocol: str | None,
|
|
557
|
+
provider_key: str | None,
|
|
558
|
+
model: str | None,
|
|
559
|
+
web_search_mode: str,
|
|
560
|
+
web_search_adapter: str,
|
|
561
|
+
provider_hosted_search: bool,
|
|
562
|
+
external_provider_name: str | None,
|
|
563
|
+
source_count: int,
|
|
564
|
+
citation_count: int,
|
|
565
|
+
query_count: int,
|
|
566
|
+
fallback_occurred: bool,
|
|
567
|
+
status_category: str = "success",
|
|
568
|
+
) -> None:
|
|
569
|
+
payload = redact_telemetry_payload(
|
|
570
|
+
{
|
|
571
|
+
"kind": "web_search",
|
|
572
|
+
"protocol": _safe_label(protocol),
|
|
573
|
+
"provider_key": _safe_label(provider_key),
|
|
574
|
+
"model": _safe_label(model),
|
|
575
|
+
"web_search_mode": _safe_label(web_search_mode),
|
|
576
|
+
"web_search_adapter": _safe_label(web_search_adapter),
|
|
577
|
+
"provider_hosted_search": bool(provider_hosted_search),
|
|
578
|
+
"external_provider_name": _safe_label(external_provider_name),
|
|
579
|
+
"source_count": max(0, int(source_count)),
|
|
580
|
+
"citation_count": max(0, int(citation_count)),
|
|
581
|
+
"query_count": max(0, int(query_count)),
|
|
582
|
+
"fallback_occurred": bool(fallback_occurred),
|
|
583
|
+
"status_category": _safe_label(status_category),
|
|
584
|
+
}
|
|
585
|
+
)
|
|
586
|
+
with _HISTORY_LOCK:
|
|
587
|
+
_WEB_SEARCH_HISTORY.append(payload)
|
|
588
|
+
_LOGGER.info("web_search_call", extra={"alysis_web_search": payload})
|
|
589
|
+
_append_to_sink(payload)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def redact_telemetry_payload(payload: Any) -> Any:
|
|
593
|
+
return _redact_value(payload)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def tools_expose_web_search(tools: list[dict[str, Any]] | None) -> bool:
|
|
597
|
+
for tool in tools or []:
|
|
598
|
+
if not isinstance(tool, dict):
|
|
599
|
+
continue
|
|
600
|
+
if _tool_name(tool) == _ALYSIS_WEB_SEARCH_TOOL_NAME:
|
|
601
|
+
return True
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def diagnostic_bundle_payload(*, provider_diagnostics: Mapping[str, Any]) -> dict[str, Any]:
|
|
606
|
+
return redact_telemetry_payload(
|
|
607
|
+
{
|
|
608
|
+
"redacted": True,
|
|
609
|
+
"provider_diagnostics": dict(provider_diagnostics),
|
|
610
|
+
"last_provider_call": last_provider_call_summary(),
|
|
611
|
+
"recent_provider_calls": provider_call_history_snapshot(limit=10),
|
|
612
|
+
"cache_effectiveness": provider_cache_effectiveness_snapshot(limit=_MAX_HISTORY),
|
|
613
|
+
"cache_diagnostics": provider_cache_diagnostics_snapshot(limit=_MAX_HISTORY),
|
|
614
|
+
"token_reconciliation": provider_token_reconciliation_snapshot(limit=_MAX_HISTORY),
|
|
615
|
+
"fingerprint_drift": provider_fingerprint_drift_snapshot(limit=_MAX_HISTORY),
|
|
616
|
+
"last_web_search": last_web_search_summary(),
|
|
617
|
+
"recent_web_search_calls": web_search_history_snapshot(limit=10),
|
|
618
|
+
"notes": [
|
|
619
|
+
"Request bodies, tool arguments, secrets, raw provider payloads, and hidden provider metadata are excluded.",
|
|
620
|
+
"Provider call history is process-local and may be empty in a fresh CLI process.",
|
|
621
|
+
],
|
|
622
|
+
}
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _tool_name(tool: Mapping[str, Any]) -> str:
|
|
627
|
+
function = tool.get("function")
|
|
628
|
+
if isinstance(function, Mapping):
|
|
629
|
+
return str(function.get("name") or "").strip()
|
|
630
|
+
return str(tool.get("name") or "").strip()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _chat_web_search_backend_kind(
|
|
634
|
+
*,
|
|
635
|
+
exposed: bool,
|
|
636
|
+
native: bool,
|
|
637
|
+
mode: str | None,
|
|
638
|
+
) -> str:
|
|
639
|
+
if native:
|
|
640
|
+
return "native/provider-hosted"
|
|
641
|
+
if not exposed:
|
|
642
|
+
return "off"
|
|
643
|
+
normalized_mode = str(mode or "").strip().lower()
|
|
644
|
+
if normalized_mode == "off":
|
|
645
|
+
return "off"
|
|
646
|
+
return "external"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _external_search_provider(
|
|
650
|
+
*,
|
|
651
|
+
exposed: bool,
|
|
652
|
+
native: bool,
|
|
653
|
+
adapter: str | None,
|
|
654
|
+
) -> str | None:
|
|
655
|
+
if native or not exposed:
|
|
656
|
+
return None
|
|
657
|
+
normalized_adapter = str(adapter or "").strip().lower()
|
|
658
|
+
if normalized_adapter and normalized_adapter != "auto":
|
|
659
|
+
return normalized_adapter
|
|
660
|
+
return "alysis_web_search_tool"
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _duration_ms(started_ms: float) -> int:
|
|
664
|
+
return max(0, int(round(telemetry_clock_ms() - started_ms)))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _provider_route_group_key(call: Mapping[str, Any]) -> tuple[str, str, str, str, str]:
|
|
668
|
+
return (
|
|
669
|
+
_safe_label(call.get("provider_key")),
|
|
670
|
+
_safe_label(call.get("protocol")),
|
|
671
|
+
_safe_label(call.get("model")),
|
|
672
|
+
_safe_label(call.get("base_url_host")),
|
|
673
|
+
_safe_label(call.get("operation")),
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _cache_diagnostics_payload(calls: list[dict[str, Any]]) -> dict[str, Any]:
|
|
678
|
+
total = _empty_cache_diagnostics_bucket()
|
|
679
|
+
groups: dict[tuple[str, str, str, str, str], dict[str, Any]] = {}
|
|
680
|
+
for call in calls:
|
|
681
|
+
if not isinstance(call, Mapping):
|
|
682
|
+
continue
|
|
683
|
+
group_key = _provider_route_group_key(call)
|
|
684
|
+
group = groups.setdefault(
|
|
685
|
+
group_key,
|
|
686
|
+
{
|
|
687
|
+
"provider_key": group_key[0],
|
|
688
|
+
"protocol": group_key[1],
|
|
689
|
+
"model": group_key[2],
|
|
690
|
+
"base_url_host": group_key[3],
|
|
691
|
+
"operation": group_key[4],
|
|
692
|
+
**_empty_cache_diagnostics_bucket(),
|
|
693
|
+
},
|
|
694
|
+
)
|
|
695
|
+
_accumulate_cache_diagnostics(total, call)
|
|
696
|
+
_accumulate_cache_diagnostics(group, call)
|
|
697
|
+
return {
|
|
698
|
+
"window_call_count": len(calls),
|
|
699
|
+
"totals": _finalize_cache_diagnostics_bucket(total),
|
|
700
|
+
"by_route": [
|
|
701
|
+
_finalize_cache_diagnostics_bucket(group) for _key, group in sorted(groups.items())
|
|
702
|
+
],
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _empty_cache_diagnostics_bucket() -> dict[str, Any]:
|
|
707
|
+
return {
|
|
708
|
+
"provider_call_count": 0,
|
|
709
|
+
"cache_policy_call_count": 0,
|
|
710
|
+
"cache_enabled_call_count": 0,
|
|
711
|
+
"cache_field_emitted_call_count": 0,
|
|
712
|
+
"cache_read_call_count": 0,
|
|
713
|
+
"cache_write_call_count": 0,
|
|
714
|
+
"cache_fallback_call_count": 0,
|
|
715
|
+
"provider_rejection_or_downgrade_call_count": 0,
|
|
716
|
+
"strategy_counts": {},
|
|
717
|
+
"status_counts": {},
|
|
718
|
+
"fallback_counts": {},
|
|
719
|
+
"provider_rejection_reason_counts": {},
|
|
720
|
+
"cache_risk_reason_counts": {},
|
|
721
|
+
"compaction_trigger_reason_counts": {},
|
|
722
|
+
"tool_schema_share_sample_count": 0,
|
|
723
|
+
"tool_schema_share_total": 0.0,
|
|
724
|
+
"tool_schema_share_max": 0.0,
|
|
725
|
+
"inline_tool_transcript_share_sample_count": 0,
|
|
726
|
+
"inline_tool_transcript_share_total": 0.0,
|
|
727
|
+
"inline_tool_transcript_share_max": 0.0,
|
|
728
|
+
"token_estimate_error_sample_count": 0,
|
|
729
|
+
"sent_token_estimate_error_sample_count": 0,
|
|
730
|
+
"input_estimate_abs_error_tokens_total": 0,
|
|
731
|
+
"input_estimate_error_tokens_total": 0,
|
|
732
|
+
"sent_input_estimate_abs_error_tokens_total": 0,
|
|
733
|
+
"max_input_estimate_abs_error_tokens": 0,
|
|
734
|
+
"cache_field_emitted_rate": None,
|
|
735
|
+
"cache_read_rate": None,
|
|
736
|
+
"cache_write_rate": None,
|
|
737
|
+
"cache_fallback_rate": None,
|
|
738
|
+
"provider_rejection_or_downgrade_rate": None,
|
|
739
|
+
"tool_schema_share_average": None,
|
|
740
|
+
"inline_tool_transcript_share_average": None,
|
|
741
|
+
"mean_input_estimate_abs_error_tokens": None,
|
|
742
|
+
"mean_sent_input_estimate_abs_error_tokens": None,
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _accumulate_cache_diagnostics(bucket: dict[str, Any], call: Mapping[str, Any]) -> None:
|
|
747
|
+
bucket["provider_call_count"] += 1
|
|
748
|
+
cache_policy = call.get("cache_policy")
|
|
749
|
+
request_shape = call.get("request_shape")
|
|
750
|
+
request_plan = call.get("request_plan")
|
|
751
|
+
usage = call.get("usage") if isinstance(call.get("usage"), Mapping) else {}
|
|
752
|
+
reconciliation = (
|
|
753
|
+
call.get("token_reconciliation")
|
|
754
|
+
if isinstance(call.get("token_reconciliation"), Mapping)
|
|
755
|
+
else {}
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
if isinstance(cache_policy, Mapping):
|
|
759
|
+
bucket["cache_policy_call_count"] += 1
|
|
760
|
+
if bool(cache_policy.get("enabled")):
|
|
761
|
+
bucket["cache_enabled_call_count"] += 1
|
|
762
|
+
strategy = _safe_label(cache_policy.get("strategy"))
|
|
763
|
+
status = _safe_label(cache_policy.get("status"))
|
|
764
|
+
fallback = _safe_label(cache_policy.get("fallback"))
|
|
765
|
+
if strategy:
|
|
766
|
+
_increment_count(bucket["strategy_counts"], strategy)
|
|
767
|
+
if status:
|
|
768
|
+
_increment_count(bucket["status_counts"], status)
|
|
769
|
+
if fallback:
|
|
770
|
+
bucket["cache_fallback_call_count"] += 1
|
|
771
|
+
_increment_count(bucket["fallback_counts"], fallback)
|
|
772
|
+
if _cache_policy_provider_rejection_or_downgrade(cache_policy):
|
|
773
|
+
bucket["provider_rejection_or_downgrade_call_count"] += 1
|
|
774
|
+
for reason in _cache_policy_rejection_reasons(cache_policy):
|
|
775
|
+
_increment_count(bucket["provider_rejection_reason_counts"], reason)
|
|
776
|
+
|
|
777
|
+
if _cache_field_emitted(cache_policy, request_shape):
|
|
778
|
+
bucket["cache_field_emitted_call_count"] += 1
|
|
779
|
+
if _effective_cache_read_tokens(usage) > 0:
|
|
780
|
+
bucket["cache_read_call_count"] += 1
|
|
781
|
+
if _effective_cache_write_tokens(usage) > 0:
|
|
782
|
+
bucket["cache_write_call_count"] += 1
|
|
783
|
+
|
|
784
|
+
if isinstance(request_shape, Mapping):
|
|
785
|
+
for reason in _safe_label_list(request_shape.get("risk_reasons")):
|
|
786
|
+
_increment_count(bucket["cache_risk_reason_counts"], reason)
|
|
787
|
+
for reason in _safe_label_list(request_shape.get("compaction_trigger_reasons")):
|
|
788
|
+
_increment_count(bucket["compaction_trigger_reason_counts"], reason)
|
|
789
|
+
trigger_reason = _safe_label(request_shape.get("compaction_trigger_reason"))
|
|
790
|
+
if trigger_reason:
|
|
791
|
+
_increment_count(bucket["compaction_trigger_reason_counts"], trigger_reason)
|
|
792
|
+
_accumulate_share(
|
|
793
|
+
bucket,
|
|
794
|
+
value=request_shape.get("tool_schema_share"),
|
|
795
|
+
sample_key="tool_schema_share_sample_count",
|
|
796
|
+
total_key="tool_schema_share_total",
|
|
797
|
+
max_key="tool_schema_share_max",
|
|
798
|
+
)
|
|
799
|
+
_accumulate_share(
|
|
800
|
+
bucket,
|
|
801
|
+
value=request_shape.get("inline_tool_transcript_share"),
|
|
802
|
+
sample_key="inline_tool_transcript_share_sample_count",
|
|
803
|
+
total_key="inline_tool_transcript_share_total",
|
|
804
|
+
max_key="inline_tool_transcript_share_max",
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
if isinstance(request_plan, Mapping):
|
|
808
|
+
for reason in _safe_label_list(request_plan.get("compaction_trigger_reasons")):
|
|
809
|
+
_increment_count(bucket["compaction_trigger_reason_counts"], reason)
|
|
810
|
+
trigger_reason = _safe_label(request_plan.get("compaction_trigger_reason"))
|
|
811
|
+
if trigger_reason:
|
|
812
|
+
_increment_count(bucket["compaction_trigger_reason_counts"], trigger_reason)
|
|
813
|
+
|
|
814
|
+
if isinstance(reconciliation, Mapping):
|
|
815
|
+
abs_error = _optional_non_negative_int(
|
|
816
|
+
reconciliation.get("input_estimate_abs_error_tokens")
|
|
817
|
+
)
|
|
818
|
+
sent_abs_error = _optional_non_negative_int(
|
|
819
|
+
reconciliation.get("sent_input_estimate_abs_error_tokens")
|
|
820
|
+
)
|
|
821
|
+
if abs_error is not None:
|
|
822
|
+
bucket["token_estimate_error_sample_count"] += 1
|
|
823
|
+
bucket["input_estimate_abs_error_tokens_total"] += abs_error
|
|
824
|
+
bucket["input_estimate_error_tokens_total"] += _int_value(
|
|
825
|
+
reconciliation.get("input_estimate_error_tokens")
|
|
826
|
+
)
|
|
827
|
+
bucket["max_input_estimate_abs_error_tokens"] = max(
|
|
828
|
+
bucket["max_input_estimate_abs_error_tokens"],
|
|
829
|
+
abs_error,
|
|
830
|
+
)
|
|
831
|
+
if sent_abs_error is not None:
|
|
832
|
+
bucket["sent_token_estimate_error_sample_count"] += 1
|
|
833
|
+
bucket["sent_input_estimate_abs_error_tokens_total"] += sent_abs_error
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _finalize_cache_diagnostics_bucket(bucket: dict[str, Any]) -> dict[str, Any]:
|
|
837
|
+
finalized = copy.deepcopy(bucket)
|
|
838
|
+
provider_calls = _non_negative_int_value(finalized.get("provider_call_count"))
|
|
839
|
+
finalized["cache_field_emitted_rate"] = _rate(
|
|
840
|
+
finalized.get("cache_field_emitted_call_count"),
|
|
841
|
+
provider_calls,
|
|
842
|
+
)
|
|
843
|
+
finalized["cache_read_rate"] = _rate(finalized.get("cache_read_call_count"), provider_calls)
|
|
844
|
+
finalized["cache_write_rate"] = _rate(finalized.get("cache_write_call_count"), provider_calls)
|
|
845
|
+
finalized["cache_fallback_rate"] = _rate(
|
|
846
|
+
finalized.get("cache_fallback_call_count"),
|
|
847
|
+
provider_calls,
|
|
848
|
+
)
|
|
849
|
+
finalized["provider_rejection_or_downgrade_rate"] = _rate(
|
|
850
|
+
finalized.get("provider_rejection_or_downgrade_call_count"),
|
|
851
|
+
provider_calls,
|
|
852
|
+
)
|
|
853
|
+
finalized["tool_schema_share_average"] = _average(
|
|
854
|
+
finalized.get("tool_schema_share_total"),
|
|
855
|
+
finalized.get("tool_schema_share_sample_count"),
|
|
856
|
+
)
|
|
857
|
+
finalized["inline_tool_transcript_share_average"] = _average(
|
|
858
|
+
finalized.get("inline_tool_transcript_share_total"),
|
|
859
|
+
finalized.get("inline_tool_transcript_share_sample_count"),
|
|
860
|
+
)
|
|
861
|
+
estimate_samples = _non_negative_int_value(finalized.get("token_estimate_error_sample_count"))
|
|
862
|
+
finalized["mean_input_estimate_abs_error_tokens"] = _average(
|
|
863
|
+
finalized.get("input_estimate_abs_error_tokens_total"),
|
|
864
|
+
estimate_samples,
|
|
865
|
+
digits=2,
|
|
866
|
+
)
|
|
867
|
+
finalized["mean_sent_input_estimate_abs_error_tokens"] = _average(
|
|
868
|
+
finalized.get("sent_input_estimate_abs_error_tokens_total"),
|
|
869
|
+
finalized.get("sent_token_estimate_error_sample_count"),
|
|
870
|
+
digits=2,
|
|
871
|
+
)
|
|
872
|
+
for key in (
|
|
873
|
+
"strategy_counts",
|
|
874
|
+
"status_counts",
|
|
875
|
+
"fallback_counts",
|
|
876
|
+
"provider_rejection_reason_counts",
|
|
877
|
+
"cache_risk_reason_counts",
|
|
878
|
+
"compaction_trigger_reason_counts",
|
|
879
|
+
):
|
|
880
|
+
finalized[key] = dict(sorted(finalized[key].items()))
|
|
881
|
+
return finalized
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _cache_field_emitted(
|
|
885
|
+
cache_policy: Any,
|
|
886
|
+
request_shape: Any,
|
|
887
|
+
) -> bool:
|
|
888
|
+
if isinstance(request_shape, Mapping) and bool(request_shape.get("cache_fields_emitted")):
|
|
889
|
+
return True
|
|
890
|
+
if not isinstance(cache_policy, Mapping):
|
|
891
|
+
return False
|
|
892
|
+
emitted = cache_policy.get("emitted_fields")
|
|
893
|
+
return isinstance(emitted, (list, tuple)) and bool(emitted)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _cache_policy_provider_rejection_or_downgrade(cache_policy: Mapping[str, Any]) -> bool:
|
|
897
|
+
if _safe_label(cache_policy.get("capability_downgrade")):
|
|
898
|
+
return True
|
|
899
|
+
return bool(_cache_policy_rejection_reasons(cache_policy))
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _cache_policy_rejection_reasons(cache_policy: Mapping[str, Any]) -> list[str]:
|
|
903
|
+
reasons: list[str] = []
|
|
904
|
+
for key in ("fallback", "status"):
|
|
905
|
+
value = _safe_label(cache_policy.get(key))
|
|
906
|
+
if not value:
|
|
907
|
+
continue
|
|
908
|
+
lowered = value.casefold()
|
|
909
|
+
if any(
|
|
910
|
+
marker in lowered
|
|
911
|
+
for marker in (
|
|
912
|
+
"reject",
|
|
913
|
+
"unsupported",
|
|
914
|
+
"stripped",
|
|
915
|
+
"runtime_disabled",
|
|
916
|
+
"provider_disabled",
|
|
917
|
+
"not_supported",
|
|
918
|
+
"failed",
|
|
919
|
+
)
|
|
920
|
+
):
|
|
921
|
+
reasons.append(value)
|
|
922
|
+
downgrade = _safe_label(cache_policy.get("capability_downgrade"))
|
|
923
|
+
if downgrade:
|
|
924
|
+
reasons.append(downgrade)
|
|
925
|
+
return list(dict.fromkeys(reasons))
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _accumulate_share(
|
|
929
|
+
bucket: dict[str, Any],
|
|
930
|
+
*,
|
|
931
|
+
value: Any,
|
|
932
|
+
sample_key: str,
|
|
933
|
+
total_key: str,
|
|
934
|
+
max_key: str,
|
|
935
|
+
) -> None:
|
|
936
|
+
parsed = _optional_non_negative_float(value)
|
|
937
|
+
if parsed is None:
|
|
938
|
+
return
|
|
939
|
+
bucket[sample_key] += 1
|
|
940
|
+
bucket[total_key] += parsed
|
|
941
|
+
bucket[max_key] = max(bucket[max_key], parsed)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _rate(numerator: Any, denominator: Any) -> float | None:
|
|
945
|
+
denominator_int = _non_negative_int_value(denominator)
|
|
946
|
+
if denominator_int <= 0:
|
|
947
|
+
return None
|
|
948
|
+
return round(_non_negative_int_value(numerator) / denominator_int, 4)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _average(total: Any, count: Any, *, digits: int = 4) -> float | None:
|
|
952
|
+
count_int = _non_negative_int_value(count)
|
|
953
|
+
if count_int <= 0:
|
|
954
|
+
return None
|
|
955
|
+
try:
|
|
956
|
+
total_float = float(total)
|
|
957
|
+
except (TypeError, ValueError):
|
|
958
|
+
return None
|
|
959
|
+
return round(total_float / count_int, digits)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _int_value(value: Any) -> int:
|
|
963
|
+
try:
|
|
964
|
+
return int(value)
|
|
965
|
+
except (TypeError, ValueError):
|
|
966
|
+
return 0
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _safe_label_list(value: Any) -> list[str]:
|
|
970
|
+
if not isinstance(value, (list, tuple)):
|
|
971
|
+
return []
|
|
972
|
+
return [_safe_label(item) for item in value if _safe_label(item)]
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _cache_effectiveness_payload(calls: list[dict[str, Any]]) -> dict[str, Any]:
|
|
976
|
+
total = _empty_cache_effectiveness_bucket()
|
|
977
|
+
groups: dict[tuple[str, str, str, str], dict[str, Any]] = {}
|
|
978
|
+
for call in calls:
|
|
979
|
+
if not isinstance(call, Mapping):
|
|
980
|
+
continue
|
|
981
|
+
group_key = (
|
|
982
|
+
_safe_label(call.get("provider_key")),
|
|
983
|
+
_safe_label(call.get("protocol")),
|
|
984
|
+
_safe_label(call.get("model")),
|
|
985
|
+
_safe_label(call.get("base_url_host")),
|
|
986
|
+
)
|
|
987
|
+
group = groups.setdefault(
|
|
988
|
+
group_key,
|
|
989
|
+
{
|
|
990
|
+
"provider_key": group_key[0],
|
|
991
|
+
"protocol": group_key[1],
|
|
992
|
+
"model": group_key[2],
|
|
993
|
+
"base_url_host": group_key[3],
|
|
994
|
+
**_empty_cache_effectiveness_bucket(),
|
|
995
|
+
},
|
|
996
|
+
)
|
|
997
|
+
_accumulate_cache_effectiveness(total, call)
|
|
998
|
+
_accumulate_cache_effectiveness(group, call)
|
|
999
|
+
return {
|
|
1000
|
+
"window_call_count": len(calls),
|
|
1001
|
+
"totals": _finalize_cache_effectiveness_bucket(total),
|
|
1002
|
+
"by_provider_model": [
|
|
1003
|
+
_finalize_cache_effectiveness_bucket(group) for _key, group in sorted(groups.items())
|
|
1004
|
+
],
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _empty_cache_effectiveness_bucket() -> dict[str, Any]:
|
|
1009
|
+
return {
|
|
1010
|
+
"provider_call_count": 0,
|
|
1011
|
+
"cache_policy_call_count": 0,
|
|
1012
|
+
"cache_enabled_call_count": 0,
|
|
1013
|
+
"cache_eligible_call_count": 0,
|
|
1014
|
+
"cache_used_call_count": 0,
|
|
1015
|
+
"cache_read_call_count": 0,
|
|
1016
|
+
"cache_write_call_count": 0,
|
|
1017
|
+
"cache_fallback_call_count": 0,
|
|
1018
|
+
"cache_miss_call_count": 0,
|
|
1019
|
+
"strategy_counts": {},
|
|
1020
|
+
"status_counts": {},
|
|
1021
|
+
"fallback_counts": {},
|
|
1022
|
+
"token_totals": {
|
|
1023
|
+
**{field: 0 for field in _CACHE_USAGE_TOTAL_FIELDS},
|
|
1024
|
+
"effective_cache_read_input_tokens": 0,
|
|
1025
|
+
"effective_cache_write_input_tokens": 0,
|
|
1026
|
+
},
|
|
1027
|
+
"cache_read_ratio": None,
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _accumulate_cache_effectiveness(bucket: dict[str, Any], call: Mapping[str, Any]) -> None:
|
|
1032
|
+
bucket["provider_call_count"] += 1
|
|
1033
|
+
cache_policy = call.get("cache_policy")
|
|
1034
|
+
usage = call.get("usage") if isinstance(call.get("usage"), Mapping) else {}
|
|
1035
|
+
effective_read = _effective_cache_read_tokens(usage)
|
|
1036
|
+
effective_write = _effective_cache_write_tokens(usage)
|
|
1037
|
+
|
|
1038
|
+
if isinstance(cache_policy, Mapping):
|
|
1039
|
+
bucket["cache_policy_call_count"] += 1
|
|
1040
|
+
enabled = bool(cache_policy.get("enabled"))
|
|
1041
|
+
eligible = bool(cache_policy.get("eligible"))
|
|
1042
|
+
used = bool(cache_policy.get("used"))
|
|
1043
|
+
fallback = _safe_label(cache_policy.get("fallback"))
|
|
1044
|
+
strategy = _safe_label(cache_policy.get("strategy"))
|
|
1045
|
+
status = _safe_label(cache_policy.get("status"))
|
|
1046
|
+
if enabled:
|
|
1047
|
+
bucket["cache_enabled_call_count"] += 1
|
|
1048
|
+
if eligible:
|
|
1049
|
+
bucket["cache_eligible_call_count"] += 1
|
|
1050
|
+
if used:
|
|
1051
|
+
bucket["cache_used_call_count"] += 1
|
|
1052
|
+
if fallback:
|
|
1053
|
+
bucket["cache_fallback_call_count"] += 1
|
|
1054
|
+
_increment_count(bucket["fallback_counts"], fallback)
|
|
1055
|
+
if strategy:
|
|
1056
|
+
_increment_count(bucket["strategy_counts"], strategy)
|
|
1057
|
+
if status:
|
|
1058
|
+
_increment_count(bucket["status_counts"], status)
|
|
1059
|
+
if enabled and not used and effective_read == 0 and effective_write == 0:
|
|
1060
|
+
bucket["cache_miss_call_count"] += 1
|
|
1061
|
+
|
|
1062
|
+
if effective_read > 0:
|
|
1063
|
+
bucket["cache_read_call_count"] += 1
|
|
1064
|
+
if effective_write > 0:
|
|
1065
|
+
bucket["cache_write_call_count"] += 1
|
|
1066
|
+
|
|
1067
|
+
token_totals = bucket["token_totals"]
|
|
1068
|
+
for field in _CACHE_USAGE_TOTAL_FIELDS:
|
|
1069
|
+
token_totals[field] += _non_negative_int_value(usage.get(field))
|
|
1070
|
+
token_totals["effective_cache_read_input_tokens"] += effective_read
|
|
1071
|
+
token_totals["effective_cache_write_input_tokens"] += effective_write
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _finalize_cache_effectiveness_bucket(bucket: dict[str, Any]) -> dict[str, Any]:
|
|
1075
|
+
finalized = copy.deepcopy(bucket)
|
|
1076
|
+
token_totals = finalized.get("token_totals")
|
|
1077
|
+
if isinstance(token_totals, Mapping):
|
|
1078
|
+
prompt_tokens = _non_negative_int_value(token_totals.get("prompt_tokens"))
|
|
1079
|
+
cached_tokens = _non_negative_int_value(
|
|
1080
|
+
token_totals.get("effective_cache_read_input_tokens")
|
|
1081
|
+
)
|
|
1082
|
+
finalized["cache_read_ratio"] = (
|
|
1083
|
+
round(cached_tokens / prompt_tokens, 4) if prompt_tokens > 0 else None
|
|
1084
|
+
)
|
|
1085
|
+
finalized["strategy_counts"] = dict(sorted(finalized["strategy_counts"].items()))
|
|
1086
|
+
finalized["status_counts"] = dict(sorted(finalized["status_counts"].items()))
|
|
1087
|
+
finalized["fallback_counts"] = dict(sorted(finalized["fallback_counts"].items()))
|
|
1088
|
+
return finalized
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _effective_cache_read_tokens(usage: Mapping[str, Any]) -> int:
|
|
1092
|
+
cache_read = usage.get("cache_read_input_tokens")
|
|
1093
|
+
if cache_read is not None:
|
|
1094
|
+
return _non_negative_int_value(cache_read)
|
|
1095
|
+
return _non_negative_int_value(usage.get("cached_prompt_tokens"))
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _effective_cache_write_tokens(usage: Mapping[str, Any]) -> int:
|
|
1099
|
+
cache_creation = usage.get("cache_creation_input_tokens")
|
|
1100
|
+
if cache_creation is not None:
|
|
1101
|
+
return _non_negative_int_value(cache_creation)
|
|
1102
|
+
return _non_negative_int_value(usage.get("cache_creation_5m_input_tokens")) + (
|
|
1103
|
+
_non_negative_int_value(usage.get("cache_creation_1h_input_tokens"))
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _non_negative_int_value(value: Any) -> int:
|
|
1108
|
+
try:
|
|
1109
|
+
number = int(value)
|
|
1110
|
+
except (TypeError, ValueError):
|
|
1111
|
+
return 0
|
|
1112
|
+
return max(0, number)
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _increment_count(counts: dict[str, int], key: str) -> None:
|
|
1116
|
+
counts[key] = counts.get(key, 0) + 1
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _token_reconciliation_aggregate_payload(calls: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1120
|
+
total = _empty_token_reconciliation_bucket()
|
|
1121
|
+
groups: dict[tuple[str, str, str, str], dict[str, Any]] = {}
|
|
1122
|
+
for call in calls:
|
|
1123
|
+
if not isinstance(call, Mapping):
|
|
1124
|
+
continue
|
|
1125
|
+
group_key = (
|
|
1126
|
+
_safe_label(call.get("provider_key")),
|
|
1127
|
+
_safe_label(call.get("protocol")),
|
|
1128
|
+
_safe_label(call.get("model")),
|
|
1129
|
+
_safe_label(call.get("base_url_host")),
|
|
1130
|
+
)
|
|
1131
|
+
group = groups.setdefault(
|
|
1132
|
+
group_key,
|
|
1133
|
+
{
|
|
1134
|
+
"provider_key": group_key[0],
|
|
1135
|
+
"protocol": group_key[1],
|
|
1136
|
+
"model": group_key[2],
|
|
1137
|
+
"base_url_host": group_key[3],
|
|
1138
|
+
**_empty_token_reconciliation_bucket(),
|
|
1139
|
+
},
|
|
1140
|
+
)
|
|
1141
|
+
reconciliation = call.get("token_reconciliation")
|
|
1142
|
+
if not isinstance(reconciliation, Mapping):
|
|
1143
|
+
continue
|
|
1144
|
+
_accumulate_token_reconciliation(total, reconciliation)
|
|
1145
|
+
_accumulate_token_reconciliation(group, reconciliation)
|
|
1146
|
+
return {
|
|
1147
|
+
"window_call_count": len(calls),
|
|
1148
|
+
"totals": _finalize_token_reconciliation_bucket(total),
|
|
1149
|
+
"by_provider_model": [
|
|
1150
|
+
_finalize_token_reconciliation_bucket(group) for _key, group in sorted(groups.items())
|
|
1151
|
+
],
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _empty_token_reconciliation_bucket() -> dict[str, Any]:
|
|
1156
|
+
return {
|
|
1157
|
+
"reconciliation_call_count": 0,
|
|
1158
|
+
"reported_prompt_call_count": 0,
|
|
1159
|
+
"undercount_call_count": 0,
|
|
1160
|
+
"overcount_call_count": 0,
|
|
1161
|
+
"exact_count_call_count": 0,
|
|
1162
|
+
"input_estimate_tokens_total": 0,
|
|
1163
|
+
"sent_input_estimate_tokens_total": 0,
|
|
1164
|
+
"reported_input_estimate_tokens_total": 0,
|
|
1165
|
+
"reported_prompt_tokens_total": 0,
|
|
1166
|
+
"cached_prompt_tokens_total": 0,
|
|
1167
|
+
"input_tokens_uncached_total": 0,
|
|
1168
|
+
"input_estimate_error_tokens_total": 0,
|
|
1169
|
+
"input_estimate_abs_error_tokens_total": 0,
|
|
1170
|
+
"max_abs_error_tokens": 0,
|
|
1171
|
+
"mean_abs_error_tokens": None,
|
|
1172
|
+
"reported_to_estimate_ratio": None,
|
|
1173
|
+
"estimator_counts": {},
|
|
1174
|
+
"estimate_basis_counts": {},
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _accumulate_token_reconciliation(
|
|
1179
|
+
bucket: dict[str, Any],
|
|
1180
|
+
reconciliation: Mapping[str, Any],
|
|
1181
|
+
) -> None:
|
|
1182
|
+
input_estimate = _optional_non_negative_int(reconciliation.get("input_estimate_tokens"))
|
|
1183
|
+
if input_estimate is None:
|
|
1184
|
+
return
|
|
1185
|
+
bucket["reconciliation_call_count"] += 1
|
|
1186
|
+
bucket["input_estimate_tokens_total"] += input_estimate
|
|
1187
|
+
bucket["sent_input_estimate_tokens_total"] += _non_negative_int_value(
|
|
1188
|
+
reconciliation.get("sent_input_estimate_tokens")
|
|
1189
|
+
)
|
|
1190
|
+
estimator = _safe_label(reconciliation.get("estimator"))
|
|
1191
|
+
if estimator:
|
|
1192
|
+
_increment_count(bucket["estimator_counts"], estimator)
|
|
1193
|
+
estimate_basis = _safe_label(reconciliation.get("estimate_basis"))
|
|
1194
|
+
if estimate_basis:
|
|
1195
|
+
_increment_count(bucket["estimate_basis_counts"], estimate_basis)
|
|
1196
|
+
|
|
1197
|
+
reported = _optional_non_negative_int(reconciliation.get("reported_prompt_tokens"))
|
|
1198
|
+
if reported is None:
|
|
1199
|
+
return
|
|
1200
|
+
bucket["reported_prompt_call_count"] += 1
|
|
1201
|
+
bucket["reported_input_estimate_tokens_total"] += input_estimate
|
|
1202
|
+
bucket["reported_prompt_tokens_total"] += reported
|
|
1203
|
+
bucket["cached_prompt_tokens_total"] += _non_negative_int_value(
|
|
1204
|
+
reconciliation.get("cached_prompt_tokens")
|
|
1205
|
+
)
|
|
1206
|
+
bucket["input_tokens_uncached_total"] += _non_negative_int_value(
|
|
1207
|
+
reconciliation.get("input_tokens_uncached")
|
|
1208
|
+
)
|
|
1209
|
+
error = reported - input_estimate
|
|
1210
|
+
abs_error = abs(error)
|
|
1211
|
+
bucket["input_estimate_error_tokens_total"] += error
|
|
1212
|
+
bucket["input_estimate_abs_error_tokens_total"] += abs_error
|
|
1213
|
+
bucket["max_abs_error_tokens"] = max(bucket["max_abs_error_tokens"], abs_error)
|
|
1214
|
+
if error > 0:
|
|
1215
|
+
bucket["undercount_call_count"] += 1
|
|
1216
|
+
elif error < 0:
|
|
1217
|
+
bucket["overcount_call_count"] += 1
|
|
1218
|
+
else:
|
|
1219
|
+
bucket["exact_count_call_count"] += 1
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _finalize_token_reconciliation_bucket(bucket: dict[str, Any]) -> dict[str, Any]:
|
|
1223
|
+
finalized = copy.deepcopy(bucket)
|
|
1224
|
+
reported_calls = _non_negative_int_value(finalized.get("reported_prompt_call_count"))
|
|
1225
|
+
reported_estimate_total = _non_negative_int_value(
|
|
1226
|
+
finalized.get("reported_input_estimate_tokens_total")
|
|
1227
|
+
)
|
|
1228
|
+
reported_total = _non_negative_int_value(finalized.get("reported_prompt_tokens_total"))
|
|
1229
|
+
abs_error_total = _non_negative_int_value(
|
|
1230
|
+
finalized.get("input_estimate_abs_error_tokens_total")
|
|
1231
|
+
)
|
|
1232
|
+
finalized["mean_abs_error_tokens"] = (
|
|
1233
|
+
round(abs_error_total / reported_calls, 2) if reported_calls > 0 else None
|
|
1234
|
+
)
|
|
1235
|
+
finalized["reported_to_estimate_ratio"] = (
|
|
1236
|
+
round(reported_total / reported_estimate_total, 4) if reported_estimate_total > 0 else None
|
|
1237
|
+
)
|
|
1238
|
+
finalized["estimator_counts"] = dict(sorted(finalized["estimator_counts"].items()))
|
|
1239
|
+
finalized["estimate_basis_counts"] = dict(sorted(finalized["estimate_basis_counts"].items()))
|
|
1240
|
+
return finalized
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _token_reconciliation_payload(
|
|
1244
|
+
reconciliation: Mapping[str, Any] | None,
|
|
1245
|
+
usage: LLMUsage | None,
|
|
1246
|
+
) -> dict[str, Any] | None:
|
|
1247
|
+
safe = _safe_token_reconciliation(reconciliation)
|
|
1248
|
+
if safe is None and usage is None:
|
|
1249
|
+
return None
|
|
1250
|
+
payload = copy.deepcopy(safe or {})
|
|
1251
|
+
reported_prompt_tokens = usage.prompt_tokens if usage is not None else None
|
|
1252
|
+
cached_prompt_tokens = usage.cached_prompt_tokens if usage is not None else None
|
|
1253
|
+
input_tokens_uncached = usage.input_tokens_uncached if usage is not None else None
|
|
1254
|
+
cache_read_input_tokens = usage.cache_read_input_tokens if usage is not None else None
|
|
1255
|
+
payload["reported_prompt_tokens"] = reported_prompt_tokens
|
|
1256
|
+
payload["cached_prompt_tokens"] = cached_prompt_tokens
|
|
1257
|
+
payload["input_tokens_uncached"] = input_tokens_uncached
|
|
1258
|
+
payload["cache_read_input_tokens"] = cache_read_input_tokens
|
|
1259
|
+
input_estimate = _optional_non_negative_int(payload.get("input_estimate_tokens"))
|
|
1260
|
+
sent_input_estimate = _optional_non_negative_int(payload.get("sent_input_estimate_tokens"))
|
|
1261
|
+
reported = _optional_non_negative_int(reported_prompt_tokens)
|
|
1262
|
+
if input_estimate is not None and reported is not None:
|
|
1263
|
+
error = reported - input_estimate
|
|
1264
|
+
payload["input_estimate_error_tokens"] = error
|
|
1265
|
+
payload["input_estimate_abs_error_tokens"] = abs(error)
|
|
1266
|
+
payload["input_estimate_error_ratio"] = (
|
|
1267
|
+
round(reported / input_estimate, 4) if input_estimate > 0 else None
|
|
1268
|
+
)
|
|
1269
|
+
if sent_input_estimate is not None and reported is not None:
|
|
1270
|
+
error = reported - sent_input_estimate
|
|
1271
|
+
payload["sent_input_estimate_error_tokens"] = error
|
|
1272
|
+
payload["sent_input_estimate_abs_error_tokens"] = abs(error)
|
|
1273
|
+
payload["sent_input_estimate_error_ratio"] = (
|
|
1274
|
+
round(reported / sent_input_estimate, 4) if sent_input_estimate > 0 else None
|
|
1275
|
+
)
|
|
1276
|
+
return payload or None
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _safe_token_reconciliation(payload: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
1280
|
+
if not isinstance(payload, Mapping):
|
|
1281
|
+
return None
|
|
1282
|
+
safe: dict[str, Any] = {}
|
|
1283
|
+
for key in ("estimator", "estimate_basis", "input_mode"):
|
|
1284
|
+
value = payload.get(key)
|
|
1285
|
+
if value is not None:
|
|
1286
|
+
safe[key] = _safe_label(str(value))
|
|
1287
|
+
for key in ("input_estimate_tokens", "sent_input_estimate_tokens"):
|
|
1288
|
+
value = _optional_non_negative_int(payload.get(key))
|
|
1289
|
+
if value is not None:
|
|
1290
|
+
safe[key] = value
|
|
1291
|
+
return safe or None
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _optional_non_negative_int(value: Any) -> int | None:
|
|
1295
|
+
try:
|
|
1296
|
+
number = int(value)
|
|
1297
|
+
except (TypeError, ValueError):
|
|
1298
|
+
return None
|
|
1299
|
+
if number < 0:
|
|
1300
|
+
return None
|
|
1301
|
+
return number
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _optional_non_negative_float(value: Any) -> float | None:
|
|
1305
|
+
try:
|
|
1306
|
+
number = float(value)
|
|
1307
|
+
except (TypeError, ValueError):
|
|
1308
|
+
return None
|
|
1309
|
+
if number < 0:
|
|
1310
|
+
return None
|
|
1311
|
+
return number
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _safe_sampling(sampling: Any) -> dict[str, Any]:
|
|
1315
|
+
"""Normalize a ``SamplingSettings`` (or a mapping, or nothing) into a record.
|
|
1316
|
+
|
|
1317
|
+
Unlike the other ``_safe_*`` helpers this never returns ``None``. "No
|
|
1318
|
+
sampling controls were configured" is precisely the finding when two runs
|
|
1319
|
+
of one build diverge, so it belongs in every provider-call row as an
|
|
1320
|
+
explicit ``configured: false`` rather than as an absent key that a later
|
|
1321
|
+
reader has to interpret.
|
|
1322
|
+
"""
|
|
1323
|
+
payload: Any = None
|
|
1324
|
+
if sampling is not None:
|
|
1325
|
+
getter = getattr(sampling, "telemetry_payload", None)
|
|
1326
|
+
if callable(getter):
|
|
1327
|
+
try:
|
|
1328
|
+
payload = getter()
|
|
1329
|
+
except Exception: # noqa: BLE001 - telemetry must not break a call
|
|
1330
|
+
payload = None
|
|
1331
|
+
elif isinstance(sampling, Mapping):
|
|
1332
|
+
payload = dict(sampling)
|
|
1333
|
+
if not isinstance(payload, Mapping):
|
|
1334
|
+
payload = {
|
|
1335
|
+
"configured": False,
|
|
1336
|
+
"temperature": None,
|
|
1337
|
+
"top_p": None,
|
|
1338
|
+
"seed": None,
|
|
1339
|
+
"sources": {},
|
|
1340
|
+
}
|
|
1341
|
+
return dict(payload)
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _safe_cache_policy(policy: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
1345
|
+
if not isinstance(policy, Mapping):
|
|
1346
|
+
return None
|
|
1347
|
+
safe: dict[str, Any] = {}
|
|
1348
|
+
for key in (
|
|
1349
|
+
"strategy",
|
|
1350
|
+
"mode",
|
|
1351
|
+
"ttl",
|
|
1352
|
+
"retention",
|
|
1353
|
+
"status",
|
|
1354
|
+
"fallback",
|
|
1355
|
+
"capability_downgrade",
|
|
1356
|
+
"source",
|
|
1357
|
+
"capability_source",
|
|
1358
|
+
"usage_schema",
|
|
1359
|
+
"refresh_reason",
|
|
1360
|
+
"delete_status",
|
|
1361
|
+
"prompt_cache_key_hash",
|
|
1362
|
+
):
|
|
1363
|
+
value = policy.get(key)
|
|
1364
|
+
if value is not None:
|
|
1365
|
+
safe[key] = _safe_label(str(value))
|
|
1366
|
+
for key in (
|
|
1367
|
+
"allowed_fields",
|
|
1368
|
+
"emitted_fields",
|
|
1369
|
+
"trusted_usage_fields",
|
|
1370
|
+
"warnings",
|
|
1371
|
+
"disabled_fields",
|
|
1372
|
+
"runtime_disabled_fields",
|
|
1373
|
+
"eviction_reasons",
|
|
1374
|
+
):
|
|
1375
|
+
value = policy.get(key)
|
|
1376
|
+
if isinstance(value, (list, tuple)):
|
|
1377
|
+
safe[key] = [_safe_label(str(item)) for item in value if str(item).strip()]
|
|
1378
|
+
for key in (
|
|
1379
|
+
"enabled",
|
|
1380
|
+
"eligible",
|
|
1381
|
+
"used",
|
|
1382
|
+
"emits_request_fields",
|
|
1383
|
+
"explicit_block_used",
|
|
1384
|
+
"top_level_cache_control_used",
|
|
1385
|
+
):
|
|
1386
|
+
if key in policy:
|
|
1387
|
+
safe[key] = bool(policy.get(key))
|
|
1388
|
+
for key in (
|
|
1389
|
+
"min_tokens",
|
|
1390
|
+
"cacheable_prefix_estimated_tokens",
|
|
1391
|
+
"explicit_block_count",
|
|
1392
|
+
"entry_count",
|
|
1393
|
+
"max_entries",
|
|
1394
|
+
"ttl_seconds",
|
|
1395
|
+
"refresh_margin_seconds",
|
|
1396
|
+
"refresh_in_seconds",
|
|
1397
|
+
"expires_in_seconds",
|
|
1398
|
+
"cache_age_seconds",
|
|
1399
|
+
"cached_content_estimated_tokens",
|
|
1400
|
+
"created_entry_count",
|
|
1401
|
+
"reused_entry_count",
|
|
1402
|
+
"evicted_entry_count",
|
|
1403
|
+
"delete_attempt_count",
|
|
1404
|
+
"delete_success_count",
|
|
1405
|
+
"delete_failure_count",
|
|
1406
|
+
):
|
|
1407
|
+
value = policy.get(key)
|
|
1408
|
+
try:
|
|
1409
|
+
number = int(value)
|
|
1410
|
+
except (TypeError, ValueError):
|
|
1411
|
+
continue
|
|
1412
|
+
if number >= 0:
|
|
1413
|
+
safe[key] = number
|
|
1414
|
+
return safe or None
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _safe_request_shape(shape: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
1418
|
+
if not isinstance(shape, Mapping):
|
|
1419
|
+
return None
|
|
1420
|
+
safe: dict[str, Any] = {}
|
|
1421
|
+
for key in ("input_mode", "cache_strategy", "cache_status", "compaction_trigger_reason"):
|
|
1422
|
+
value = shape.get(key)
|
|
1423
|
+
if value is not None:
|
|
1424
|
+
safe[key] = _safe_label(str(value))
|
|
1425
|
+
for key in (
|
|
1426
|
+
"cache_enabled",
|
|
1427
|
+
"cache_eligible",
|
|
1428
|
+
"cache_used",
|
|
1429
|
+
"cache_fields_emitted",
|
|
1430
|
+
"top_level_cache_control_present",
|
|
1431
|
+
"cached_content_attached",
|
|
1432
|
+
"affinity_field_emitted",
|
|
1433
|
+
"cacheable_prefix_present",
|
|
1434
|
+
):
|
|
1435
|
+
if key in shape:
|
|
1436
|
+
safe[key] = bool(shape.get(key))
|
|
1437
|
+
for key in (
|
|
1438
|
+
"schema_version",
|
|
1439
|
+
"message_count",
|
|
1440
|
+
"tool_count",
|
|
1441
|
+
"system_message_count",
|
|
1442
|
+
"developer_message_count",
|
|
1443
|
+
"user_message_count",
|
|
1444
|
+
"assistant_message_count",
|
|
1445
|
+
"tool_message_count",
|
|
1446
|
+
"tool_call_message_count",
|
|
1447
|
+
"content_block_count",
|
|
1448
|
+
"cache_control_block_count",
|
|
1449
|
+
"explicit_cache_control_block_count",
|
|
1450
|
+
"cacheable_prefix_message_count",
|
|
1451
|
+
"cacheable_prefix_estimated_tokens",
|
|
1452
|
+
"cacheable_surface_estimated_tokens",
|
|
1453
|
+
"min_cacheable_tokens",
|
|
1454
|
+
"total_estimated_tokens",
|
|
1455
|
+
):
|
|
1456
|
+
value = _optional_non_negative_int(shape.get(key))
|
|
1457
|
+
if value is not None:
|
|
1458
|
+
safe[key] = value
|
|
1459
|
+
for key in ("tool_schema_share", "inline_tool_transcript_share"):
|
|
1460
|
+
value = _optional_non_negative_float(shape.get(key))
|
|
1461
|
+
if value is not None:
|
|
1462
|
+
safe[key] = round(value, 4)
|
|
1463
|
+
for key in ("emitted_cache_fields", "risk_reasons", "compaction_trigger_reasons"):
|
|
1464
|
+
value = shape.get(key)
|
|
1465
|
+
if isinstance(value, (list, tuple)):
|
|
1466
|
+
safe[key] = [_safe_label(str(item)) for item in value if str(item).strip()]
|
|
1467
|
+
breakdown = shape.get("token_breakdown")
|
|
1468
|
+
if isinstance(breakdown, Mapping):
|
|
1469
|
+
safe_breakdown: dict[str, int] = {}
|
|
1470
|
+
for key in (
|
|
1471
|
+
"bootstrap_prompt_tokens",
|
|
1472
|
+
"tool_schema_tokens",
|
|
1473
|
+
"live_conversation_history_tokens",
|
|
1474
|
+
"inline_tool_transcript_tokens",
|
|
1475
|
+
"memory_summary_tokens",
|
|
1476
|
+
"pins_tokens",
|
|
1477
|
+
"total_tokens",
|
|
1478
|
+
):
|
|
1479
|
+
value = _optional_non_negative_int(breakdown.get(key))
|
|
1480
|
+
if value is not None:
|
|
1481
|
+
safe_breakdown[key] = value
|
|
1482
|
+
if safe_breakdown:
|
|
1483
|
+
safe["token_breakdown"] = safe_breakdown
|
|
1484
|
+
return safe or None
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def _safe_request_plan(plan: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
1488
|
+
if not isinstance(plan, Mapping):
|
|
1489
|
+
return None
|
|
1490
|
+
safe: dict[str, Any] = {}
|
|
1491
|
+
for key in (
|
|
1492
|
+
"input_mode",
|
|
1493
|
+
"status",
|
|
1494
|
+
"fallback",
|
|
1495
|
+
"continuation_strategy",
|
|
1496
|
+
"cache_strategy",
|
|
1497
|
+
"cache_mode",
|
|
1498
|
+
"cacheable_prefix_hash",
|
|
1499
|
+
"request_messages_signature",
|
|
1500
|
+
"tool_schema_hash",
|
|
1501
|
+
"compaction_trigger_reason",
|
|
1502
|
+
):
|
|
1503
|
+
value = plan.get(key)
|
|
1504
|
+
if value is not None:
|
|
1505
|
+
safe[key] = _safe_label(str(value))
|
|
1506
|
+
for key in ("compaction_trigger_reasons",):
|
|
1507
|
+
value = plan.get(key)
|
|
1508
|
+
if isinstance(value, (list, tuple)):
|
|
1509
|
+
safe[key] = [_safe_label(str(item)) for item in value if str(item).strip()]
|
|
1510
|
+
for key in ("previous_response_id_used", "fallback_used", "stream"):
|
|
1511
|
+
if key in plan:
|
|
1512
|
+
safe[key] = bool(plan.get(key))
|
|
1513
|
+
for key in (
|
|
1514
|
+
"schema_version",
|
|
1515
|
+
"message_count",
|
|
1516
|
+
"request_message_count",
|
|
1517
|
+
"tool_count",
|
|
1518
|
+
"stable_prefix_message_count",
|
|
1519
|
+
"dynamic_suffix_message_count",
|
|
1520
|
+
"provider_metadata_message_count",
|
|
1521
|
+
"stable_prefix_estimated_tokens",
|
|
1522
|
+
"dynamic_suffix_estimated_tokens",
|
|
1523
|
+
"tool_schema_tokens",
|
|
1524
|
+
"total_estimated_tokens",
|
|
1525
|
+
"serialized_request_estimate_tokens",
|
|
1526
|
+
"sent_serialized_request_estimate_tokens",
|
|
1527
|
+
"full_input_item_count",
|
|
1528
|
+
"sent_input_item_count",
|
|
1529
|
+
"continuation_anchor_index",
|
|
1530
|
+
"resent_stable_instruction_count",
|
|
1531
|
+
):
|
|
1532
|
+
value = plan.get(key)
|
|
1533
|
+
try:
|
|
1534
|
+
number = int(value)
|
|
1535
|
+
except (TypeError, ValueError):
|
|
1536
|
+
continue
|
|
1537
|
+
if number >= 0:
|
|
1538
|
+
safe[key] = number
|
|
1539
|
+
return safe or None
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _first_token_latency_ms(
|
|
1543
|
+
*,
|
|
1544
|
+
started_ms: float,
|
|
1545
|
+
first_delta_ms: float | None,
|
|
1546
|
+
) -> int | None:
|
|
1547
|
+
if first_delta_ms is None:
|
|
1548
|
+
return None
|
|
1549
|
+
return max(0, int(round(first_delta_ms - started_ms)))
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _usage_payload(usage: LLMUsage | None) -> dict[str, int | None]:
|
|
1553
|
+
return {
|
|
1554
|
+
"prompt_tokens": usage.prompt_tokens if usage is not None else None,
|
|
1555
|
+
"completion_tokens": usage.completion_tokens if usage is not None else None,
|
|
1556
|
+
"total_tokens": usage.total_tokens if usage is not None else None,
|
|
1557
|
+
"cached_prompt_tokens": usage.cached_prompt_tokens if usage is not None else None,
|
|
1558
|
+
"input_tokens_uncached": usage.input_tokens_uncached if usage is not None else None,
|
|
1559
|
+
"cache_read_input_tokens": usage.cache_read_input_tokens if usage is not None else None,
|
|
1560
|
+
"cache_creation_input_tokens": (
|
|
1561
|
+
usage.cache_creation_input_tokens if usage is not None else None
|
|
1562
|
+
),
|
|
1563
|
+
"cache_creation_5m_input_tokens": (
|
|
1564
|
+
usage.cache_creation_5m_input_tokens if usage is not None else None
|
|
1565
|
+
),
|
|
1566
|
+
"cache_creation_1h_input_tokens": (
|
|
1567
|
+
usage.cache_creation_1h_input_tokens if usage is not None else None
|
|
1568
|
+
),
|
|
1569
|
+
"reasoning_tokens": usage.reasoning_tokens if usage is not None else None,
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def _status_category(exc: Exception) -> str:
|
|
1574
|
+
if is_provider_throttling_error(exc):
|
|
1575
|
+
return "rate_limited"
|
|
1576
|
+
if is_provider_unavailable_error(exc):
|
|
1577
|
+
return "provider_unavailable"
|
|
1578
|
+
lowered = str(exc).lower()
|
|
1579
|
+
if any(token in lowered for token in ("timeout", "connect", "network", "dns")):
|
|
1580
|
+
return "network_error"
|
|
1581
|
+
return "provider_error"
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def _stream_event_count(raw: Mapping[str, Any] | None) -> int:
|
|
1585
|
+
if not isinstance(raw, Mapping):
|
|
1586
|
+
return 0
|
|
1587
|
+
direct = raw.get("events")
|
|
1588
|
+
if isinstance(direct, int):
|
|
1589
|
+
return max(0, direct)
|
|
1590
|
+
stream_metadata = raw.get("stream_metadata")
|
|
1591
|
+
if isinstance(stream_metadata, Mapping):
|
|
1592
|
+
events = stream_metadata.get("events")
|
|
1593
|
+
if isinstance(events, int):
|
|
1594
|
+
return max(0, events)
|
|
1595
|
+
gemini_metadata = raw.get("streamMetadata")
|
|
1596
|
+
if isinstance(gemini_metadata, Mapping):
|
|
1597
|
+
chunks = gemini_metadata.get("chunks")
|
|
1598
|
+
if isinstance(chunks, int):
|
|
1599
|
+
return max(0, chunks)
|
|
1600
|
+
return 0
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def _stream_unknown_event_count(raw: Mapping[str, Any] | None) -> int:
|
|
1604
|
+
if not isinstance(raw, Mapping):
|
|
1605
|
+
return 0
|
|
1606
|
+
stream_metadata = raw.get("stream_metadata")
|
|
1607
|
+
if isinstance(stream_metadata, Mapping):
|
|
1608
|
+
unknown = stream_metadata.get("unknown_events")
|
|
1609
|
+
if isinstance(unknown, list):
|
|
1610
|
+
return len(unknown)
|
|
1611
|
+
gemini_metadata = raw.get("streamMetadata")
|
|
1612
|
+
if isinstance(gemini_metadata, Mapping):
|
|
1613
|
+
unknown_chunks = gemini_metadata.get("unknown_chunks")
|
|
1614
|
+
if isinstance(unknown_chunks, list):
|
|
1615
|
+
return len(unknown_chunks)
|
|
1616
|
+
return 0
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _stream_restart_count(raw: Mapping[str, Any] | None) -> int:
|
|
1620
|
+
if not isinstance(raw, Mapping):
|
|
1621
|
+
return 0
|
|
1622
|
+
try:
|
|
1623
|
+
return max(0, int(raw.get("stream_restart_count") or 0))
|
|
1624
|
+
except (TypeError, ValueError):
|
|
1625
|
+
return 0
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _stream_restart_reason(raw: Mapping[str, Any] | None) -> str:
|
|
1629
|
+
if not isinstance(raw, Mapping):
|
|
1630
|
+
return ""
|
|
1631
|
+
return _safe_label(raw.get("stream_restart_reason"))
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def _provider_metadata_web_search_counts(response: LLMResponse) -> dict[str, int]:
|
|
1635
|
+
metadata = response.provider_metadata if isinstance(response.provider_metadata, Mapping) else {}
|
|
1636
|
+
return {
|
|
1637
|
+
"source_count": _count_list_keys(metadata, {"sources", "groundingChunks"}),
|
|
1638
|
+
"citation_count": _count_list_keys(
|
|
1639
|
+
metadata,
|
|
1640
|
+
{"citations", "groundingSupports", "citationMetadata"},
|
|
1641
|
+
),
|
|
1642
|
+
"query_count": _count_list_keys(metadata, {"queries", "webSearchQueries"}),
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def _count_list_keys(value: Any, keys: set[str]) -> int:
|
|
1647
|
+
if isinstance(value, Mapping):
|
|
1648
|
+
count = 0
|
|
1649
|
+
for key, item in value.items():
|
|
1650
|
+
if str(key) in keys and isinstance(item, list):
|
|
1651
|
+
count += len(item)
|
|
1652
|
+
else:
|
|
1653
|
+
count += _count_list_keys(item, keys)
|
|
1654
|
+
return count
|
|
1655
|
+
if isinstance(value, list):
|
|
1656
|
+
return sum(_count_list_keys(item, keys) for item in value)
|
|
1657
|
+
return 0
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def _safe_label(value: Any) -> str:
|
|
1661
|
+
text = str(value or "").strip()
|
|
1662
|
+
if not text:
|
|
1663
|
+
return ""
|
|
1664
|
+
return _redact_string(text)
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def _redact_value(value: Any, *, key: str | None = None) -> Any:
|
|
1668
|
+
normalized_key = str(key or "").strip().casefold().replace("-", "_")
|
|
1669
|
+
if normalized_key:
|
|
1670
|
+
if normalized_key not in _SAFE_REDACTION_KEYS and (
|
|
1671
|
+
normalized_key in _SENSITIVE_EXACT_KEYS
|
|
1672
|
+
or normalized_key.endswith("_api_key")
|
|
1673
|
+
or any(fragment in normalized_key for fragment in _SENSITIVE_KEY_FRAGMENTS)
|
|
1674
|
+
):
|
|
1675
|
+
return "[redacted]"
|
|
1676
|
+
if normalized_key in _HIDDEN_PAYLOAD_KEYS:
|
|
1677
|
+
return "[omitted]"
|
|
1678
|
+
if isinstance(value, Mapping):
|
|
1679
|
+
return {str(k): _redact_value(v, key=str(k)) for k, v in value.items()}
|
|
1680
|
+
if isinstance(value, list):
|
|
1681
|
+
return [_redact_value(item) for item in value]
|
|
1682
|
+
if isinstance(value, tuple):
|
|
1683
|
+
return [_redact_value(item) for item in value]
|
|
1684
|
+
if isinstance(value, str):
|
|
1685
|
+
return _redact_string(value)
|
|
1686
|
+
if isinstance(value, (bool, int, float)) or value is None:
|
|
1687
|
+
return value
|
|
1688
|
+
try:
|
|
1689
|
+
json.dumps(value)
|
|
1690
|
+
except TypeError:
|
|
1691
|
+
return _redact_string(repr(value))
|
|
1692
|
+
return value
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def _redact_string(value: str) -> str:
|
|
1696
|
+
text = str(value)
|
|
1697
|
+
if not text:
|
|
1698
|
+
return text
|
|
1699
|
+
return _SECRET_VALUE_RE.sub("[redacted]", text)
|