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,2288 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ..error_text import sanitize_error_text_for_output
|
|
13
|
+
from ..provider_telemetry import ProviderCallTelemetryRecorder
|
|
14
|
+
from ..request_estimation import estimate_provider_payload_tokens
|
|
15
|
+
from ..web_search_adapters import ANTHROPIC_MESSAGES_ADAPTER, AUTO_WEB_SEARCH_ADAPTER
|
|
16
|
+
from .cache_capabilities import CACHE_CONTROL_FIELD
|
|
17
|
+
from .cache_control_blocks import (
|
|
18
|
+
count_cache_control_blocks,
|
|
19
|
+
count_explicit_cache_control_blocks,
|
|
20
|
+
explicit_cache_control_payloads,
|
|
21
|
+
strip_cache_control_blocks,
|
|
22
|
+
)
|
|
23
|
+
from .cache_policy import merge_cache_policy_metadata
|
|
24
|
+
from .metadata import (
|
|
25
|
+
ANTHROPIC_MESSAGES_PROVIDER_METADATA_KEY,
|
|
26
|
+
PROVIDER_METADATA_KEY,
|
|
27
|
+
ProviderRouteIdentity,
|
|
28
|
+
build_provider_route_identity,
|
|
29
|
+
canonicalize_extra_headers,
|
|
30
|
+
credential_scope_fingerprint,
|
|
31
|
+
gate_messages_for_provider_route,
|
|
32
|
+
merge_canonical_headers,
|
|
33
|
+
stamp_response_for_route,
|
|
34
|
+
)
|
|
35
|
+
from .provider_limits import (
|
|
36
|
+
DEFAULT_PROVIDER_CONCURRENCY_CAPS,
|
|
37
|
+
ProviderRetrySettings,
|
|
38
|
+
best_effort_provider_key,
|
|
39
|
+
mark_provider_call_non_retryable,
|
|
40
|
+
run_provider_limited_call,
|
|
41
|
+
)
|
|
42
|
+
from .request_plan import LLMRequestPlan, RequestCachePlan
|
|
43
|
+
from .request_shape import build_request_shape_report
|
|
44
|
+
from .streaming import SSEFrame, iter_sse_frames, parse_sse_json_frame
|
|
45
|
+
from .temperature_compat import documented_temperature_omit_reason
|
|
46
|
+
from .types import (
|
|
47
|
+
InputTokenCount,
|
|
48
|
+
LLMError,
|
|
49
|
+
LLMResponse,
|
|
50
|
+
LLMUsage,
|
|
51
|
+
ReasoningOutput,
|
|
52
|
+
ReasoningOutputKind,
|
|
53
|
+
ToolCall,
|
|
54
|
+
UsageConfidence,
|
|
55
|
+
UsageContract,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
_DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
|
|
59
|
+
ANTHROPIC_MESSAGES_ROUTE_REVISION = _DEFAULT_ANTHROPIC_VERSION
|
|
60
|
+
_DEFAULT_ACCEPT_ENCODING = "identity"
|
|
61
|
+
_ANTHROPIC_METADATA_KEY = ANTHROPIC_MESSAGES_PROVIDER_METADATA_KEY
|
|
62
|
+
_ALYSIS_WEB_SEARCH_FUNCTION_NAME = "web_search"
|
|
63
|
+
_ANTHROPIC_WEB_SEARCH_TOOL_TYPE = "web_search_20260209"
|
|
64
|
+
_ANTHROPIC_WEB_SEARCH_TOOL_TYPES = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"web_search_20250305",
|
|
67
|
+
"web_search_20260209",
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
_WEB_SEARCH_MODES_ALLOWING_ANTHROPIC_BUILTIN = frozenset({"auto", "native"})
|
|
71
|
+
_ANTHROPIC_CACHE_CONTROL_TTLS = frozenset({"5m", "1h"})
|
|
72
|
+
_ANTHROPIC_MAX_CACHE_CONTROL_BREAKPOINTS = 4
|
|
73
|
+
_ANTHROPIC_MIN_MANUAL_THINKING_BUDGET = 1024
|
|
74
|
+
_ANTHROPIC_MANUAL_THINKING_BUDGETS = {
|
|
75
|
+
"minimal": 1024,
|
|
76
|
+
"low": 1024,
|
|
77
|
+
"medium": 4096,
|
|
78
|
+
"high": 8192,
|
|
79
|
+
"xhigh": 16384,
|
|
80
|
+
"max": 32768,
|
|
81
|
+
}
|
|
82
|
+
_ANTHROPIC_API_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
|
|
83
|
+
_CLAUDE_MODEL_VERSION_RE = re.compile(
|
|
84
|
+
r"(?:^|[/.:_-])claude[-_.](?P<family>opus|sonnet|haiku|fable|mythos)"
|
|
85
|
+
r"[-_.](?P<major>\d+)(?:[-_.](?P<minor>\d+))?"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class _ClaudeModelVersion:
|
|
91
|
+
family: str
|
|
92
|
+
major: int
|
|
93
|
+
minor: int | None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class _AnthropicThinkingPlan:
|
|
98
|
+
config: dict[str, Any] | None
|
|
99
|
+
output_effort: str | None
|
|
100
|
+
active: bool
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _claude_model_version(model: str) -> _ClaudeModelVersion | None:
|
|
104
|
+
normalized = str(model or "").strip().casefold()
|
|
105
|
+
match = _CLAUDE_MODEL_VERSION_RE.search(normalized)
|
|
106
|
+
if match is None:
|
|
107
|
+
return None
|
|
108
|
+
minor_text = match.group("minor")
|
|
109
|
+
minor = int(minor_text) if minor_text is not None else None
|
|
110
|
+
# Dated snapshots such as claude-opus-4-20250514 are Claude 4.0, not
|
|
111
|
+
# version 4.20-million. Keep the same distinction as temperature_compat.
|
|
112
|
+
if minor is not None and minor >= 100:
|
|
113
|
+
minor = None
|
|
114
|
+
return _ClaudeModelVersion(
|
|
115
|
+
family=match.group("family"),
|
|
116
|
+
major=int(match.group("major")),
|
|
117
|
+
minor=minor,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _uses_adaptive_thinking(model: str) -> bool:
|
|
122
|
+
normalized = str(model or "").strip().casefold()
|
|
123
|
+
if "claude-mythos-preview" in normalized:
|
|
124
|
+
return True
|
|
125
|
+
version = _claude_model_version(model)
|
|
126
|
+
if version is None:
|
|
127
|
+
return False
|
|
128
|
+
if version.family in {"fable", "mythos"}:
|
|
129
|
+
return version.major >= 5
|
|
130
|
+
if version.family not in {"opus", "sonnet"}:
|
|
131
|
+
return False
|
|
132
|
+
return version.major >= 5 or (
|
|
133
|
+
version.major == 4 and version.minor is not None and version.minor >= 6
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _supports_disabled_thinking(model: str) -> bool:
|
|
138
|
+
normalized = str(model or "").strip().casefold()
|
|
139
|
+
if "claude-mythos-preview" in normalized:
|
|
140
|
+
return False
|
|
141
|
+
version = _claude_model_version(model)
|
|
142
|
+
if version is None:
|
|
143
|
+
return True
|
|
144
|
+
return not (version.family in {"fable", "mythos"} and version.major >= 5)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _thinking_enabled_by_default(model: str) -> bool:
|
|
148
|
+
"""Models whose documented default already includes adaptive thinking.
|
|
149
|
+
|
|
150
|
+
Opus joined the 5-generation families here: on Opus 5 an omitted ``thinking``
|
|
151
|
+
field runs adaptive, where on Opus 4.8/4.7 the same request did not think at
|
|
152
|
+
all. Reporting that honestly matters for output budgeting — ``max_tokens``
|
|
153
|
+
caps thinking and answer text together.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
normalized = str(model or "").strip().casefold()
|
|
157
|
+
if "claude-mythos-preview" in normalized:
|
|
158
|
+
return True
|
|
159
|
+
version = _claude_model_version(model)
|
|
160
|
+
if version is None or version.major < 5:
|
|
161
|
+
return False
|
|
162
|
+
return version.family in {"fable", "mythos", "opus", "sonnet"}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _supports_output_effort(model: str) -> bool:
|
|
166
|
+
normalized = str(model or "").strip().casefold()
|
|
167
|
+
if "claude-mythos-preview" in normalized:
|
|
168
|
+
return True
|
|
169
|
+
version = _claude_model_version(model)
|
|
170
|
+
if version is None:
|
|
171
|
+
return False
|
|
172
|
+
if version.family in {"fable", "mythos"}:
|
|
173
|
+
return version.major >= 5
|
|
174
|
+
if version.family == "opus":
|
|
175
|
+
return version.major >= 5 or (
|
|
176
|
+
version.major == 4 and version.minor is not None and version.minor >= 5
|
|
177
|
+
)
|
|
178
|
+
if version.family == "sonnet":
|
|
179
|
+
return version.major >= 5 or (
|
|
180
|
+
version.major == 4 and version.minor is not None and version.minor >= 6
|
|
181
|
+
)
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _supports_xhigh_effort(model: str) -> bool:
|
|
186
|
+
version = _claude_model_version(model)
|
|
187
|
+
if version is None:
|
|
188
|
+
return False
|
|
189
|
+
if version.family in {"fable", "mythos"}:
|
|
190
|
+
return version.major >= 5
|
|
191
|
+
if version.family == "opus":
|
|
192
|
+
return version.major >= 5 or (
|
|
193
|
+
version.major == 4 and version.minor is not None and version.minor >= 7
|
|
194
|
+
)
|
|
195
|
+
return version.family == "sonnet" and version.major >= 5
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _supports_max_effort(model: str) -> bool:
|
|
199
|
+
normalized = str(model or "").strip().casefold()
|
|
200
|
+
return "claude-mythos-preview" in normalized or _uses_adaptive_thinking(model)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _manual_thinking_budget(*, effort: str | None, max_tokens: int) -> int:
|
|
204
|
+
if max_tokens <= _ANTHROPIC_MIN_MANUAL_THINKING_BUDGET:
|
|
205
|
+
raise LLMError("Anthropic manual thinking requires max_tokens greater than 1024")
|
|
206
|
+
requested = _ANTHROPIC_MANUAL_THINKING_BUDGETS.get(effort or "high", 8192)
|
|
207
|
+
# Preserve useful answer headroom where possible while satisfying the API's
|
|
208
|
+
# strict budget_tokens < max_tokens constraint for smaller output limits.
|
|
209
|
+
reserve = 1024 if max_tokens >= 2048 else 1
|
|
210
|
+
return max(
|
|
211
|
+
_ANTHROPIC_MIN_MANUAL_THINKING_BUDGET,
|
|
212
|
+
min(requested, max_tokens - reserve),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _anthropic_thinking_plan(
|
|
217
|
+
*,
|
|
218
|
+
model: str,
|
|
219
|
+
enable_thinking: bool | None,
|
|
220
|
+
reasoning_effort: str | None,
|
|
221
|
+
max_tokens: int,
|
|
222
|
+
request_summary: bool,
|
|
223
|
+
) -> _AnthropicThinkingPlan:
|
|
224
|
+
effort = str(reasoning_effort or "").strip().casefold() or None
|
|
225
|
+
if effort == "ultra":
|
|
226
|
+
raise LLMError(
|
|
227
|
+
"Anthropic Messages does not support reasoning_effort='ultra'; use xhigh or max"
|
|
228
|
+
)
|
|
229
|
+
if effort not in {None, "none", *_ANTHROPIC_MANUAL_THINKING_BUDGETS}:
|
|
230
|
+
raise LLMError(f"Anthropic Messages reasoning_effort is not supported: {effort}")
|
|
231
|
+
|
|
232
|
+
if enable_thinking is False or effort == "none":
|
|
233
|
+
if not _supports_disabled_thinking(model):
|
|
234
|
+
raise LLMError(f"Anthropic model {model!r} does not support disabling thinking")
|
|
235
|
+
# output_effort stays None on purpose: Opus 5 accepts disabled thinking
|
|
236
|
+
# only at effort 'high' or below, and rejects it outright at xhigh/max.
|
|
237
|
+
# Emitting no effort leaves the server default (high), which is the
|
|
238
|
+
# documented way to keep a thinking-off route legal. Do not pass the
|
|
239
|
+
# caller's effort through here.
|
|
240
|
+
return _AnthropicThinkingPlan(
|
|
241
|
+
config={"type": "disabled"},
|
|
242
|
+
output_effort=None,
|
|
243
|
+
active=False,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
explicitly_active = enable_thinking is True or effort is not None
|
|
247
|
+
if not explicitly_active:
|
|
248
|
+
# Auto/default is intentionally provider-owned. Trace visibility must
|
|
249
|
+
# never switch model reasoning on by itself. For models that already
|
|
250
|
+
# think by default, requesting ``display=summarized`` changes visibility
|
|
251
|
+
# only; the provider still owns effort and whether a simple turn thinks.
|
|
252
|
+
default_active = _thinking_enabled_by_default(model)
|
|
253
|
+
return _AnthropicThinkingPlan(
|
|
254
|
+
config=(
|
|
255
|
+
{"type": "adaptive", "display": "summarized"}
|
|
256
|
+
if default_active and request_summary
|
|
257
|
+
else None
|
|
258
|
+
),
|
|
259
|
+
output_effort=None,
|
|
260
|
+
active=default_active,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
version = _claude_model_version(model)
|
|
264
|
+
if version is None and "claude-mythos-preview" not in str(model).casefold():
|
|
265
|
+
raise LLMError(
|
|
266
|
+
f"Anthropic Messages cannot safely select a thinking mode for model {model!r}"
|
|
267
|
+
)
|
|
268
|
+
if version is not None and version.major < 4:
|
|
269
|
+
raise LLMError(f"Anthropic model {model!r} does not support extended thinking")
|
|
270
|
+
|
|
271
|
+
display = "summarized" if request_summary else "omitted"
|
|
272
|
+
if _uses_adaptive_thinking(model):
|
|
273
|
+
config: dict[str, Any] = {"type": "adaptive", "display": display}
|
|
274
|
+
else:
|
|
275
|
+
config = {
|
|
276
|
+
"type": "enabled",
|
|
277
|
+
"budget_tokens": _manual_thinking_budget(
|
|
278
|
+
effort=effort,
|
|
279
|
+
max_tokens=max_tokens,
|
|
280
|
+
),
|
|
281
|
+
"display": display,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
output_effort: str | None = None
|
|
285
|
+
if effort is not None and _supports_output_effort(model):
|
|
286
|
+
if effort not in _ANTHROPIC_API_EFFORTS:
|
|
287
|
+
raise LLMError(f"Anthropic Messages reasoning_effort is not supported: {effort}")
|
|
288
|
+
if effort == "xhigh" and not _supports_xhigh_effort(model):
|
|
289
|
+
raise LLMError(f"Anthropic model {model!r} does not support xhigh effort")
|
|
290
|
+
if effort == "max" and not _supports_max_effort(model):
|
|
291
|
+
raise LLMError(f"Anthropic model {model!r} does not support max effort")
|
|
292
|
+
output_effort = effort
|
|
293
|
+
return _AnthropicThinkingPlan(
|
|
294
|
+
config=config,
|
|
295
|
+
output_effort=output_effort,
|
|
296
|
+
active=True,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _headers_with_default_accept_encoding(headers: dict[str, str]) -> dict[str, str]:
|
|
301
|
+
request_headers = dict(headers)
|
|
302
|
+
if not any(key.lower() == "accept-encoding" for key in request_headers):
|
|
303
|
+
request_headers["accept-encoding"] = _DEFAULT_ACCEPT_ENCODING
|
|
304
|
+
return request_headers
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _non_negative_int(value: Any) -> int | None:
|
|
308
|
+
try:
|
|
309
|
+
parsed = int(value)
|
|
310
|
+
except (TypeError, ValueError):
|
|
311
|
+
return None
|
|
312
|
+
return parsed if parsed >= 0 else None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _normalize_prompt_cache_control_ttl(value: str | None) -> str:
|
|
316
|
+
normalized = str(value or "").strip().lower() or "5m"
|
|
317
|
+
return normalized if normalized in _ANTHROPIC_CACHE_CONTROL_TTLS else "5m"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _response_with_cache_policy_metadata(
|
|
321
|
+
response: LLMResponse,
|
|
322
|
+
cache_policy: dict[str, Any] | None,
|
|
323
|
+
request_plan_metadata: dict[str, Any] | None = None,
|
|
324
|
+
) -> LLMResponse:
|
|
325
|
+
if not cache_policy and not request_plan_metadata:
|
|
326
|
+
return response
|
|
327
|
+
provider_metadata = copy.deepcopy(response.provider_metadata) or {}
|
|
328
|
+
anthropic_metadata = provider_metadata.setdefault(_ANTHROPIC_METADATA_KEY, {})
|
|
329
|
+
if isinstance(anthropic_metadata, dict):
|
|
330
|
+
if cache_policy:
|
|
331
|
+
anthropic_metadata["cache_policy"] = copy.deepcopy(cache_policy)
|
|
332
|
+
if request_plan_metadata:
|
|
333
|
+
anthropic_metadata["request_plan"] = copy.deepcopy(request_plan_metadata)
|
|
334
|
+
return LLMResponse(
|
|
335
|
+
content=response.content,
|
|
336
|
+
tool_calls=response.tool_calls,
|
|
337
|
+
raw=response.raw,
|
|
338
|
+
response_model=response.response_model,
|
|
339
|
+
usage=response.usage,
|
|
340
|
+
provider_metadata=provider_metadata,
|
|
341
|
+
reasoning=response.reasoning,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _content_to_text(raw: Any) -> str:
|
|
346
|
+
if raw is None:
|
|
347
|
+
return ""
|
|
348
|
+
if isinstance(raw, str):
|
|
349
|
+
return raw
|
|
350
|
+
if isinstance(raw, list):
|
|
351
|
+
parts: list[str] = []
|
|
352
|
+
for item in raw:
|
|
353
|
+
if isinstance(item, str):
|
|
354
|
+
parts.append(item)
|
|
355
|
+
elif isinstance(item, dict):
|
|
356
|
+
text = item.get("text") or item.get("content")
|
|
357
|
+
if isinstance(text, str):
|
|
358
|
+
parts.append(text)
|
|
359
|
+
return "".join(parts)
|
|
360
|
+
if isinstance(raw, dict):
|
|
361
|
+
text = raw.get("text") or raw.get("content")
|
|
362
|
+
return text if isinstance(text, str) else json.dumps(raw, ensure_ascii=False)
|
|
363
|
+
return str(raw)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _json_arguments(args: Any) -> dict[str, Any]:
|
|
367
|
+
if isinstance(args, dict):
|
|
368
|
+
return dict(args)
|
|
369
|
+
if args is None:
|
|
370
|
+
return {}
|
|
371
|
+
if isinstance(args, str):
|
|
372
|
+
try:
|
|
373
|
+
parsed = json.loads(args)
|
|
374
|
+
except json.JSONDecodeError:
|
|
375
|
+
return {"_raw_arguments": args}
|
|
376
|
+
if isinstance(parsed, dict):
|
|
377
|
+
return parsed
|
|
378
|
+
return {"_raw_arguments": args}
|
|
379
|
+
return {"_raw_arguments": json.dumps(args, ensure_ascii=False)}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _anthropic_blocks_from_content(raw: Any, *, role: str) -> list[dict[str, Any]]:
|
|
383
|
+
if raw is None:
|
|
384
|
+
return []
|
|
385
|
+
if isinstance(raw, str):
|
|
386
|
+
return [{"type": "text", "text": raw}] if raw else []
|
|
387
|
+
if not isinstance(raw, list):
|
|
388
|
+
text = _content_to_text(raw)
|
|
389
|
+
return [{"type": "text", "text": text}] if text else []
|
|
390
|
+
|
|
391
|
+
blocks: list[dict[str, Any]] = []
|
|
392
|
+
for item in raw:
|
|
393
|
+
if isinstance(item, str):
|
|
394
|
+
if item:
|
|
395
|
+
blocks.append({"type": "text", "text": item})
|
|
396
|
+
continue
|
|
397
|
+
if not isinstance(item, dict):
|
|
398
|
+
continue
|
|
399
|
+
block_type = str(item.get("type") or "").strip()
|
|
400
|
+
text = item.get("text") or item.get("content")
|
|
401
|
+
if block_type in {"text", "input_text", "output_text"} and isinstance(text, str):
|
|
402
|
+
block = {"type": "text", "text": text}
|
|
403
|
+
cache_control = item.get(CACHE_CONTROL_FIELD)
|
|
404
|
+
if isinstance(cache_control, dict):
|
|
405
|
+
block[CACHE_CONTROL_FIELD] = copy.deepcopy(cache_control)
|
|
406
|
+
blocks.append(block)
|
|
407
|
+
continue
|
|
408
|
+
if role == "user" and block_type == "image_url":
|
|
409
|
+
image_url = item.get("image_url")
|
|
410
|
+
url = ""
|
|
411
|
+
if isinstance(image_url, dict):
|
|
412
|
+
url = str(image_url.get("url") or "").strip()
|
|
413
|
+
elif isinstance(image_url, str):
|
|
414
|
+
url = image_url.strip()
|
|
415
|
+
if url:
|
|
416
|
+
blocks.append({"type": "image", "source": {"type": "url", "url": url}})
|
|
417
|
+
continue
|
|
418
|
+
if block_type in {
|
|
419
|
+
"text",
|
|
420
|
+
"image",
|
|
421
|
+
"tool_result",
|
|
422
|
+
"tool_use",
|
|
423
|
+
"server_tool_use",
|
|
424
|
+
"web_search_tool_result",
|
|
425
|
+
}:
|
|
426
|
+
blocks.append(copy.deepcopy(item))
|
|
427
|
+
return blocks
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _function_name_from_tool(tool: dict[str, Any]) -> str:
|
|
431
|
+
function = tool.get("function")
|
|
432
|
+
if isinstance(function, dict):
|
|
433
|
+
return str(function.get("name") or "").strip()
|
|
434
|
+
if str(tool.get("type") or "") == "function":
|
|
435
|
+
return str(tool.get("name") or "").strip()
|
|
436
|
+
return ""
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _is_alysis_web_search_function(tool: dict[str, Any]) -> bool:
|
|
440
|
+
return _function_name_from_tool(tool) == _ALYSIS_WEB_SEARCH_FUNCTION_NAME
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _is_anthropic_hosted_web_search_tool(tool: dict[str, Any]) -> bool:
|
|
444
|
+
return str(tool.get("type") or "").strip() in _ANTHROPIC_WEB_SEARCH_TOOL_TYPES
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _anthropic_builtin_web_search_allowed(*, mode: str, adapter: str) -> bool:
|
|
448
|
+
normalized_mode = str(mode or "").strip().lower()
|
|
449
|
+
normalized_adapter = str(adapter or "").strip().lower() or AUTO_WEB_SEARCH_ADAPTER
|
|
450
|
+
if normalized_mode not in _WEB_SEARCH_MODES_ALLOWING_ANTHROPIC_BUILTIN:
|
|
451
|
+
return False
|
|
452
|
+
return normalized_adapter in {AUTO_WEB_SEARCH_ADAPTER, ANTHROPIC_MESSAGES_ADAPTER}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass(frozen=True)
|
|
456
|
+
class _AnthropicToolMapping:
|
|
457
|
+
tools: list[dict[str, Any]]
|
|
458
|
+
added_builtin_web_search: bool
|
|
459
|
+
removed_alysis_web_search: bool
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _anthropic_tool_from_chat_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
|
|
463
|
+
if not isinstance(tool, dict):
|
|
464
|
+
return None
|
|
465
|
+
tool_type = str(tool.get("type") or "").strip()
|
|
466
|
+
if tool_type == "function":
|
|
467
|
+
function = tool.get("function")
|
|
468
|
+
source = function if isinstance(function, dict) else tool
|
|
469
|
+
name = str(source.get("name") or "").strip()
|
|
470
|
+
if not name:
|
|
471
|
+
return None
|
|
472
|
+
mapped: dict[str, Any] = {
|
|
473
|
+
"name": name,
|
|
474
|
+
"input_schema": copy.deepcopy(source.get("parameters") or {"type": "object"}),
|
|
475
|
+
}
|
|
476
|
+
description = str(source.get("description") or "").strip()
|
|
477
|
+
if description:
|
|
478
|
+
mapped["description"] = description
|
|
479
|
+
return mapped
|
|
480
|
+
if _is_anthropic_hosted_web_search_tool(tool):
|
|
481
|
+
return copy.deepcopy(tool)
|
|
482
|
+
raise LLMError(f"Anthropic Messages does not support tool type {tool_type!r}")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _anthropic_tools(
|
|
486
|
+
tools: list[dict[str, Any]] | None,
|
|
487
|
+
*,
|
|
488
|
+
mode: str,
|
|
489
|
+
adapter: str,
|
|
490
|
+
) -> _AnthropicToolMapping:
|
|
491
|
+
normalized_mode = str(mode or "off").strip().lower()
|
|
492
|
+
normalized_adapter = (
|
|
493
|
+
str(adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower() or AUTO_WEB_SEARCH_ADAPTER
|
|
494
|
+
)
|
|
495
|
+
raw_tools = [tool for tool in tools or [] if isinstance(tool, dict)]
|
|
496
|
+
alysis_web_search_present = any(_is_alysis_web_search_function(tool) for tool in raw_tools)
|
|
497
|
+
use_builtin_web_search = alysis_web_search_present and _anthropic_builtin_web_search_allowed(
|
|
498
|
+
mode=normalized_mode,
|
|
499
|
+
adapter=normalized_adapter,
|
|
500
|
+
)
|
|
501
|
+
if normalized_mode == "native" and alysis_web_search_present and not use_builtin_web_search:
|
|
502
|
+
raise LLMError(
|
|
503
|
+
"web_search_mode=native with protocol=anthropic_messages requires "
|
|
504
|
+
"web_search_adapter='auto' or 'anthropic_messages' for Anthropic hosted web_search; "
|
|
505
|
+
f"got {normalized_adapter!r}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
mapped_tools: list[dict[str, Any]] = []
|
|
509
|
+
removed_alysis_web_search = False
|
|
510
|
+
for tool in raw_tools:
|
|
511
|
+
if _is_alysis_web_search_function(tool):
|
|
512
|
+
if normalized_mode in {"off", "native"} or use_builtin_web_search:
|
|
513
|
+
removed_alysis_web_search = True
|
|
514
|
+
continue
|
|
515
|
+
if _is_anthropic_hosted_web_search_tool(tool) and normalized_mode in {"off", "external"}:
|
|
516
|
+
continue
|
|
517
|
+
mapped = _anthropic_tool_from_chat_tool(tool)
|
|
518
|
+
if mapped is not None:
|
|
519
|
+
mapped_tools.append(mapped)
|
|
520
|
+
|
|
521
|
+
if use_builtin_web_search and not any(
|
|
522
|
+
_is_anthropic_hosted_web_search_tool(tool) for tool in mapped_tools
|
|
523
|
+
):
|
|
524
|
+
mapped_tools.append(
|
|
525
|
+
{
|
|
526
|
+
"type": _ANTHROPIC_WEB_SEARCH_TOOL_TYPE,
|
|
527
|
+
"name": _ALYSIS_WEB_SEARCH_FUNCTION_NAME,
|
|
528
|
+
"max_uses": 5,
|
|
529
|
+
}
|
|
530
|
+
)
|
|
531
|
+
return _AnthropicToolMapping(
|
|
532
|
+
tools=mapped_tools,
|
|
533
|
+
added_builtin_web_search=use_builtin_web_search,
|
|
534
|
+
removed_alysis_web_search=removed_alysis_web_search,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _anthropic_tool_choice(
|
|
539
|
+
tool_choice: Any,
|
|
540
|
+
*,
|
|
541
|
+
removed_alysis_web_search: bool,
|
|
542
|
+
added_builtin_web_search: bool,
|
|
543
|
+
) -> dict[str, Any] | None:
|
|
544
|
+
if tool_choice is None:
|
|
545
|
+
return None
|
|
546
|
+
if isinstance(tool_choice, str):
|
|
547
|
+
normalized = tool_choice.strip()
|
|
548
|
+
if normalized == "auto":
|
|
549
|
+
return {"type": "auto"}
|
|
550
|
+
if normalized == "none":
|
|
551
|
+
return {"type": "none"}
|
|
552
|
+
if normalized == "required":
|
|
553
|
+
return {"type": "any"}
|
|
554
|
+
raise LLMError(f"Anthropic Messages does not support tool_choice={tool_choice!r}")
|
|
555
|
+
if not isinstance(tool_choice, dict):
|
|
556
|
+
raise LLMError("Anthropic Messages tool_choice must be a string or object")
|
|
557
|
+
|
|
558
|
+
choice_type = str(tool_choice.get("type") or "").strip()
|
|
559
|
+
if choice_type == "function":
|
|
560
|
+
if "name" in tool_choice:
|
|
561
|
+
name = str(tool_choice.get("name") or "").strip()
|
|
562
|
+
else:
|
|
563
|
+
function = tool_choice.get("function")
|
|
564
|
+
name = str(function.get("name") or "").strip() if isinstance(function, dict) else ""
|
|
565
|
+
if not name:
|
|
566
|
+
raise LLMError("Anthropic Messages forced function tool_choice is missing name")
|
|
567
|
+
if (
|
|
568
|
+
name == _ALYSIS_WEB_SEARCH_FUNCTION_NAME
|
|
569
|
+
and removed_alysis_web_search
|
|
570
|
+
and not added_builtin_web_search
|
|
571
|
+
):
|
|
572
|
+
raise LLMError(
|
|
573
|
+
"Anthropic Messages removed the Alysis Code web_search function for the selected "
|
|
574
|
+
"web_search_mode; do not force tool_choice to function web_search"
|
|
575
|
+
)
|
|
576
|
+
return {"type": "tool", "name": name}
|
|
577
|
+
if choice_type in {"auto", "any", "none"}:
|
|
578
|
+
return {"type": choice_type}
|
|
579
|
+
if choice_type == "tool":
|
|
580
|
+
name = str(tool_choice.get("name") or "").strip()
|
|
581
|
+
if not name:
|
|
582
|
+
raise LLMError("Anthropic Messages forced tool_choice is missing name")
|
|
583
|
+
return {"type": "tool", "name": name}
|
|
584
|
+
raise LLMError(f"Anthropic Messages does not support tool_choice type {choice_type!r}")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _metadata_content_blocks(message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
588
|
+
metadata = message.get(PROVIDER_METADATA_KEY)
|
|
589
|
+
if not isinstance(metadata, dict):
|
|
590
|
+
return []
|
|
591
|
+
anthropic_metadata = metadata.get(_ANTHROPIC_METADATA_KEY)
|
|
592
|
+
if not isinstance(anthropic_metadata, dict):
|
|
593
|
+
return []
|
|
594
|
+
blocks = anthropic_metadata.get("content_blocks")
|
|
595
|
+
if not isinstance(blocks, list):
|
|
596
|
+
return []
|
|
597
|
+
copied: list[dict[str, Any]] = []
|
|
598
|
+
for block in blocks:
|
|
599
|
+
if isinstance(block, dict):
|
|
600
|
+
copied.append(copy.deepcopy(block))
|
|
601
|
+
return copied
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _tool_use_blocks_from_tool_calls(message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
605
|
+
blocks: list[dict[str, Any]] = []
|
|
606
|
+
raw_tool_calls = message.get("tool_calls")
|
|
607
|
+
if not isinstance(raw_tool_calls, list):
|
|
608
|
+
return blocks
|
|
609
|
+
for raw_tool_call in raw_tool_calls:
|
|
610
|
+
if not isinstance(raw_tool_call, dict):
|
|
611
|
+
continue
|
|
612
|
+
call_id = str(raw_tool_call.get("id") or raw_tool_call.get("call_id") or "").strip()
|
|
613
|
+
function = raw_tool_call.get("function")
|
|
614
|
+
if isinstance(function, dict):
|
|
615
|
+
name = str(function.get("name") or "").strip()
|
|
616
|
+
arguments = _json_arguments(function.get("arguments"))
|
|
617
|
+
else:
|
|
618
|
+
name = str(raw_tool_call.get("name") or "").strip()
|
|
619
|
+
arguments = _json_arguments(raw_tool_call.get("arguments"))
|
|
620
|
+
if not name:
|
|
621
|
+
continue
|
|
622
|
+
if not call_id:
|
|
623
|
+
call_id = f"toolu_{name}"
|
|
624
|
+
blocks.append(
|
|
625
|
+
{
|
|
626
|
+
"type": "tool_use",
|
|
627
|
+
"id": call_id,
|
|
628
|
+
"name": name,
|
|
629
|
+
"input": arguments,
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
return blocks
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _is_tool_result_user_message(message: dict[str, Any]) -> bool:
|
|
636
|
+
if str(message.get("role") or "") != "user":
|
|
637
|
+
return False
|
|
638
|
+
content = message.get("content")
|
|
639
|
+
return (
|
|
640
|
+
isinstance(content, list)
|
|
641
|
+
and bool(content)
|
|
642
|
+
and all(isinstance(block, dict) and block.get("type") == "tool_result" for block in content)
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _anthropic_messages_from_messages(
|
|
647
|
+
messages: list[dict[str, Any]],
|
|
648
|
+
) -> tuple[str | list[dict[str, Any]] | None, list[dict[str, Any]]]:
|
|
649
|
+
system_parts: list[str] = []
|
|
650
|
+
system_blocks: list[dict[str, Any]] = []
|
|
651
|
+
anthropic_messages: list[dict[str, Any]] = []
|
|
652
|
+
|
|
653
|
+
def _flush_system_parts() -> None:
|
|
654
|
+
if not system_parts:
|
|
655
|
+
return
|
|
656
|
+
text = "\n\n".join(system_parts).strip()
|
|
657
|
+
system_parts.clear()
|
|
658
|
+
if text:
|
|
659
|
+
system_blocks.append({"type": "text", "text": text})
|
|
660
|
+
|
|
661
|
+
for message in messages:
|
|
662
|
+
if not isinstance(message, dict):
|
|
663
|
+
continue
|
|
664
|
+
role = str(message.get("role") or "").strip()
|
|
665
|
+
if role in {"system", "developer"}:
|
|
666
|
+
content = message.get("content")
|
|
667
|
+
if isinstance(content, list) and count_cache_control_blocks(content) > 0:
|
|
668
|
+
_flush_system_parts()
|
|
669
|
+
system_blocks.extend(_anthropic_blocks_from_content(content, role="system"))
|
|
670
|
+
continue
|
|
671
|
+
text = _content_to_text(content).strip()
|
|
672
|
+
if text:
|
|
673
|
+
if system_blocks:
|
|
674
|
+
system_blocks.append({"type": "text", "text": text})
|
|
675
|
+
else:
|
|
676
|
+
system_parts.append(text)
|
|
677
|
+
continue
|
|
678
|
+
if role == "user":
|
|
679
|
+
blocks = _anthropic_blocks_from_content(message.get("content"), role="user")
|
|
680
|
+
if blocks:
|
|
681
|
+
anthropic_messages.append({"role": "user", "content": blocks})
|
|
682
|
+
continue
|
|
683
|
+
if role == "assistant":
|
|
684
|
+
metadata_blocks = _metadata_content_blocks(message)
|
|
685
|
+
if metadata_blocks:
|
|
686
|
+
anthropic_messages.append({"role": "assistant", "content": metadata_blocks})
|
|
687
|
+
continue
|
|
688
|
+
blocks = _anthropic_blocks_from_content(message.get("content"), role="assistant")
|
|
689
|
+
blocks.extend(_tool_use_blocks_from_tool_calls(message))
|
|
690
|
+
if blocks:
|
|
691
|
+
anthropic_messages.append({"role": "assistant", "content": blocks})
|
|
692
|
+
continue
|
|
693
|
+
if role == "tool":
|
|
694
|
+
tool_use_id = str(message.get("tool_call_id") or message.get("call_id") or "").strip()
|
|
695
|
+
if not tool_use_id:
|
|
696
|
+
raise LLMError("Anthropic Messages tool result is missing tool_call_id")
|
|
697
|
+
tool_result_block = {
|
|
698
|
+
"type": "tool_result",
|
|
699
|
+
"tool_use_id": tool_use_id,
|
|
700
|
+
"content": _content_to_text(message.get("content")),
|
|
701
|
+
}
|
|
702
|
+
if anthropic_messages and _is_tool_result_user_message(anthropic_messages[-1]):
|
|
703
|
+
content = anthropic_messages[-1].setdefault("content", [])
|
|
704
|
+
if isinstance(content, list):
|
|
705
|
+
content.append(tool_result_block)
|
|
706
|
+
else:
|
|
707
|
+
anthropic_messages.append({"role": "user", "content": [tool_result_block]})
|
|
708
|
+
else:
|
|
709
|
+
anthropic_messages.append(
|
|
710
|
+
{
|
|
711
|
+
"role": "user",
|
|
712
|
+
"content": [tool_result_block],
|
|
713
|
+
}
|
|
714
|
+
)
|
|
715
|
+
continue
|
|
716
|
+
raise LLMError(f"Anthropic Messages cannot send message role {role!r}")
|
|
717
|
+
if system_blocks:
|
|
718
|
+
_flush_system_parts()
|
|
719
|
+
system: str | list[dict[str, Any]] | None = system_blocks
|
|
720
|
+
else:
|
|
721
|
+
system = "\n\n".join(system_parts).strip() or None
|
|
722
|
+
return system, anthropic_messages
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _anthropic_system_with_cache_control(
|
|
726
|
+
system: str | list[dict[str, Any]] | None,
|
|
727
|
+
*,
|
|
728
|
+
cache_control: Mapping[str, Any] | None,
|
|
729
|
+
) -> tuple[str | list[dict[str, Any]] | None, bool]:
|
|
730
|
+
if not system or not isinstance(cache_control, Mapping):
|
|
731
|
+
return system, False
|
|
732
|
+
if count_cache_control_blocks(system) > 0:
|
|
733
|
+
return system, True
|
|
734
|
+
cache_control_payload = copy.deepcopy(dict(cache_control))
|
|
735
|
+
if isinstance(system, str):
|
|
736
|
+
text = system.strip()
|
|
737
|
+
if not text:
|
|
738
|
+
return system, False
|
|
739
|
+
return (
|
|
740
|
+
[
|
|
741
|
+
{
|
|
742
|
+
"type": "text",
|
|
743
|
+
"text": system,
|
|
744
|
+
CACHE_CONTROL_FIELD: cache_control_payload,
|
|
745
|
+
}
|
|
746
|
+
],
|
|
747
|
+
True,
|
|
748
|
+
)
|
|
749
|
+
copied: list[dict[str, Any]] = [copy.deepcopy(block) for block in system]
|
|
750
|
+
for index in range(len(copied) - 1, -1, -1):
|
|
751
|
+
block = copied[index]
|
|
752
|
+
if not isinstance(block, dict):
|
|
753
|
+
continue
|
|
754
|
+
block_type = str(block.get("type") or "text").strip().lower()
|
|
755
|
+
text = block.get("text")
|
|
756
|
+
if block_type != "text" or not isinstance(text, str) or not text.strip():
|
|
757
|
+
continue
|
|
758
|
+
block[CACHE_CONTROL_FIELD] = cache_control_payload
|
|
759
|
+
return copied, True
|
|
760
|
+
return system, False
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _cache_control_ttl(cache_control: Mapping[str, Any] | None) -> str:
|
|
764
|
+
if not isinstance(cache_control, Mapping):
|
|
765
|
+
return "5m"
|
|
766
|
+
return _normalize_prompt_cache_control_ttl(str(cache_control.get("ttl") or "5m"))
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _append_policy_list_value(
|
|
770
|
+
cache_policy: dict[str, Any] | None,
|
|
771
|
+
key: str,
|
|
772
|
+
value: str,
|
|
773
|
+
) -> None:
|
|
774
|
+
if cache_policy is None:
|
|
775
|
+
return
|
|
776
|
+
normalized = str(value or "").strip()
|
|
777
|
+
if not normalized:
|
|
778
|
+
return
|
|
779
|
+
existing = cache_policy.get(key)
|
|
780
|
+
values = [str(item).strip() for item in existing] if isinstance(existing, list) else []
|
|
781
|
+
if normalized not in values:
|
|
782
|
+
values.append(normalized)
|
|
783
|
+
cache_policy[key] = values
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _apply_anthropic_cache_control_plan(
|
|
787
|
+
*,
|
|
788
|
+
payload: dict[str, Any],
|
|
789
|
+
cache_control: Mapping[str, Any] | None,
|
|
790
|
+
cache_policy: dict[str, Any] | None,
|
|
791
|
+
) -> None:
|
|
792
|
+
if not isinstance(cache_control, Mapping):
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
explicit_payloads = explicit_cache_control_payloads(payload)
|
|
796
|
+
explicit_block_count = count_explicit_cache_control_blocks(payload)
|
|
797
|
+
requested_ttl = _cache_control_ttl(cache_control)
|
|
798
|
+
ttl_conflict = (
|
|
799
|
+
bool(explicit_payloads) and _cache_control_ttl(explicit_payloads[-1]) != requested_ttl
|
|
800
|
+
)
|
|
801
|
+
top_level_allowed = (
|
|
802
|
+
not ttl_conflict and explicit_block_count < _ANTHROPIC_MAX_CACHE_CONTROL_BREAKPOINTS
|
|
803
|
+
)
|
|
804
|
+
explicit_slot_limit = _ANTHROPIC_MAX_CACHE_CONTROL_BREAKPOINTS - (1 if top_level_allowed else 0)
|
|
805
|
+
|
|
806
|
+
system = payload.get("system")
|
|
807
|
+
if system and not ttl_conflict:
|
|
808
|
+
system_has_cache_control = count_cache_control_blocks(system) > 0
|
|
809
|
+
if not system_has_cache_control and explicit_block_count < explicit_slot_limit:
|
|
810
|
+
updated_system, added_system_cache_control = _anthropic_system_with_cache_control(
|
|
811
|
+
system,
|
|
812
|
+
cache_control=cache_control,
|
|
813
|
+
)
|
|
814
|
+
if added_system_cache_control:
|
|
815
|
+
payload["system"] = updated_system
|
|
816
|
+
explicit_block_count = count_explicit_cache_control_blocks(payload)
|
|
817
|
+
elif system_has_cache_control:
|
|
818
|
+
explicit_block_count = count_explicit_cache_control_blocks(payload)
|
|
819
|
+
|
|
820
|
+
if top_level_allowed:
|
|
821
|
+
payload[CACHE_CONTROL_FIELD] = copy.deepcopy(dict(cache_control))
|
|
822
|
+
else:
|
|
823
|
+
_append_policy_list_value(
|
|
824
|
+
cache_policy,
|
|
825
|
+
"warnings",
|
|
826
|
+
(
|
|
827
|
+
"anthropic_top_level_cache_control_skipped_ttl_conflict"
|
|
828
|
+
if ttl_conflict
|
|
829
|
+
else "anthropic_top_level_cache_control_skipped_breakpoint_limit"
|
|
830
|
+
),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
explicit_block_count = count_explicit_cache_control_blocks(payload)
|
|
834
|
+
if cache_policy is not None:
|
|
835
|
+
cache_policy["used"] = bool(top_level_allowed or explicit_block_count > 0)
|
|
836
|
+
cache_policy["top_level_cache_control_used"] = bool(top_level_allowed)
|
|
837
|
+
cache_policy["explicit_block_used"] = explicit_block_count > 0
|
|
838
|
+
cache_policy["explicit_block_count"] = explicit_block_count
|
|
839
|
+
if not top_level_allowed and explicit_block_count <= 0:
|
|
840
|
+
cache_policy["fallback"] = "cache_control_not_applied"
|
|
841
|
+
_append_policy_list_value(cache_policy, "disabled_fields", CACHE_CONTROL_FIELD)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _payload_has_cache_control(payload: Mapping[str, Any]) -> bool:
|
|
845
|
+
return count_cache_control_blocks(payload) > 0
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _cache_control_rejection_reason(response: httpx.Response) -> str | None:
|
|
849
|
+
if response.status_code != 400:
|
|
850
|
+
return None
|
|
851
|
+
try:
|
|
852
|
+
body = response.text
|
|
853
|
+
except Exception:
|
|
854
|
+
body = ""
|
|
855
|
+
lowered = body.lower()
|
|
856
|
+
if any(
|
|
857
|
+
marker in lowered
|
|
858
|
+
for marker in (
|
|
859
|
+
"cache_control",
|
|
860
|
+
"cache control",
|
|
861
|
+
"prompt cache",
|
|
862
|
+
"cache breakpoint",
|
|
863
|
+
"ephemeral",
|
|
864
|
+
"ttl",
|
|
865
|
+
)
|
|
866
|
+
):
|
|
867
|
+
return "anthropic_cache_control_rejected"
|
|
868
|
+
return None
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _temperature_rejection_reason(response: httpx.Response) -> str | None:
|
|
872
|
+
if response.status_code not in {400, 422}:
|
|
873
|
+
return None
|
|
874
|
+
try:
|
|
875
|
+
body = response.text
|
|
876
|
+
except Exception:
|
|
877
|
+
body = ""
|
|
878
|
+
if "temperature" in body.casefold():
|
|
879
|
+
return "provider_rejected_temperature"
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _thinking_display_rejection_reason(response: httpx.Response) -> str | None:
|
|
884
|
+
"""Return a fallback reason only for explicit summary-display incompatibility."""
|
|
885
|
+
|
|
886
|
+
if response.status_code not in {400, 422}:
|
|
887
|
+
return None
|
|
888
|
+
try:
|
|
889
|
+
body = response.text
|
|
890
|
+
except Exception:
|
|
891
|
+
body = ""
|
|
892
|
+
lowered = body.casefold()
|
|
893
|
+
if not any(marker in lowered for marker in ("display", "summarized")):
|
|
894
|
+
return None
|
|
895
|
+
if not any(
|
|
896
|
+
marker in lowered
|
|
897
|
+
for marker in (
|
|
898
|
+
"unsupported",
|
|
899
|
+
"not supported",
|
|
900
|
+
"does not support",
|
|
901
|
+
"unknown",
|
|
902
|
+
"unrecognized",
|
|
903
|
+
"invalid",
|
|
904
|
+
"not allowed",
|
|
905
|
+
"not permitted",
|
|
906
|
+
"unexpected",
|
|
907
|
+
"extra input",
|
|
908
|
+
"extra field",
|
|
909
|
+
)
|
|
910
|
+
):
|
|
911
|
+
return None
|
|
912
|
+
return "provider_rejected_thinking_display"
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _payload_requests_summarized_thinking(payload: Mapping[str, Any]) -> bool:
|
|
916
|
+
thinking = payload.get("thinking")
|
|
917
|
+
return isinstance(thinking, Mapping) and thinking.get("display") == "summarized"
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _without_thinking_display(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
921
|
+
downgraded = copy.deepcopy(dict(payload))
|
|
922
|
+
thinking = downgraded.get("thinking")
|
|
923
|
+
if isinstance(thinking, dict):
|
|
924
|
+
thinking.pop("display", None)
|
|
925
|
+
return downgraded
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _downgrade_anthropic_cache_control_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
929
|
+
downgraded = copy.deepcopy(dict(payload))
|
|
930
|
+
strip_cache_control_blocks(downgraded)
|
|
931
|
+
return downgraded
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _mark_anthropic_cache_control_downgrade(
|
|
935
|
+
cache_policy: dict[str, Any] | None,
|
|
936
|
+
*,
|
|
937
|
+
reason: str,
|
|
938
|
+
) -> None:
|
|
939
|
+
if cache_policy is None:
|
|
940
|
+
return
|
|
941
|
+
cache_policy["status"] = "fallback"
|
|
942
|
+
cache_policy["used"] = False
|
|
943
|
+
cache_policy["fallback"] = reason
|
|
944
|
+
cache_policy["top_level_cache_control_used"] = False
|
|
945
|
+
cache_policy["explicit_block_used"] = False
|
|
946
|
+
cache_policy["explicit_block_count"] = 0
|
|
947
|
+
_append_policy_list_value(cache_policy, "disabled_fields", CACHE_CONTROL_FIELD)
|
|
948
|
+
_append_policy_list_value(cache_policy, "warnings", reason)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _parse_usage(raw: Any) -> LLMUsage | None:
|
|
952
|
+
if not isinstance(raw, dict):
|
|
953
|
+
return None
|
|
954
|
+
|
|
955
|
+
def _as_non_negative_int(value: Any) -> int | None:
|
|
956
|
+
try:
|
|
957
|
+
parsed = int(value) if value is not None else None
|
|
958
|
+
except (TypeError, ValueError):
|
|
959
|
+
return None
|
|
960
|
+
if parsed is None or parsed >= 0:
|
|
961
|
+
return parsed
|
|
962
|
+
return None
|
|
963
|
+
|
|
964
|
+
input_tokens = _as_non_negative_int(raw.get("input_tokens"))
|
|
965
|
+
output_tokens = _as_non_negative_int(raw.get("output_tokens"))
|
|
966
|
+
cache_read_input_tokens = _as_non_negative_int(raw.get("cache_read_input_tokens"))
|
|
967
|
+
cache_creation = raw.get("cache_creation")
|
|
968
|
+
cache_creation_5m_input_tokens: int | None = None
|
|
969
|
+
cache_creation_1h_input_tokens: int | None = None
|
|
970
|
+
if isinstance(cache_creation, dict):
|
|
971
|
+
cache_creation_5m_input_tokens = _as_non_negative_int(
|
|
972
|
+
cache_creation.get("ephemeral_5m_input_tokens")
|
|
973
|
+
)
|
|
974
|
+
cache_creation_1h_input_tokens = _as_non_negative_int(
|
|
975
|
+
cache_creation.get("ephemeral_1h_input_tokens")
|
|
976
|
+
)
|
|
977
|
+
cache_creation_input_tokens = _as_non_negative_int(raw.get("cache_creation_input_tokens"))
|
|
978
|
+
if cache_creation_input_tokens is None:
|
|
979
|
+
creation_parts = [
|
|
980
|
+
value
|
|
981
|
+
for value in (cache_creation_5m_input_tokens, cache_creation_1h_input_tokens)
|
|
982
|
+
if value is not None
|
|
983
|
+
]
|
|
984
|
+
if creation_parts:
|
|
985
|
+
cache_creation_input_tokens = sum(creation_parts)
|
|
986
|
+
|
|
987
|
+
has_cache_accounting = any(
|
|
988
|
+
value is not None for value in (cache_read_input_tokens, cache_creation_input_tokens)
|
|
989
|
+
)
|
|
990
|
+
prompt_tokens = input_tokens
|
|
991
|
+
if has_cache_accounting:
|
|
992
|
+
prompt_tokens = sum(
|
|
993
|
+
value or 0
|
|
994
|
+
for value in (input_tokens, cache_read_input_tokens, cache_creation_input_tokens)
|
|
995
|
+
)
|
|
996
|
+
total_tokens = None
|
|
997
|
+
if prompt_tokens is not None and output_tokens is not None:
|
|
998
|
+
total_tokens = prompt_tokens + output_tokens
|
|
999
|
+
return LLMUsage(
|
|
1000
|
+
prompt_tokens=prompt_tokens,
|
|
1001
|
+
completion_tokens=output_tokens,
|
|
1002
|
+
total_tokens=total_tokens,
|
|
1003
|
+
cached_prompt_tokens=cache_read_input_tokens,
|
|
1004
|
+
input_tokens_uncached=input_tokens,
|
|
1005
|
+
cache_read_input_tokens=cache_read_input_tokens,
|
|
1006
|
+
cache_creation_input_tokens=cache_creation_input_tokens,
|
|
1007
|
+
cache_creation_5m_input_tokens=cache_creation_5m_input_tokens,
|
|
1008
|
+
cache_creation_1h_input_tokens=cache_creation_1h_input_tokens,
|
|
1009
|
+
raw_provider_usage=copy.deepcopy(raw),
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _extract_error_message(data: Any) -> str | None:
|
|
1014
|
+
if not isinstance(data, dict):
|
|
1015
|
+
return None
|
|
1016
|
+
error_obj = data.get("error")
|
|
1017
|
+
if isinstance(error_obj, dict):
|
|
1018
|
+
message = str(error_obj.get("message") or "").strip()
|
|
1019
|
+
if message:
|
|
1020
|
+
error_type = str(error_obj.get("type") or "").strip()
|
|
1021
|
+
return f"{error_type}: {message}" if error_type else message
|
|
1022
|
+
return None
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _text_from_content_blocks(content: list[Any]) -> str:
|
|
1026
|
+
parts: list[str] = []
|
|
1027
|
+
for block in content:
|
|
1028
|
+
if not isinstance(block, dict):
|
|
1029
|
+
continue
|
|
1030
|
+
if str(block.get("type") or "") == "text":
|
|
1031
|
+
text = block.get("text")
|
|
1032
|
+
if isinstance(text, str):
|
|
1033
|
+
parts.append(text)
|
|
1034
|
+
return "".join(parts)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _thinking_summaries_from_content_blocks(content: list[Any]) -> list[str]:
|
|
1038
|
+
summaries: list[str] = []
|
|
1039
|
+
for block in content:
|
|
1040
|
+
if not isinstance(block, dict) or str(block.get("type") or "") != "thinking":
|
|
1041
|
+
continue
|
|
1042
|
+
thinking = block.get("thinking")
|
|
1043
|
+
if isinstance(thinking, str) and thinking:
|
|
1044
|
+
# This field is only requested with display="summarized". Never
|
|
1045
|
+
# surface signatures, redacted_thinking blocks, or opaque state.
|
|
1046
|
+
summaries.append(thinking)
|
|
1047
|
+
return summaries
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _parse_tool_calls(content: list[Any]) -> list[ToolCall]:
|
|
1051
|
+
tool_calls: list[ToolCall] = []
|
|
1052
|
+
for index, block in enumerate(content):
|
|
1053
|
+
if not isinstance(block, dict):
|
|
1054
|
+
continue
|
|
1055
|
+
if str(block.get("type") or "") != "tool_use":
|
|
1056
|
+
continue
|
|
1057
|
+
tool_use_id = str(block.get("id") or f"toolu_{index}").strip()
|
|
1058
|
+
name = str(block.get("name") or "").strip()
|
|
1059
|
+
if not name:
|
|
1060
|
+
continue
|
|
1061
|
+
raw_input = block.get("input")
|
|
1062
|
+
arguments = (
|
|
1063
|
+
dict(raw_input) if isinstance(raw_input, dict) else {"_raw_arguments": raw_input}
|
|
1064
|
+
)
|
|
1065
|
+
tool_calls.append(
|
|
1066
|
+
ToolCall(
|
|
1067
|
+
id=tool_use_id,
|
|
1068
|
+
name=name,
|
|
1069
|
+
arguments=arguments,
|
|
1070
|
+
provider_metadata={
|
|
1071
|
+
_ANTHROPIC_METADATA_KEY: {
|
|
1072
|
+
"content_index": index,
|
|
1073
|
+
}
|
|
1074
|
+
},
|
|
1075
|
+
)
|
|
1076
|
+
)
|
|
1077
|
+
return tool_calls
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _collect_web_search_metadata(content: list[Any]) -> dict[str, Any]:
|
|
1081
|
+
server_tool_uses: list[dict[str, Any]] = []
|
|
1082
|
+
web_search_results: list[dict[str, Any]] = []
|
|
1083
|
+
citations: list[dict[str, Any]] = []
|
|
1084
|
+
sources: list[dict[str, Any]] = []
|
|
1085
|
+
queries: list[str] = []
|
|
1086
|
+
|
|
1087
|
+
def _append_source(raw: dict[str, Any]) -> None:
|
|
1088
|
+
url = str(raw.get("url") or "").strip()
|
|
1089
|
+
if not url:
|
|
1090
|
+
return
|
|
1091
|
+
source: dict[str, Any] = {
|
|
1092
|
+
"url": url,
|
|
1093
|
+
"title": str(raw.get("title") or "").strip(),
|
|
1094
|
+
}
|
|
1095
|
+
for key in ("page_age", "encrypted_content", "encrypted_index", "cited_text"):
|
|
1096
|
+
value = raw.get(key)
|
|
1097
|
+
if value is not None:
|
|
1098
|
+
source[key] = value
|
|
1099
|
+
sources.append(source)
|
|
1100
|
+
|
|
1101
|
+
for block in content:
|
|
1102
|
+
if not isinstance(block, dict):
|
|
1103
|
+
continue
|
|
1104
|
+
block_type = str(block.get("type") or "")
|
|
1105
|
+
if block_type == "server_tool_use" and str(block.get("name") or "") == "web_search":
|
|
1106
|
+
copied = copy.deepcopy(block)
|
|
1107
|
+
server_tool_uses.append(copied)
|
|
1108
|
+
raw_input = block.get("input")
|
|
1109
|
+
if isinstance(raw_input, dict):
|
|
1110
|
+
query = str(raw_input.get("query") or "").strip()
|
|
1111
|
+
if query:
|
|
1112
|
+
queries.append(query)
|
|
1113
|
+
continue
|
|
1114
|
+
if block_type == "web_search_tool_result":
|
|
1115
|
+
web_search_results.append(copy.deepcopy(block))
|
|
1116
|
+
result_content = block.get("content")
|
|
1117
|
+
if isinstance(result_content, list):
|
|
1118
|
+
for result in result_content:
|
|
1119
|
+
if (
|
|
1120
|
+
isinstance(result, dict)
|
|
1121
|
+
and str(result.get("type") or "") == "web_search_result"
|
|
1122
|
+
):
|
|
1123
|
+
web_search_results.append(copy.deepcopy(result))
|
|
1124
|
+
_append_source(result)
|
|
1125
|
+
continue
|
|
1126
|
+
if block_type != "text":
|
|
1127
|
+
continue
|
|
1128
|
+
raw_citations = block.get("citations")
|
|
1129
|
+
if not isinstance(raw_citations, list):
|
|
1130
|
+
continue
|
|
1131
|
+
for citation in raw_citations:
|
|
1132
|
+
if not isinstance(citation, dict):
|
|
1133
|
+
continue
|
|
1134
|
+
if str(citation.get("type") or "") != "web_search_result_location":
|
|
1135
|
+
continue
|
|
1136
|
+
url = str(citation.get("url") or "").strip()
|
|
1137
|
+
if not url:
|
|
1138
|
+
continue
|
|
1139
|
+
citation_payload = {
|
|
1140
|
+
"url": url,
|
|
1141
|
+
"title": str(citation.get("title") or "").strip(),
|
|
1142
|
+
"encrypted_index": citation.get("encrypted_index"),
|
|
1143
|
+
"cited_text": citation.get("cited_text"),
|
|
1144
|
+
}
|
|
1145
|
+
citations.append(citation_payload)
|
|
1146
|
+
_append_source(citation)
|
|
1147
|
+
|
|
1148
|
+
if queries:
|
|
1149
|
+
queries = list(dict.fromkeys(queries))
|
|
1150
|
+
deduped_sources: list[dict[str, Any]] = []
|
|
1151
|
+
seen_urls: set[str] = set()
|
|
1152
|
+
for source in sources:
|
|
1153
|
+
url = str(source.get("url") or "").strip()
|
|
1154
|
+
if not url or url in seen_urls:
|
|
1155
|
+
continue
|
|
1156
|
+
seen_urls.add(url)
|
|
1157
|
+
deduped_sources.append(source)
|
|
1158
|
+
metadata: dict[str, Any] = {}
|
|
1159
|
+
if server_tool_uses:
|
|
1160
|
+
metadata["server_tool_uses"] = server_tool_uses
|
|
1161
|
+
if web_search_results:
|
|
1162
|
+
metadata["web_search_results"] = web_search_results
|
|
1163
|
+
if citations:
|
|
1164
|
+
metadata["citations"] = citations
|
|
1165
|
+
if deduped_sources:
|
|
1166
|
+
metadata["sources"] = deduped_sources
|
|
1167
|
+
if queries:
|
|
1168
|
+
metadata["queries"] = queries
|
|
1169
|
+
return metadata
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _anthropic_provider_metadata(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
1173
|
+
content = data.get("content")
|
|
1174
|
+
metadata: dict[str, Any] = {}
|
|
1175
|
+
message_id = str(data.get("id") or "").strip()
|
|
1176
|
+
if message_id:
|
|
1177
|
+
metadata["message_id"] = message_id
|
|
1178
|
+
stop_reason = str(data.get("stop_reason") or "").strip()
|
|
1179
|
+
if stop_reason:
|
|
1180
|
+
metadata["stop_reason"] = stop_reason
|
|
1181
|
+
stop_sequence = data.get("stop_sequence")
|
|
1182
|
+
if stop_sequence is not None:
|
|
1183
|
+
metadata["stop_sequence"] = stop_sequence
|
|
1184
|
+
if isinstance(content, list):
|
|
1185
|
+
metadata["content_blocks"] = copy.deepcopy(content)
|
|
1186
|
+
metadata.update(_collect_web_search_metadata(content))
|
|
1187
|
+
usage = data.get("usage")
|
|
1188
|
+
if isinstance(usage, dict):
|
|
1189
|
+
metadata["usage"] = copy.deepcopy(usage)
|
|
1190
|
+
stream_metadata = data.get("stream_metadata")
|
|
1191
|
+
if isinstance(stream_metadata, dict):
|
|
1192
|
+
metadata["stream_metadata"] = copy.deepcopy(stream_metadata)
|
|
1193
|
+
return {_ANTHROPIC_METADATA_KEY: metadata} if metadata else None
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _response_from_json(data: dict[str, Any]) -> httpx.Response:
|
|
1197
|
+
return httpx.Response(200, json=data)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _event_index(data: dict[str, Any], *, event_type: str) -> int:
|
|
1201
|
+
index = data.get("index")
|
|
1202
|
+
if isinstance(index, int) and index >= 0:
|
|
1203
|
+
return index
|
|
1204
|
+
raise LLMError(f"Anthropic Messages stream {event_type} event is missing a valid index")
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
class _AnthropicStreamAccumulator:
|
|
1208
|
+
def __init__(
|
|
1209
|
+
self,
|
|
1210
|
+
*,
|
|
1211
|
+
on_text_delta: Callable[[str], None] | None,
|
|
1212
|
+
on_reasoning_delta: Callable[[str], None] | None,
|
|
1213
|
+
reasoning_is_summary: bool,
|
|
1214
|
+
) -> None:
|
|
1215
|
+
self.on_text_delta = on_text_delta
|
|
1216
|
+
self.on_reasoning_delta = on_reasoning_delta
|
|
1217
|
+
self.reasoning_is_summary = reasoning_is_summary
|
|
1218
|
+
self.message: dict[str, Any] = {
|
|
1219
|
+
"type": "message",
|
|
1220
|
+
"role": "assistant",
|
|
1221
|
+
"content": [],
|
|
1222
|
+
}
|
|
1223
|
+
self.content_blocks: dict[int, dict[str, Any]] = {}
|
|
1224
|
+
self.input_json_chunks: dict[int, list[str]] = {}
|
|
1225
|
+
self.usage: dict[str, Any] = {}
|
|
1226
|
+
self.unknown_events: list[dict[str, Any]] = []
|
|
1227
|
+
self.event_count = 0
|
|
1228
|
+
self.seen_message_start = False
|
|
1229
|
+
self.seen_message_stop = False
|
|
1230
|
+
|
|
1231
|
+
def handle(self, frame: SSEFrame, data: dict[str, Any]) -> None:
|
|
1232
|
+
event_type = str(data.get("type") or frame.event or "").strip()
|
|
1233
|
+
if not event_type:
|
|
1234
|
+
self._append_unknown(frame=frame, data=data)
|
|
1235
|
+
return
|
|
1236
|
+
self.event_count += 1
|
|
1237
|
+
|
|
1238
|
+
if event_type == "message_start":
|
|
1239
|
+
self._handle_message_start(data)
|
|
1240
|
+
return
|
|
1241
|
+
if event_type == "content_block_start":
|
|
1242
|
+
self._handle_content_block_start(data)
|
|
1243
|
+
return
|
|
1244
|
+
if event_type == "content_block_delta":
|
|
1245
|
+
self._handle_content_block_delta(data)
|
|
1246
|
+
return
|
|
1247
|
+
if event_type == "content_block_stop":
|
|
1248
|
+
self._handle_content_block_stop(data)
|
|
1249
|
+
return
|
|
1250
|
+
if event_type == "message_delta":
|
|
1251
|
+
self._handle_message_delta(data)
|
|
1252
|
+
return
|
|
1253
|
+
if event_type == "message_stop":
|
|
1254
|
+
self.seen_message_stop = True
|
|
1255
|
+
return
|
|
1256
|
+
if event_type == "ping":
|
|
1257
|
+
return
|
|
1258
|
+
if event_type == "error":
|
|
1259
|
+
message = _extract_error_message(data)
|
|
1260
|
+
raise LLMError(f"Anthropic Messages stream error: {message or data!r}")
|
|
1261
|
+
|
|
1262
|
+
self._append_unknown(frame=frame, data=data)
|
|
1263
|
+
|
|
1264
|
+
def _handle_message_start(self, data: dict[str, Any]) -> None:
|
|
1265
|
+
raw_message = data.get("message")
|
|
1266
|
+
if not isinstance(raw_message, dict):
|
|
1267
|
+
raise LLMError("Anthropic Messages stream message_start missing message object")
|
|
1268
|
+
message = copy.deepcopy(raw_message)
|
|
1269
|
+
content = message.get("content")
|
|
1270
|
+
if not isinstance(content, list):
|
|
1271
|
+
message["content"] = []
|
|
1272
|
+
else:
|
|
1273
|
+
message["content"] = []
|
|
1274
|
+
self.message.update(message)
|
|
1275
|
+
usage = message.get("usage")
|
|
1276
|
+
if isinstance(usage, dict):
|
|
1277
|
+
self.usage.update(copy.deepcopy(usage))
|
|
1278
|
+
self.seen_message_start = True
|
|
1279
|
+
|
|
1280
|
+
def _handle_content_block_start(self, data: dict[str, Any]) -> None:
|
|
1281
|
+
index = _event_index(data, event_type="content_block_start")
|
|
1282
|
+
raw_block = data.get("content_block")
|
|
1283
|
+
block = copy.deepcopy(raw_block) if isinstance(raw_block, dict) else {}
|
|
1284
|
+
if not str(block.get("type") or "").strip():
|
|
1285
|
+
block["type"] = "unknown"
|
|
1286
|
+
block.setdefault("_stream_index", index)
|
|
1287
|
+
self.content_blocks[index] = block
|
|
1288
|
+
if str(block.get("type") or "") in {"tool_use", "server_tool_use"}:
|
|
1289
|
+
self.input_json_chunks.setdefault(index, [])
|
|
1290
|
+
|
|
1291
|
+
def _handle_content_block_delta(self, data: dict[str, Any]) -> None:
|
|
1292
|
+
index = _event_index(data, event_type="content_block_delta")
|
|
1293
|
+
block = self.content_blocks.setdefault(index, {"type": "unknown", "_stream_index": index})
|
|
1294
|
+
raw_delta = data.get("delta")
|
|
1295
|
+
if not isinstance(raw_delta, dict):
|
|
1296
|
+
self._append_block_delta(block, raw_delta)
|
|
1297
|
+
return
|
|
1298
|
+
delta_type = str(raw_delta.get("type") or "").strip()
|
|
1299
|
+
|
|
1300
|
+
if delta_type == "text_delta":
|
|
1301
|
+
text = raw_delta.get("text")
|
|
1302
|
+
if isinstance(text, str) and text:
|
|
1303
|
+
existing = block.get("text")
|
|
1304
|
+
block["text"] = (existing if isinstance(existing, str) else "") + text
|
|
1305
|
+
if self.on_text_delta is not None:
|
|
1306
|
+
self.on_text_delta(text)
|
|
1307
|
+
return
|
|
1308
|
+
|
|
1309
|
+
if delta_type == "input_json_delta":
|
|
1310
|
+
partial_json = raw_delta.get("partial_json")
|
|
1311
|
+
if isinstance(partial_json, str):
|
|
1312
|
+
self.input_json_chunks.setdefault(index, []).append(partial_json)
|
|
1313
|
+
return
|
|
1314
|
+
|
|
1315
|
+
if delta_type == "thinking_delta":
|
|
1316
|
+
thinking = raw_delta.get("thinking")
|
|
1317
|
+
if isinstance(thinking, str) and thinking:
|
|
1318
|
+
existing = block.get("thinking")
|
|
1319
|
+
block["thinking"] = (existing if isinstance(existing, str) else "") + thinking
|
|
1320
|
+
if self.reasoning_is_summary and self.on_reasoning_delta is not None:
|
|
1321
|
+
self.on_reasoning_delta(thinking)
|
|
1322
|
+
return
|
|
1323
|
+
|
|
1324
|
+
if delta_type == "signature_delta":
|
|
1325
|
+
signature = raw_delta.get("signature")
|
|
1326
|
+
if isinstance(signature, str) and signature:
|
|
1327
|
+
existing = block.get("signature")
|
|
1328
|
+
block["signature"] = (existing if isinstance(existing, str) else "") + signature
|
|
1329
|
+
return
|
|
1330
|
+
|
|
1331
|
+
if delta_type in {"citations_delta", "citation_delta"}:
|
|
1332
|
+
self._append_citation_delta(block, raw_delta)
|
|
1333
|
+
return
|
|
1334
|
+
|
|
1335
|
+
self._append_block_delta(block, raw_delta)
|
|
1336
|
+
|
|
1337
|
+
def _handle_content_block_stop(self, data: dict[str, Any]) -> None:
|
|
1338
|
+
index = _event_index(data, event_type="content_block_stop")
|
|
1339
|
+
block = self.content_blocks.get(index)
|
|
1340
|
+
if block is None:
|
|
1341
|
+
raise LLMError(
|
|
1342
|
+
"Anthropic Messages stream content_block_stop before content_block_start"
|
|
1343
|
+
)
|
|
1344
|
+
chunks = self.input_json_chunks.get(index)
|
|
1345
|
+
if chunks is None:
|
|
1346
|
+
return
|
|
1347
|
+
joined = "".join(chunks).strip()
|
|
1348
|
+
if not joined:
|
|
1349
|
+
if "input" not in block:
|
|
1350
|
+
block["input"] = {}
|
|
1351
|
+
return
|
|
1352
|
+
try:
|
|
1353
|
+
parsed = json.loads(joined)
|
|
1354
|
+
except json.JSONDecodeError as exc:
|
|
1355
|
+
raise LLMError(
|
|
1356
|
+
"Anthropic Messages stream emitted malformed tool input JSON "
|
|
1357
|
+
f"for content block {index}: {exc.msg}"
|
|
1358
|
+
) from exc
|
|
1359
|
+
block["input"] = parsed if isinstance(parsed, dict) else {"_raw_arguments": parsed}
|
|
1360
|
+
|
|
1361
|
+
def _handle_message_delta(self, data: dict[str, Any]) -> None:
|
|
1362
|
+
delta = data.get("delta")
|
|
1363
|
+
if isinstance(delta, dict):
|
|
1364
|
+
for key in ("stop_reason", "stop_sequence"):
|
|
1365
|
+
if key in delta:
|
|
1366
|
+
self.message[key] = copy.deepcopy(delta[key])
|
|
1367
|
+
usage = data.get("usage")
|
|
1368
|
+
if isinstance(usage, dict):
|
|
1369
|
+
self.usage.update(copy.deepcopy(usage))
|
|
1370
|
+
|
|
1371
|
+
def finish(self) -> dict[str, Any]:
|
|
1372
|
+
if not self.seen_message_start:
|
|
1373
|
+
raise LLMError("Anthropic Messages stream returned no message_start event")
|
|
1374
|
+
if not self.seen_message_stop:
|
|
1375
|
+
raise LLMError("Anthropic Messages stream ended before message_stop")
|
|
1376
|
+
for index in tuple(self.input_json_chunks):
|
|
1377
|
+
self._handle_content_block_stop({"index": index})
|
|
1378
|
+
content = [
|
|
1379
|
+
self._public_content_block(block)
|
|
1380
|
+
for _index, block in sorted(self.content_blocks.items(), key=lambda item: item[0])
|
|
1381
|
+
]
|
|
1382
|
+
self.message["content"] = content
|
|
1383
|
+
if self.usage:
|
|
1384
|
+
self.message["usage"] = copy.deepcopy(self.usage)
|
|
1385
|
+
stream_metadata: dict[str, Any] = {"events": self.event_count}
|
|
1386
|
+
if self.unknown_events:
|
|
1387
|
+
stream_metadata["unknown_events"] = copy.deepcopy(self.unknown_events)
|
|
1388
|
+
self.message["stream_metadata"] = stream_metadata
|
|
1389
|
+
return copy.deepcopy(self.message)
|
|
1390
|
+
|
|
1391
|
+
@staticmethod
|
|
1392
|
+
def _append_citation_delta(block: dict[str, Any], delta: dict[str, Any]) -> None:
|
|
1393
|
+
citations = block.setdefault("citations", [])
|
|
1394
|
+
if not isinstance(citations, list):
|
|
1395
|
+
citations = []
|
|
1396
|
+
block["citations"] = citations
|
|
1397
|
+
citation = delta.get("citation")
|
|
1398
|
+
if isinstance(citation, dict):
|
|
1399
|
+
citations.append(copy.deepcopy(citation))
|
|
1400
|
+
return
|
|
1401
|
+
raw_citations = delta.get("citations")
|
|
1402
|
+
if isinstance(raw_citations, list):
|
|
1403
|
+
citations.extend(
|
|
1404
|
+
copy.deepcopy(item) for item in raw_citations if isinstance(item, dict)
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
@staticmethod
|
|
1408
|
+
def _append_block_delta(block: dict[str, Any], delta: Any) -> None:
|
|
1409
|
+
deltas = block.setdefault("_stream_deltas", [])
|
|
1410
|
+
if isinstance(deltas, list):
|
|
1411
|
+
deltas.append(copy.deepcopy(delta))
|
|
1412
|
+
|
|
1413
|
+
def _append_unknown(self, *, frame: SSEFrame, data: dict[str, Any]) -> None:
|
|
1414
|
+
self.unknown_events.append(
|
|
1415
|
+
{
|
|
1416
|
+
"event": frame.event,
|
|
1417
|
+
"data": copy.deepcopy(data),
|
|
1418
|
+
}
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
@staticmethod
|
|
1422
|
+
def _public_content_block(block: dict[str, Any]) -> dict[str, Any]:
|
|
1423
|
+
copied = copy.deepcopy(block)
|
|
1424
|
+
copied.pop("_stream_index", None)
|
|
1425
|
+
return copied
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
class AnthropicMessagesClient:
|
|
1429
|
+
usage_contract = UsageContract(
|
|
1430
|
+
response_usage_confidence=UsageConfidence.AUTHORITATIVE,
|
|
1431
|
+
input_token_count_strategy="anthropic_messages",
|
|
1432
|
+
)
|
|
1433
|
+
usage_counts_authoritative = usage_contract.response_usage_authoritative
|
|
1434
|
+
supports_tool_calling = True
|
|
1435
|
+
supports_forced_tool_choice = True
|
|
1436
|
+
|
|
1437
|
+
def __init__(
|
|
1438
|
+
self,
|
|
1439
|
+
*,
|
|
1440
|
+
base_url: str,
|
|
1441
|
+
api_key: str,
|
|
1442
|
+
model: str,
|
|
1443
|
+
timeout_s: float = 20.0,
|
|
1444
|
+
temperature: float = 1.0,
|
|
1445
|
+
prompt_cache_key: str | None = None,
|
|
1446
|
+
prompt_cache_retention: str | None = None,
|
|
1447
|
+
enable_thinking: bool | None = None,
|
|
1448
|
+
reasoning_effort: str | None = None,
|
|
1449
|
+
transport: httpx.BaseTransport | None = None,
|
|
1450
|
+
extra_headers: dict[str, str] | None = None,
|
|
1451
|
+
provider_key: str | None = None,
|
|
1452
|
+
web_search_mode: str = "off",
|
|
1453
|
+
web_search_adapter: str = AUTO_WEB_SEARCH_ADAPTER,
|
|
1454
|
+
prompt_cache_control_enabled: bool = False,
|
|
1455
|
+
prompt_cache_control_ttl: str = "5m",
|
|
1456
|
+
prompt_cache_policy_metadata: Mapping[str, Any] | None = None,
|
|
1457
|
+
provider_concurrency_caps: dict[str, int] | None = None,
|
|
1458
|
+
provider_retry_settings: ProviderRetrySettings | None = None,
|
|
1459
|
+
provider_sleep_fn: Callable[[float], None] | None = None,
|
|
1460
|
+
provider_random_fn: Callable[[], float] | None = None,
|
|
1461
|
+
default_max_tokens: int = 4096,
|
|
1462
|
+
usage_contract: UsageContract | None = None,
|
|
1463
|
+
route_identity: ProviderRouteIdentity | None = None,
|
|
1464
|
+
) -> None:
|
|
1465
|
+
self.base_url = base_url.rstrip("/")
|
|
1466
|
+
self.api_key = api_key
|
|
1467
|
+
self.model = model
|
|
1468
|
+
self.timeout_s = timeout_s
|
|
1469
|
+
self.temperature = temperature
|
|
1470
|
+
self.prompt_cache_key = str(prompt_cache_key or "").strip() or None
|
|
1471
|
+
self.prompt_cache_retention = str(prompt_cache_retention or "").strip() or None
|
|
1472
|
+
self.enable_thinking = enable_thinking
|
|
1473
|
+
self.reasoning_effort = str(reasoning_effort or "").strip().lower() or None
|
|
1474
|
+
self._transport = transport
|
|
1475
|
+
self.extra_headers = canonicalize_extra_headers(extra_headers)
|
|
1476
|
+
self.provider_key = str(provider_key or "").strip() or None
|
|
1477
|
+
self.route_identity = route_identity or build_provider_route_identity(
|
|
1478
|
+
protocol="anthropic_messages",
|
|
1479
|
+
base_url=self.base_url,
|
|
1480
|
+
provider_key=self.provider_key,
|
|
1481
|
+
model=self.model,
|
|
1482
|
+
credential_scope=credential_scope_fingerprint(self.api_key),
|
|
1483
|
+
routing_headers=self.extra_headers,
|
|
1484
|
+
protocol_revision=ANTHROPIC_MESSAGES_ROUTE_REVISION,
|
|
1485
|
+
)
|
|
1486
|
+
self.web_search_mode = str(web_search_mode or "off").strip().lower()
|
|
1487
|
+
self.web_search_adapter = (
|
|
1488
|
+
str(web_search_adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower()
|
|
1489
|
+
or AUTO_WEB_SEARCH_ADAPTER
|
|
1490
|
+
)
|
|
1491
|
+
self.prompt_cache_control_enabled = bool(prompt_cache_control_enabled)
|
|
1492
|
+
self.prompt_cache_control_ttl = _normalize_prompt_cache_control_ttl(
|
|
1493
|
+
prompt_cache_control_ttl
|
|
1494
|
+
)
|
|
1495
|
+
self.prompt_cache_policy_metadata = (
|
|
1496
|
+
copy.deepcopy(dict(prompt_cache_policy_metadata))
|
|
1497
|
+
if isinstance(prompt_cache_policy_metadata, Mapping)
|
|
1498
|
+
else None
|
|
1499
|
+
)
|
|
1500
|
+
self.provider_concurrency_caps = dict(
|
|
1501
|
+
DEFAULT_PROVIDER_CONCURRENCY_CAPS
|
|
1502
|
+
if provider_concurrency_caps is None
|
|
1503
|
+
else provider_concurrency_caps
|
|
1504
|
+
)
|
|
1505
|
+
self.provider_retry_settings = provider_retry_settings or ProviderRetrySettings()
|
|
1506
|
+
self._provider_sleep_fn = provider_sleep_fn
|
|
1507
|
+
self._provider_random_fn = provider_random_fn
|
|
1508
|
+
self.default_max_tokens = int(default_max_tokens)
|
|
1509
|
+
self.usage_contract = usage_contract or type(self).usage_contract
|
|
1510
|
+
self.usage_counts_authoritative = self.usage_contract.response_usage_authoritative
|
|
1511
|
+
self._input_token_count_available: bool | None = None
|
|
1512
|
+
self._temperature_omit_after_rejection = False
|
|
1513
|
+
self._thinking_display_supported: bool | None = None
|
|
1514
|
+
|
|
1515
|
+
def _headers(self) -> dict[str, str]:
|
|
1516
|
+
headers = merge_canonical_headers(
|
|
1517
|
+
{
|
|
1518
|
+
"x-api-key": self.api_key,
|
|
1519
|
+
"anthropic-version": _DEFAULT_ANTHROPIC_VERSION,
|
|
1520
|
+
"Content-Type": "application/json",
|
|
1521
|
+
"User-Agent": "alysis-code/0.1.0",
|
|
1522
|
+
},
|
|
1523
|
+
self.extra_headers,
|
|
1524
|
+
)
|
|
1525
|
+
return _headers_with_default_accept_encoding(headers)
|
|
1526
|
+
|
|
1527
|
+
@staticmethod
|
|
1528
|
+
def _llm_error_from_response(response: httpx.Response) -> LLMError:
|
|
1529
|
+
try:
|
|
1530
|
+
data = response.json()
|
|
1531
|
+
except Exception:
|
|
1532
|
+
body = response.text
|
|
1533
|
+
if len(body) > 1000:
|
|
1534
|
+
body = body[:1000] + "...(truncated)"
|
|
1535
|
+
return LLMError(
|
|
1536
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {body}")
|
|
1537
|
+
)
|
|
1538
|
+
error_message = _extract_error_message(data)
|
|
1539
|
+
if error_message:
|
|
1540
|
+
return LLMError(
|
|
1541
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {error_message}")
|
|
1542
|
+
)
|
|
1543
|
+
return LLMError(
|
|
1544
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {data!r}")
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
def count_input_tokens(
|
|
1548
|
+
self,
|
|
1549
|
+
*,
|
|
1550
|
+
messages: list[dict[str, Any]],
|
|
1551
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1552
|
+
tool_choice: Any | None = None,
|
|
1553
|
+
) -> InputTokenCount | None:
|
|
1554
|
+
if self._input_token_count_available is False:
|
|
1555
|
+
return None
|
|
1556
|
+
messages = gate_messages_for_provider_route(messages, self.route_identity)
|
|
1557
|
+
system, anthropic_messages = _anthropic_messages_from_messages(messages)
|
|
1558
|
+
tool_mapping = _anthropic_tools(
|
|
1559
|
+
tools,
|
|
1560
|
+
mode=self.web_search_mode,
|
|
1561
|
+
adapter=self.web_search_adapter,
|
|
1562
|
+
)
|
|
1563
|
+
payload: dict[str, Any] = {
|
|
1564
|
+
"model": self.model,
|
|
1565
|
+
"messages": anthropic_messages,
|
|
1566
|
+
}
|
|
1567
|
+
thinking_plan = _anthropic_thinking_plan(
|
|
1568
|
+
model=self.model,
|
|
1569
|
+
enable_thinking=self.enable_thinking,
|
|
1570
|
+
reasoning_effort=self.reasoning_effort,
|
|
1571
|
+
max_tokens=self.default_max_tokens,
|
|
1572
|
+
request_summary=False,
|
|
1573
|
+
)
|
|
1574
|
+
if thinking_plan.config is not None:
|
|
1575
|
+
payload["thinking"] = copy.deepcopy(thinking_plan.config)
|
|
1576
|
+
if self._thinking_display_supported is False:
|
|
1577
|
+
payload = _without_thinking_display(payload)
|
|
1578
|
+
if system:
|
|
1579
|
+
payload["system"] = system
|
|
1580
|
+
if tool_mapping.tools:
|
|
1581
|
+
payload["tools"] = tool_mapping.tools
|
|
1582
|
+
mapped_tool_choice = _anthropic_tool_choice(
|
|
1583
|
+
tool_choice,
|
|
1584
|
+
removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
|
|
1585
|
+
added_builtin_web_search=tool_mapping.added_builtin_web_search,
|
|
1586
|
+
)
|
|
1587
|
+
if mapped_tool_choice is not None:
|
|
1588
|
+
if not (thinking_plan.active and mapped_tool_choice.get("type") in {"any", "tool"}):
|
|
1589
|
+
payload["tool_choice"] = mapped_tool_choice
|
|
1590
|
+
count_cache_plan = RequestCachePlan(
|
|
1591
|
+
strategy=("anthropic_cache_control" if self.prompt_cache_control_enabled else "none"),
|
|
1592
|
+
mode="automatic" if self.prompt_cache_control_enabled else "manual",
|
|
1593
|
+
anthropic_cache_control_enabled=self.prompt_cache_control_enabled,
|
|
1594
|
+
anthropic_cache_control_ttl=self.prompt_cache_control_ttl,
|
|
1595
|
+
)
|
|
1596
|
+
_apply_anthropic_cache_control_plan(
|
|
1597
|
+
payload=payload,
|
|
1598
|
+
cache_control=count_cache_plan.anthropic_cache_control_payload(),
|
|
1599
|
+
cache_policy=merge_cache_policy_metadata(
|
|
1600
|
+
self.prompt_cache_policy_metadata,
|
|
1601
|
+
count_cache_plan.anthropic_cache_policy_metadata(),
|
|
1602
|
+
),
|
|
1603
|
+
)
|
|
1604
|
+
url = f"{self.base_url}/messages/count_tokens"
|
|
1605
|
+
|
|
1606
|
+
def _send_request() -> InputTokenCount | None:
|
|
1607
|
+
try:
|
|
1608
|
+
with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
|
|
1609
|
+
response = client.post(url, headers=self._headers(), json=payload)
|
|
1610
|
+
except httpx.HTTPError as exc:
|
|
1611
|
+
raise LLMError(
|
|
1612
|
+
"Anthropic input token count request failed: "
|
|
1613
|
+
f"{sanitize_error_text_for_output(exc)}"
|
|
1614
|
+
) from exc
|
|
1615
|
+
if response.status_code in {404, 405, 501}:
|
|
1616
|
+
self._input_token_count_available = False
|
|
1617
|
+
return None
|
|
1618
|
+
if response.status_code >= 400:
|
|
1619
|
+
raise self._llm_error_from_response(response)
|
|
1620
|
+
try:
|
|
1621
|
+
data = response.json()
|
|
1622
|
+
except Exception as exc: # noqa: BLE001
|
|
1623
|
+
raise LLMError("Anthropic input token count returned non-JSON response") from exc
|
|
1624
|
+
count = _non_negative_int(data.get("input_tokens") if isinstance(data, dict) else None)
|
|
1625
|
+
if count is None:
|
|
1626
|
+
raise LLMError("Anthropic input token count response omitted input_tokens")
|
|
1627
|
+
self._input_token_count_available = True
|
|
1628
|
+
return InputTokenCount(
|
|
1629
|
+
input_tokens=count,
|
|
1630
|
+
raw_provider_usage=copy.deepcopy(data),
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
return run_provider_limited_call(
|
|
1634
|
+
call=_send_request,
|
|
1635
|
+
provider_key=self.provider_key,
|
|
1636
|
+
provider_concurrency_caps=self.provider_concurrency_caps,
|
|
1637
|
+
retry_settings=self.provider_retry_settings,
|
|
1638
|
+
operation="anthropic_messages_count_input_tokens",
|
|
1639
|
+
sleep_fn=self._provider_sleep_fn,
|
|
1640
|
+
random_fn=self._provider_random_fn,
|
|
1641
|
+
retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
def chat(
|
|
1645
|
+
self,
|
|
1646
|
+
*,
|
|
1647
|
+
messages: list[dict[str, Any]],
|
|
1648
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1649
|
+
tool_choice: Any | None = None,
|
|
1650
|
+
response_format: dict[str, Any] | None = None,
|
|
1651
|
+
stream: bool = False,
|
|
1652
|
+
on_text_delta: Callable[[str], None] | None = None,
|
|
1653
|
+
on_reasoning_delta: Callable[[str], None] | None = None,
|
|
1654
|
+
temperature: float | None = None,
|
|
1655
|
+
max_tokens: int | None = None,
|
|
1656
|
+
request_plan: LLMRequestPlan | None = None,
|
|
1657
|
+
) -> LLMResponse:
|
|
1658
|
+
default_cache = RequestCachePlan(
|
|
1659
|
+
strategy="anthropic_cache_control" if self.prompt_cache_control_enabled else "none",
|
|
1660
|
+
mode="automatic" if self.prompt_cache_control_enabled else "manual",
|
|
1661
|
+
anthropic_cache_control_enabled=self.prompt_cache_control_enabled,
|
|
1662
|
+
anthropic_cache_control_ttl=self.prompt_cache_control_ttl,
|
|
1663
|
+
)
|
|
1664
|
+
plan = request_plan or LLMRequestPlan.from_chat_args(
|
|
1665
|
+
messages=messages,
|
|
1666
|
+
tools=tools,
|
|
1667
|
+
tool_choice=tool_choice,
|
|
1668
|
+
response_format=response_format,
|
|
1669
|
+
stream=stream,
|
|
1670
|
+
temperature=temperature,
|
|
1671
|
+
max_tokens=max_tokens,
|
|
1672
|
+
cache=default_cache,
|
|
1673
|
+
)
|
|
1674
|
+
if (
|
|
1675
|
+
request_plan is not None
|
|
1676
|
+
and plan.cache.mode != "off"
|
|
1677
|
+
and plan.cache.strategy == "none"
|
|
1678
|
+
and not plan.cache.anthropic_cache_control_enabled
|
|
1679
|
+
and self.prompt_cache_control_enabled
|
|
1680
|
+
):
|
|
1681
|
+
plan = plan.with_cache(default_cache)
|
|
1682
|
+
messages = gate_messages_for_provider_route(plan.message_list(), self.route_identity)
|
|
1683
|
+
tools = plan.tool_list()
|
|
1684
|
+
tool_choice = plan.tool_choice
|
|
1685
|
+
response_format = plan.response_format
|
|
1686
|
+
stream = plan.stream
|
|
1687
|
+
temperature = plan.temperature
|
|
1688
|
+
max_tokens = plan.max_tokens
|
|
1689
|
+
if response_format is not None:
|
|
1690
|
+
raise LLMError("Anthropic Messages does not support response_format")
|
|
1691
|
+
|
|
1692
|
+
system, anthropic_messages = _anthropic_messages_from_messages(messages)
|
|
1693
|
+
tool_mapping = _anthropic_tools(
|
|
1694
|
+
tools,
|
|
1695
|
+
mode=self.web_search_mode,
|
|
1696
|
+
adapter=self.web_search_adapter,
|
|
1697
|
+
)
|
|
1698
|
+
effective_max_tokens = (
|
|
1699
|
+
int(max_tokens) if max_tokens is not None else self.default_max_tokens
|
|
1700
|
+
)
|
|
1701
|
+
thinking_plan = _anthropic_thinking_plan(
|
|
1702
|
+
model=self.model,
|
|
1703
|
+
enable_thinking=self.enable_thinking,
|
|
1704
|
+
reasoning_effort=self.reasoning_effort,
|
|
1705
|
+
max_tokens=effective_max_tokens,
|
|
1706
|
+
request_summary=(
|
|
1707
|
+
on_reasoning_delta is not None and self._thinking_display_supported is not False
|
|
1708
|
+
),
|
|
1709
|
+
)
|
|
1710
|
+
temperature_omit_reason = documented_temperature_omit_reason(self.model)
|
|
1711
|
+
if thinking_plan.active:
|
|
1712
|
+
temperature_omit_reason = "anthropic_extended_thinking_temperature_unsupported"
|
|
1713
|
+
if self._temperature_omit_after_rejection and temperature_omit_reason is None:
|
|
1714
|
+
temperature_omit_reason = "provider_rejected_parameter"
|
|
1715
|
+
payload: dict[str, Any] = {
|
|
1716
|
+
"model": self.model,
|
|
1717
|
+
"max_tokens": effective_max_tokens,
|
|
1718
|
+
"messages": anthropic_messages,
|
|
1719
|
+
}
|
|
1720
|
+
if thinking_plan.config is not None:
|
|
1721
|
+
payload["thinking"] = copy.deepcopy(thinking_plan.config)
|
|
1722
|
+
if self._thinking_display_supported is False:
|
|
1723
|
+
payload = _without_thinking_display(payload)
|
|
1724
|
+
if thinking_plan.output_effort is not None:
|
|
1725
|
+
payload["output_config"] = {"effort": thinking_plan.output_effort}
|
|
1726
|
+
if temperature_omit_reason is None:
|
|
1727
|
+
payload["temperature"] = self.temperature if temperature is None else float(temperature)
|
|
1728
|
+
cache_control = plan.cache.anthropic_cache_control_payload()
|
|
1729
|
+
cache_policy = merge_cache_policy_metadata(
|
|
1730
|
+
self.prompt_cache_policy_metadata,
|
|
1731
|
+
plan.cache.anthropic_cache_policy_metadata(),
|
|
1732
|
+
)
|
|
1733
|
+
if stream:
|
|
1734
|
+
payload["stream"] = True
|
|
1735
|
+
if system:
|
|
1736
|
+
payload["system"] = system
|
|
1737
|
+
forced_tool_choice_omitted = False
|
|
1738
|
+
if tool_mapping.tools:
|
|
1739
|
+
payload["tools"] = tool_mapping.tools
|
|
1740
|
+
mapped_tool_choice = _anthropic_tool_choice(
|
|
1741
|
+
tool_choice,
|
|
1742
|
+
removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
|
|
1743
|
+
added_builtin_web_search=tool_mapping.added_builtin_web_search,
|
|
1744
|
+
)
|
|
1745
|
+
if mapped_tool_choice is not None:
|
|
1746
|
+
if thinking_plan.active and mapped_tool_choice.get("type") in {"any", "tool"}:
|
|
1747
|
+
# Anthropic rejects forced tool use while extended thinking
|
|
1748
|
+
# is active. Leaving the choice unset preserves automatic
|
|
1749
|
+
# tool selection without failing the entire turn.
|
|
1750
|
+
forced_tool_choice_omitted = True
|
|
1751
|
+
else:
|
|
1752
|
+
payload["tool_choice"] = mapped_tool_choice
|
|
1753
|
+
elif tool_choice is not None:
|
|
1754
|
+
raise LLMError("Anthropic Messages tool_choice requires at least one available tool")
|
|
1755
|
+
|
|
1756
|
+
_apply_anthropic_cache_control_plan(
|
|
1757
|
+
payload=payload,
|
|
1758
|
+
cache_control=cache_control,
|
|
1759
|
+
cache_policy=cache_policy,
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
def _prompt_estimation_payload(current_payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
1763
|
+
estimation_payload = {
|
|
1764
|
+
"messages": current_payload.get("messages", []),
|
|
1765
|
+
}
|
|
1766
|
+
for key in ("system", "tools", "cache_control"):
|
|
1767
|
+
if key in current_payload:
|
|
1768
|
+
estimation_payload[key] = current_payload[key]
|
|
1769
|
+
return estimation_payload
|
|
1770
|
+
|
|
1771
|
+
def _request_shape_metadata(
|
|
1772
|
+
current_payload: Mapping[str, Any],
|
|
1773
|
+
*,
|
|
1774
|
+
input_mode: str = "full",
|
|
1775
|
+
) -> dict[str, Any]:
|
|
1776
|
+
return build_request_shape_report(
|
|
1777
|
+
messages=messages,
|
|
1778
|
+
tools=tools,
|
|
1779
|
+
cache_policy=cache_policy,
|
|
1780
|
+
provider_payload=_prompt_estimation_payload(current_payload),
|
|
1781
|
+
input_mode=input_mode,
|
|
1782
|
+
)
|
|
1783
|
+
|
|
1784
|
+
def _token_reconciliation_metadata(
|
|
1785
|
+
current_payload: Mapping[str, Any],
|
|
1786
|
+
*,
|
|
1787
|
+
input_mode: str = "full",
|
|
1788
|
+
) -> dict[str, Any]:
|
|
1789
|
+
input_estimate_tokens = estimate_provider_payload_tokens(
|
|
1790
|
+
_prompt_estimation_payload(current_payload)
|
|
1791
|
+
)
|
|
1792
|
+
return {
|
|
1793
|
+
"input_estimate_tokens": input_estimate_tokens,
|
|
1794
|
+
"sent_input_estimate_tokens": input_estimate_tokens,
|
|
1795
|
+
"estimator": "cl100k_base",
|
|
1796
|
+
"estimate_basis": "provider_prompt_payload",
|
|
1797
|
+
"input_mode": input_mode,
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
request_shape = _request_shape_metadata(payload)
|
|
1801
|
+
token_reconciliation = _token_reconciliation_metadata(payload)
|
|
1802
|
+
request_plan_extra: dict[str, Any] = {}
|
|
1803
|
+
if temperature_omit_reason is not None:
|
|
1804
|
+
request_plan_extra.update(
|
|
1805
|
+
{
|
|
1806
|
+
"temperature_omitted": True,
|
|
1807
|
+
"temperature_omit_reason": temperature_omit_reason,
|
|
1808
|
+
}
|
|
1809
|
+
)
|
|
1810
|
+
payload_thinking = payload.get("thinking")
|
|
1811
|
+
if isinstance(payload_thinking, Mapping):
|
|
1812
|
+
request_plan_extra.update(
|
|
1813
|
+
{
|
|
1814
|
+
"thinking_mode": payload_thinking.get("type"),
|
|
1815
|
+
"thinking_display": payload_thinking.get("display"),
|
|
1816
|
+
"reasoning_summary_requested": _payload_requests_summarized_thinking(payload),
|
|
1817
|
+
}
|
|
1818
|
+
)
|
|
1819
|
+
if forced_tool_choice_omitted:
|
|
1820
|
+
request_plan_extra.update(
|
|
1821
|
+
{
|
|
1822
|
+
"tool_choice_omitted": True,
|
|
1823
|
+
"tool_choice_omit_reason": "anthropic_extended_thinking_forced_tool_unsupported",
|
|
1824
|
+
}
|
|
1825
|
+
)
|
|
1826
|
+
request_plan_metadata = plan.request_plan_metadata(
|
|
1827
|
+
input_mode="full",
|
|
1828
|
+
continuation_strategy="full_replay",
|
|
1829
|
+
provider_payload=_prompt_estimation_payload(payload),
|
|
1830
|
+
sent_provider_payload=_prompt_estimation_payload(payload),
|
|
1831
|
+
cache_policy_metadata=cache_policy,
|
|
1832
|
+
extra=request_plan_extra or None,
|
|
1833
|
+
)
|
|
1834
|
+
|
|
1835
|
+
provider_key = self.provider_key or best_effort_provider_key(
|
|
1836
|
+
base_url=self.base_url,
|
|
1837
|
+
model=self.model,
|
|
1838
|
+
)
|
|
1839
|
+
telemetry = ProviderCallTelemetryRecorder(
|
|
1840
|
+
provider_key=provider_key,
|
|
1841
|
+
protocol="anthropic_messages",
|
|
1842
|
+
model=self.model,
|
|
1843
|
+
base_url=self.base_url,
|
|
1844
|
+
stream=stream,
|
|
1845
|
+
tools=tools,
|
|
1846
|
+
web_search_mode=self.web_search_mode,
|
|
1847
|
+
web_search_adapter=self.web_search_adapter,
|
|
1848
|
+
native_web_search=tool_mapping.added_builtin_web_search,
|
|
1849
|
+
cache_policy=cache_policy,
|
|
1850
|
+
request_plan=request_plan_metadata,
|
|
1851
|
+
request_shape=request_shape,
|
|
1852
|
+
token_reconciliation=token_reconciliation,
|
|
1853
|
+
operation="anthropic_messages_chat",
|
|
1854
|
+
)
|
|
1855
|
+
telemetry_on_text_delta = telemetry.wrap_text_delta(on_text_delta)
|
|
1856
|
+
telemetry_on_reasoning_delta = telemetry.wrap_reasoning_delta(on_reasoning_delta)
|
|
1857
|
+
public_output_emitted = False
|
|
1858
|
+
|
|
1859
|
+
def _tracked_text_delta(delta: str) -> None:
|
|
1860
|
+
nonlocal public_output_emitted
|
|
1861
|
+
if delta:
|
|
1862
|
+
public_output_emitted = True
|
|
1863
|
+
if telemetry_on_text_delta is not None:
|
|
1864
|
+
telemetry_on_text_delta(delta)
|
|
1865
|
+
|
|
1866
|
+
def _tracked_reasoning_delta(delta: str) -> None:
|
|
1867
|
+
nonlocal public_output_emitted
|
|
1868
|
+
if delta:
|
|
1869
|
+
public_output_emitted = True
|
|
1870
|
+
if telemetry_on_reasoning_delta is not None:
|
|
1871
|
+
telemetry_on_reasoning_delta(delta)
|
|
1872
|
+
|
|
1873
|
+
def _activate_reasoning_summary_fallback(
|
|
1874
|
+
current_payload: Mapping[str, Any],
|
|
1875
|
+
*,
|
|
1876
|
+
reason: str,
|
|
1877
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
1878
|
+
downgraded_payload = _without_thinking_display(current_payload)
|
|
1879
|
+
fallback_plan = plan.request_plan_metadata(
|
|
1880
|
+
input_mode="reasoning_summary_fallback",
|
|
1881
|
+
continuation_strategy="full_replay",
|
|
1882
|
+
provider_payload=_prompt_estimation_payload(downgraded_payload),
|
|
1883
|
+
sent_provider_payload=_prompt_estimation_payload(downgraded_payload),
|
|
1884
|
+
cache_policy_metadata=cache_policy,
|
|
1885
|
+
extra={
|
|
1886
|
+
"fallback_used": True,
|
|
1887
|
+
"thinking_display_omitted": True,
|
|
1888
|
+
"reasoning_summary_requested": False,
|
|
1889
|
+
"reasoning_summary_fallback_reason": reason,
|
|
1890
|
+
},
|
|
1891
|
+
)
|
|
1892
|
+
telemetry.set_request_plan(fallback_plan)
|
|
1893
|
+
telemetry.set_request_shape(
|
|
1894
|
+
_request_shape_metadata(
|
|
1895
|
+
downgraded_payload,
|
|
1896
|
+
input_mode="reasoning_summary_fallback",
|
|
1897
|
+
)
|
|
1898
|
+
)
|
|
1899
|
+
telemetry.set_token_reconciliation(
|
|
1900
|
+
_token_reconciliation_metadata(
|
|
1901
|
+
downgraded_payload,
|
|
1902
|
+
input_mode="reasoning_summary_fallback",
|
|
1903
|
+
)
|
|
1904
|
+
)
|
|
1905
|
+
return downgraded_payload, fallback_plan
|
|
1906
|
+
|
|
1907
|
+
def _send_request() -> LLMResponse:
|
|
1908
|
+
url = f"{self.base_url}/messages"
|
|
1909
|
+
try:
|
|
1910
|
+
with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
|
|
1911
|
+
request_payload = payload
|
|
1912
|
+
active_request_plan_metadata = request_plan_metadata
|
|
1913
|
+
if self._thinking_display_supported is False and (
|
|
1914
|
+
_payload_requests_summarized_thinking(request_payload)
|
|
1915
|
+
):
|
|
1916
|
+
request_payload, active_request_plan_metadata = (
|
|
1917
|
+
_activate_reasoning_summary_fallback(
|
|
1918
|
+
request_payload,
|
|
1919
|
+
reason="cached_provider_rejection",
|
|
1920
|
+
)
|
|
1921
|
+
)
|
|
1922
|
+
cache_control_retry_used = False
|
|
1923
|
+
temperature_retry_used = False
|
|
1924
|
+
thinking_display_retry_used = False
|
|
1925
|
+
while True:
|
|
1926
|
+
if stream:
|
|
1927
|
+
with client.stream(
|
|
1928
|
+
"POST",
|
|
1929
|
+
url,
|
|
1930
|
+
headers=self._headers(),
|
|
1931
|
+
json=request_payload,
|
|
1932
|
+
) as response:
|
|
1933
|
+
if response.status_code >= 400:
|
|
1934
|
+
response.read()
|
|
1935
|
+
thinking_display_rejection = (
|
|
1936
|
+
_thinking_display_rejection_reason(response)
|
|
1937
|
+
if not thinking_display_retry_used
|
|
1938
|
+
and _payload_requests_summarized_thinking(request_payload)
|
|
1939
|
+
else None
|
|
1940
|
+
)
|
|
1941
|
+
if thinking_display_rejection is not None:
|
|
1942
|
+
thinking_display_retry_used = True
|
|
1943
|
+
self._thinking_display_supported = False
|
|
1944
|
+
request_payload, active_request_plan_metadata = (
|
|
1945
|
+
_activate_reasoning_summary_fallback(
|
|
1946
|
+
request_payload,
|
|
1947
|
+
reason=thinking_display_rejection,
|
|
1948
|
+
)
|
|
1949
|
+
)
|
|
1950
|
+
continue
|
|
1951
|
+
temperature_rejection = (
|
|
1952
|
+
_temperature_rejection_reason(response)
|
|
1953
|
+
if not temperature_retry_used
|
|
1954
|
+
and "temperature" in request_payload
|
|
1955
|
+
else None
|
|
1956
|
+
)
|
|
1957
|
+
if temperature_rejection is not None:
|
|
1958
|
+
temperature_retry_used = True
|
|
1959
|
+
self._temperature_omit_after_rejection = True
|
|
1960
|
+
request_payload = copy.deepcopy(dict(request_payload))
|
|
1961
|
+
request_payload.pop("temperature", None)
|
|
1962
|
+
active_request_plan_metadata = plan.request_plan_metadata(
|
|
1963
|
+
input_mode="temperature_fallback",
|
|
1964
|
+
continuation_strategy="full_replay",
|
|
1965
|
+
provider_payload=_prompt_estimation_payload(
|
|
1966
|
+
request_payload
|
|
1967
|
+
),
|
|
1968
|
+
sent_provider_payload=_prompt_estimation_payload(
|
|
1969
|
+
request_payload
|
|
1970
|
+
),
|
|
1971
|
+
cache_policy_metadata=cache_policy,
|
|
1972
|
+
extra={
|
|
1973
|
+
"fallback_used": True,
|
|
1974
|
+
"temperature_omitted": True,
|
|
1975
|
+
"temperature_omit_reason": (temperature_rejection),
|
|
1976
|
+
},
|
|
1977
|
+
)
|
|
1978
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
1979
|
+
telemetry.set_request_shape(
|
|
1980
|
+
_request_shape_metadata(
|
|
1981
|
+
request_payload,
|
|
1982
|
+
input_mode="temperature_fallback",
|
|
1983
|
+
)
|
|
1984
|
+
)
|
|
1985
|
+
telemetry.set_token_reconciliation(
|
|
1986
|
+
_token_reconciliation_metadata(
|
|
1987
|
+
request_payload,
|
|
1988
|
+
input_mode="temperature_fallback",
|
|
1989
|
+
)
|
|
1990
|
+
)
|
|
1991
|
+
continue
|
|
1992
|
+
downgrade_reason = (
|
|
1993
|
+
_cache_control_rejection_reason(response)
|
|
1994
|
+
if not cache_control_retry_used
|
|
1995
|
+
and _payload_has_cache_control(request_payload)
|
|
1996
|
+
else None
|
|
1997
|
+
)
|
|
1998
|
+
if downgrade_reason is not None:
|
|
1999
|
+
cache_control_retry_used = True
|
|
2000
|
+
request_payload = (
|
|
2001
|
+
_downgrade_anthropic_cache_control_payload(
|
|
2002
|
+
request_payload
|
|
2003
|
+
)
|
|
2004
|
+
)
|
|
2005
|
+
_mark_anthropic_cache_control_downgrade(
|
|
2006
|
+
cache_policy,
|
|
2007
|
+
reason=downgrade_reason,
|
|
2008
|
+
)
|
|
2009
|
+
telemetry.set_cache_policy(cache_policy)
|
|
2010
|
+
active_request_plan_metadata = plan.request_plan_metadata(
|
|
2011
|
+
input_mode="cache_control_fallback",
|
|
2012
|
+
continuation_strategy="full_replay",
|
|
2013
|
+
provider_payload=_prompt_estimation_payload(
|
|
2014
|
+
request_payload
|
|
2015
|
+
),
|
|
2016
|
+
sent_provider_payload=_prompt_estimation_payload(
|
|
2017
|
+
request_payload
|
|
2018
|
+
),
|
|
2019
|
+
cache_policy_metadata=cache_policy,
|
|
2020
|
+
extra={"fallback_used": True},
|
|
2021
|
+
)
|
|
2022
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2023
|
+
telemetry.set_request_shape(
|
|
2024
|
+
_request_shape_metadata(
|
|
2025
|
+
request_payload,
|
|
2026
|
+
input_mode="cache_control_fallback",
|
|
2027
|
+
)
|
|
2028
|
+
)
|
|
2029
|
+
telemetry.set_token_reconciliation(
|
|
2030
|
+
_token_reconciliation_metadata(
|
|
2031
|
+
request_payload,
|
|
2032
|
+
input_mode="cache_control_fallback",
|
|
2033
|
+
)
|
|
2034
|
+
)
|
|
2035
|
+
continue
|
|
2036
|
+
raise self._llm_error_from_response(response)
|
|
2037
|
+
return _response_with_cache_policy_metadata(
|
|
2038
|
+
self._parse_stream_response(
|
|
2039
|
+
response,
|
|
2040
|
+
on_text_delta=(
|
|
2041
|
+
_tracked_text_delta
|
|
2042
|
+
if telemetry_on_text_delta is not None
|
|
2043
|
+
else None
|
|
2044
|
+
),
|
|
2045
|
+
on_reasoning_delta=(
|
|
2046
|
+
_tracked_reasoning_delta
|
|
2047
|
+
if telemetry_on_reasoning_delta is not None
|
|
2048
|
+
else None
|
|
2049
|
+
),
|
|
2050
|
+
reasoning_is_summary=(
|
|
2051
|
+
_payload_requests_summarized_thinking(request_payload)
|
|
2052
|
+
),
|
|
2053
|
+
),
|
|
2054
|
+
cache_policy,
|
|
2055
|
+
active_request_plan_metadata,
|
|
2056
|
+
)
|
|
2057
|
+
response = client.post(
|
|
2058
|
+
url,
|
|
2059
|
+
headers=self._headers(),
|
|
2060
|
+
json=request_payload,
|
|
2061
|
+
)
|
|
2062
|
+
if response.status_code < 400:
|
|
2063
|
+
break
|
|
2064
|
+
thinking_display_rejection = (
|
|
2065
|
+
_thinking_display_rejection_reason(response)
|
|
2066
|
+
if not thinking_display_retry_used
|
|
2067
|
+
and _payload_requests_summarized_thinking(request_payload)
|
|
2068
|
+
else None
|
|
2069
|
+
)
|
|
2070
|
+
if thinking_display_rejection is not None:
|
|
2071
|
+
thinking_display_retry_used = True
|
|
2072
|
+
self._thinking_display_supported = False
|
|
2073
|
+
request_payload, active_request_plan_metadata = (
|
|
2074
|
+
_activate_reasoning_summary_fallback(
|
|
2075
|
+
request_payload,
|
|
2076
|
+
reason=thinking_display_rejection,
|
|
2077
|
+
)
|
|
2078
|
+
)
|
|
2079
|
+
continue
|
|
2080
|
+
temperature_rejection = (
|
|
2081
|
+
_temperature_rejection_reason(response)
|
|
2082
|
+
if not temperature_retry_used and "temperature" in request_payload
|
|
2083
|
+
else None
|
|
2084
|
+
)
|
|
2085
|
+
if temperature_rejection is not None:
|
|
2086
|
+
temperature_retry_used = True
|
|
2087
|
+
self._temperature_omit_after_rejection = True
|
|
2088
|
+
request_payload = copy.deepcopy(dict(request_payload))
|
|
2089
|
+
request_payload.pop("temperature", None)
|
|
2090
|
+
active_request_plan_metadata = plan.request_plan_metadata(
|
|
2091
|
+
input_mode="temperature_fallback",
|
|
2092
|
+
continuation_strategy="full_replay",
|
|
2093
|
+
provider_payload=_prompt_estimation_payload(request_payload),
|
|
2094
|
+
sent_provider_payload=_prompt_estimation_payload(request_payload),
|
|
2095
|
+
cache_policy_metadata=cache_policy,
|
|
2096
|
+
extra={
|
|
2097
|
+
"fallback_used": True,
|
|
2098
|
+
"temperature_omitted": True,
|
|
2099
|
+
"temperature_omit_reason": temperature_rejection,
|
|
2100
|
+
},
|
|
2101
|
+
)
|
|
2102
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2103
|
+
telemetry.set_request_shape(
|
|
2104
|
+
_request_shape_metadata(
|
|
2105
|
+
request_payload,
|
|
2106
|
+
input_mode="temperature_fallback",
|
|
2107
|
+
)
|
|
2108
|
+
)
|
|
2109
|
+
telemetry.set_token_reconciliation(
|
|
2110
|
+
_token_reconciliation_metadata(
|
|
2111
|
+
request_payload,
|
|
2112
|
+
input_mode="temperature_fallback",
|
|
2113
|
+
)
|
|
2114
|
+
)
|
|
2115
|
+
continue
|
|
2116
|
+
downgrade_reason = (
|
|
2117
|
+
_cache_control_rejection_reason(response)
|
|
2118
|
+
if not cache_control_retry_used
|
|
2119
|
+
and _payload_has_cache_control(request_payload)
|
|
2120
|
+
else None
|
|
2121
|
+
)
|
|
2122
|
+
if downgrade_reason is not None:
|
|
2123
|
+
cache_control_retry_used = True
|
|
2124
|
+
request_payload = _downgrade_anthropic_cache_control_payload(
|
|
2125
|
+
request_payload
|
|
2126
|
+
)
|
|
2127
|
+
_mark_anthropic_cache_control_downgrade(
|
|
2128
|
+
cache_policy,
|
|
2129
|
+
reason=downgrade_reason,
|
|
2130
|
+
)
|
|
2131
|
+
telemetry.set_cache_policy(cache_policy)
|
|
2132
|
+
active_request_plan_metadata = plan.request_plan_metadata(
|
|
2133
|
+
input_mode="cache_control_fallback",
|
|
2134
|
+
continuation_strategy="full_replay",
|
|
2135
|
+
provider_payload=_prompt_estimation_payload(request_payload),
|
|
2136
|
+
sent_provider_payload=_prompt_estimation_payload(request_payload),
|
|
2137
|
+
cache_policy_metadata=cache_policy,
|
|
2138
|
+
extra={"fallback_used": True},
|
|
2139
|
+
)
|
|
2140
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2141
|
+
telemetry.set_request_shape(
|
|
2142
|
+
_request_shape_metadata(
|
|
2143
|
+
request_payload,
|
|
2144
|
+
input_mode="cache_control_fallback",
|
|
2145
|
+
)
|
|
2146
|
+
)
|
|
2147
|
+
telemetry.set_token_reconciliation(
|
|
2148
|
+
_token_reconciliation_metadata(
|
|
2149
|
+
request_payload,
|
|
2150
|
+
input_mode="cache_control_fallback",
|
|
2151
|
+
)
|
|
2152
|
+
)
|
|
2153
|
+
continue
|
|
2154
|
+
break
|
|
2155
|
+
except httpx.DecodingError as e:
|
|
2156
|
+
err = LLMError(
|
|
2157
|
+
f"Anthropic Messages decompression failed: {sanitize_error_text_for_output(e)}"
|
|
2158
|
+
)
|
|
2159
|
+
if stream and public_output_emitted:
|
|
2160
|
+
mark_provider_call_non_retryable(err)
|
|
2161
|
+
raise err from e
|
|
2162
|
+
except Exception as e: # noqa: BLE001
|
|
2163
|
+
if isinstance(e, LLMError):
|
|
2164
|
+
if stream and public_output_emitted:
|
|
2165
|
+
mark_provider_call_non_retryable(e)
|
|
2166
|
+
raise
|
|
2167
|
+
err = LLMError(
|
|
2168
|
+
f"Anthropic Messages request failed: {sanitize_error_text_for_output(e)}"
|
|
2169
|
+
)
|
|
2170
|
+
if stream and public_output_emitted:
|
|
2171
|
+
mark_provider_call_non_retryable(err)
|
|
2172
|
+
raise err from e
|
|
2173
|
+
if response.status_code >= 400:
|
|
2174
|
+
raise self._llm_error_from_response(response)
|
|
2175
|
+
return _response_with_cache_policy_metadata(
|
|
2176
|
+
self._parse_chat_response(
|
|
2177
|
+
response,
|
|
2178
|
+
on_reasoning_delta=telemetry_on_reasoning_delta,
|
|
2179
|
+
reasoning_is_summary=_payload_requests_summarized_thinking(request_payload),
|
|
2180
|
+
),
|
|
2181
|
+
cache_policy,
|
|
2182
|
+
active_request_plan_metadata,
|
|
2183
|
+
)
|
|
2184
|
+
|
|
2185
|
+
return stamp_response_for_route(
|
|
2186
|
+
telemetry.run(
|
|
2187
|
+
lambda: run_provider_limited_call(
|
|
2188
|
+
call=_send_request,
|
|
2189
|
+
provider_key=provider_key,
|
|
2190
|
+
provider_concurrency_caps=self.provider_concurrency_caps,
|
|
2191
|
+
retry_settings=self.provider_retry_settings,
|
|
2192
|
+
operation="anthropic_messages_chat",
|
|
2193
|
+
sleep_fn=self._provider_sleep_fn,
|
|
2194
|
+
random_fn=self._provider_random_fn,
|
|
2195
|
+
on_retry=telemetry.on_retry,
|
|
2196
|
+
on_retry_event=getattr(
|
|
2197
|
+
self,
|
|
2198
|
+
"_provider_retry_event_observer",
|
|
2199
|
+
None,
|
|
2200
|
+
),
|
|
2201
|
+
retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
|
|
2202
|
+
)
|
|
2203
|
+
),
|
|
2204
|
+
self.route_identity,
|
|
2205
|
+
)
|
|
2206
|
+
|
|
2207
|
+
@staticmethod
|
|
2208
|
+
def _parse_stream_response(
|
|
2209
|
+
response: httpx.Response,
|
|
2210
|
+
*,
|
|
2211
|
+
on_text_delta: Callable[[str], None] | None,
|
|
2212
|
+
on_reasoning_delta: Callable[[str], None] | None,
|
|
2213
|
+
reasoning_is_summary: bool,
|
|
2214
|
+
) -> LLMResponse:
|
|
2215
|
+
accumulator = _AnthropicStreamAccumulator(
|
|
2216
|
+
on_text_delta=on_text_delta,
|
|
2217
|
+
on_reasoning_delta=on_reasoning_delta,
|
|
2218
|
+
reasoning_is_summary=reasoning_is_summary,
|
|
2219
|
+
)
|
|
2220
|
+
for frame in iter_sse_frames(response.iter_lines()):
|
|
2221
|
+
raw_event = parse_sse_json_frame(frame, stream_name="Anthropic Messages stream")
|
|
2222
|
+
if not isinstance(raw_event, dict):
|
|
2223
|
+
raise LLMError("Anthropic Messages stream emitted non-object JSON event")
|
|
2224
|
+
accumulator.handle(frame, raw_event)
|
|
2225
|
+
data = accumulator.finish()
|
|
2226
|
+
# Streamed thinking was already emitted chunk-by-chunk. Parsing the
|
|
2227
|
+
# accumulated message must not emit the same summary a second time.
|
|
2228
|
+
return AnthropicMessagesClient._parse_chat_response(
|
|
2229
|
+
response=_response_from_json(data),
|
|
2230
|
+
reasoning_is_summary=reasoning_is_summary,
|
|
2231
|
+
)
|
|
2232
|
+
|
|
2233
|
+
@staticmethod
|
|
2234
|
+
def _parse_chat_response(
|
|
2235
|
+
response: httpx.Response,
|
|
2236
|
+
*,
|
|
2237
|
+
reasoning_is_summary: bool,
|
|
2238
|
+
on_reasoning_delta: Callable[[str], None] | None = None,
|
|
2239
|
+
) -> LLMResponse:
|
|
2240
|
+
try:
|
|
2241
|
+
data = response.json()
|
|
2242
|
+
except Exception as e: # noqa: BLE001
|
|
2243
|
+
raise LLMError("Anthropic Messages returned non-JSON response") from e
|
|
2244
|
+
if not isinstance(data, dict):
|
|
2245
|
+
raise LLMError("Unexpected Anthropic Messages payload: expected JSON object")
|
|
2246
|
+
content = data.get("content")
|
|
2247
|
+
if not isinstance(content, list):
|
|
2248
|
+
raise LLMError("Unexpected Anthropic Messages payload: missing content list")
|
|
2249
|
+
|
|
2250
|
+
stop_reason = str(data.get("stop_reason") or "").strip()
|
|
2251
|
+
if stop_reason == "refusal":
|
|
2252
|
+
raise LLMError("Anthropic Messages refusal")
|
|
2253
|
+
|
|
2254
|
+
for block in content:
|
|
2255
|
+
if isinstance(block, dict) and str(block.get("type") or "") == "refusal":
|
|
2256
|
+
text = str(block.get("text") or block.get("refusal") or "").strip()
|
|
2257
|
+
suffix = f": {text}" if text else ""
|
|
2258
|
+
raise LLMError(f"Anthropic Messages refusal{suffix}")
|
|
2259
|
+
|
|
2260
|
+
text = _text_from_content_blocks(content)
|
|
2261
|
+
tool_calls = _parse_tool_calls(content)
|
|
2262
|
+
if not text and not tool_calls:
|
|
2263
|
+
suffix = f" (stop_reason={stop_reason})" if stop_reason else ""
|
|
2264
|
+
raise LLMError(f"Anthropic Messages returned no assistant text or tool calls{suffix}")
|
|
2265
|
+
|
|
2266
|
+
summaries = _thinking_summaries_from_content_blocks(content) if reasoning_is_summary else []
|
|
2267
|
+
if on_reasoning_delta is not None:
|
|
2268
|
+
for summary in summaries:
|
|
2269
|
+
on_reasoning_delta(summary)
|
|
2270
|
+
|
|
2271
|
+
response_model = data.get("model") if isinstance(data.get("model"), str) else None
|
|
2272
|
+
reasoning = tuple(
|
|
2273
|
+
ReasoningOutput(
|
|
2274
|
+
text=summary,
|
|
2275
|
+
kind=ReasoningOutputKind.SUMMARY,
|
|
2276
|
+
provider="anthropic",
|
|
2277
|
+
)
|
|
2278
|
+
for summary in summaries
|
|
2279
|
+
)
|
|
2280
|
+
return LLMResponse(
|
|
2281
|
+
content=text,
|
|
2282
|
+
tool_calls=tool_calls,
|
|
2283
|
+
raw=data,
|
|
2284
|
+
response_model=response_model,
|
|
2285
|
+
usage=_parse_usage(data.get("usage")),
|
|
2286
|
+
provider_metadata=_anthropic_provider_metadata(data),
|
|
2287
|
+
reasoning=reasoning,
|
|
2288
|
+
)
|