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,2947 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
9
|
+
from time import monotonic
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ..error_text import sanitize_error_text_for_output
|
|
15
|
+
from ..execution_deadline import DeadlineExhausted
|
|
16
|
+
from ..failure_category import provider_unavailable_retry_reason
|
|
17
|
+
from ..provider_telemetry import ProviderCallTelemetryRecorder
|
|
18
|
+
from ..provider_url import known_provider_key_from_base_url
|
|
19
|
+
from ..reasoning_contracts import (
|
|
20
|
+
ALWAYS_ON,
|
|
21
|
+
OFF_EXPLICIT,
|
|
22
|
+
OPTIONAL,
|
|
23
|
+
WIRE_CHAT_TEMPLATE_ENABLE_THINKING,
|
|
24
|
+
WIRE_REASONING_EFFORT,
|
|
25
|
+
WIRE_THINKING_LEVEL,
|
|
26
|
+
WIRE_THINKING_TYPE,
|
|
27
|
+
reasoning_contract_for,
|
|
28
|
+
)
|
|
29
|
+
from ..request_estimation import estimate_provider_payload_tokens
|
|
30
|
+
from ..run_provenance import active_sampling_settings, apply_sampling_to_payload
|
|
31
|
+
from .cache_capabilities import (
|
|
32
|
+
CACHE_CONTROL_FIELD,
|
|
33
|
+
OPENROUTER_SESSION_ID_FIELD,
|
|
34
|
+
OPENROUTER_SESSION_ID_HEADER_FIELD,
|
|
35
|
+
PROMPT_CACHE_KEY_FIELD,
|
|
36
|
+
PROMPT_CACHE_RETENTION_FIELD,
|
|
37
|
+
XAI_CONVERSATION_ID_HEADER_FIELD,
|
|
38
|
+
)
|
|
39
|
+
from .cache_control_blocks import (
|
|
40
|
+
apply_openai_compatible_cache_control_breakpoint,
|
|
41
|
+
count_cache_control_blocks,
|
|
42
|
+
strip_cache_control_blocks,
|
|
43
|
+
)
|
|
44
|
+
from .cache_policy import merge_cache_policy_metadata
|
|
45
|
+
from .metadata import (
|
|
46
|
+
DEEPSEEK_REASONING_CONTENT_KEY as _DEEPSEEK_REASONING_CONTENT_KEY,
|
|
47
|
+
)
|
|
48
|
+
from .metadata import MISTRAL_CONTENT_CHUNKS_KEY as _MISTRAL_CONTENT_CHUNKS_KEY
|
|
49
|
+
from .metadata import MISTRAL_PROVIDER_METADATA_KEY as _MISTRAL_PROVIDER_KEY
|
|
50
|
+
from .metadata import (
|
|
51
|
+
OPENROUTER_REASONING_DETAILS_KEY as _OPENROUTER_REASONING_DETAILS_KEY,
|
|
52
|
+
)
|
|
53
|
+
from .metadata import (
|
|
54
|
+
OPENROUTER_REASONING_KEY as _OPENROUTER_REASONING_KEY,
|
|
55
|
+
)
|
|
56
|
+
from .metadata import (
|
|
57
|
+
PROVIDER_METADATA_KEY,
|
|
58
|
+
QWEN_PROVIDER_METADATA_KEY,
|
|
59
|
+
ProviderRouteIdentity,
|
|
60
|
+
build_provider_route_identity,
|
|
61
|
+
canonicalize_extra_headers,
|
|
62
|
+
credential_scope_fingerprint,
|
|
63
|
+
endpoint_descriptor,
|
|
64
|
+
endpoint_label,
|
|
65
|
+
gate_messages_for_provider_route,
|
|
66
|
+
merge_canonical_headers,
|
|
67
|
+
stamp_response_for_route,
|
|
68
|
+
strip_provider_metadata_from_message,
|
|
69
|
+
)
|
|
70
|
+
from .metadata import (
|
|
71
|
+
TOOL_CALL_PROVIDER_METADATA_KEY as _TOOL_CALL_PROVIDER_METADATA_KEY,
|
|
72
|
+
)
|
|
73
|
+
from .metadata import (
|
|
74
|
+
assistant_message_from_response as assistant_message_from_response,
|
|
75
|
+
)
|
|
76
|
+
from .metadata import (
|
|
77
|
+
attach_provider_metadata_to_assistant_message as attach_provider_metadata_to_assistant_message,
|
|
78
|
+
)
|
|
79
|
+
from .metadata import (
|
|
80
|
+
merge_provider_metadata as _merge_provider_metadata,
|
|
81
|
+
)
|
|
82
|
+
from .protocols import (
|
|
83
|
+
OPENAI_COMPAT_PROTOCOL,
|
|
84
|
+
validate_reasoning_trace_adapter_for_protocol,
|
|
85
|
+
)
|
|
86
|
+
from .provider_limits import (
|
|
87
|
+
DEFAULT_PROVIDER_CONCURRENCY_CAPS,
|
|
88
|
+
ProviderRetrySettings,
|
|
89
|
+
best_effort_provider_key,
|
|
90
|
+
mark_provider_call_non_retryable,
|
|
91
|
+
run_provider_limited_call,
|
|
92
|
+
)
|
|
93
|
+
from .request_plan import LLMRequestPlan, RequestCachePlan
|
|
94
|
+
from .request_shape import build_request_shape_report
|
|
95
|
+
from .temperature_compat import documented_temperature_omit_reason
|
|
96
|
+
from .types import (
|
|
97
|
+
InputTokenCount,
|
|
98
|
+
LLMError,
|
|
99
|
+
LLMResponse,
|
|
100
|
+
LLMStreamNoProgressError,
|
|
101
|
+
LLMUsage,
|
|
102
|
+
ReasoningOutput,
|
|
103
|
+
ReasoningOutputKind,
|
|
104
|
+
ToolCall,
|
|
105
|
+
UsageConfidence,
|
|
106
|
+
UsageContract,
|
|
107
|
+
UsageSource,
|
|
108
|
+
)
|
|
109
|
+
from .usage_normalization import parse_compatible_usage
|
|
110
|
+
|
|
111
|
+
_TEXT_LIKE_CONTENT_PART_TYPES = {"text", "output_text"}
|
|
112
|
+
_DEEPSEEK_PROVIDER_KEY = "deepseek"
|
|
113
|
+
_OPENROUTER_PROVIDER_KEY = "openrouter"
|
|
114
|
+
_QWEN_PROVIDER_KEY = QWEN_PROVIDER_METADATA_KEY
|
|
115
|
+
_GEMINI_PROVIDER_KEY = "gemini"
|
|
116
|
+
_MOONSHOT_PROVIDER_KEY = "moonshot"
|
|
117
|
+
_NVIDIA_PROVIDER_KEY = "nvidia"
|
|
118
|
+
_TOGETHER_PROVIDER_KEY = "together"
|
|
119
|
+
_GEMINI_EXTRA_CONTENT_KEY = "extra_content"
|
|
120
|
+
_OPENAI_STYLE_REASONING_EFFORT_PROVIDERS = frozenset({"openai", "azure", "mistral"})
|
|
121
|
+
_REASONING_PROVIDER_BY_ADAPTER: dict[str, str] = {
|
|
122
|
+
"deepseek_reasoning": _DEEPSEEK_PROVIDER_KEY,
|
|
123
|
+
"openrouter_reasoning": _OPENROUTER_PROVIDER_KEY,
|
|
124
|
+
"dashscope_thinking": _QWEN_PROVIDER_KEY,
|
|
125
|
+
"mistral_thinking": _MISTRAL_PROVIDER_KEY,
|
|
126
|
+
"moonshot_reasoning": _MOONSHOT_PROVIDER_KEY,
|
|
127
|
+
"nvidia_reasoning": _NVIDIA_PROVIDER_KEY,
|
|
128
|
+
}
|
|
129
|
+
_GEMINI_REASONING_EFFORTS = frozenset({"minimal", "low", "medium", "high"})
|
|
130
|
+
_DEFAULT_ACCEPT_ENCODING = "identity"
|
|
131
|
+
_DEFAULT_CONNECT_TIMEOUT_S = 2.0
|
|
132
|
+
_LOGGER = logging.getLogger(__name__)
|
|
133
|
+
_TEMPERATURE_DEFAULT_VALUE = 1.0
|
|
134
|
+
_TEMPERATURE_COMPAT_MODE_DEFAULT = "default_temperature"
|
|
135
|
+
_TEMPERATURE_COMPAT_MODE_OMIT = "omit_temperature"
|
|
136
|
+
_TEMPERATURE_COMPAT_MODES = {
|
|
137
|
+
_TEMPERATURE_COMPAT_MODE_DEFAULT,
|
|
138
|
+
_TEMPERATURE_COMPAT_MODE_OMIT,
|
|
139
|
+
}
|
|
140
|
+
_TEMPERATURE_UNSUPPORTED_STATUS_CODES = {400, 422}
|
|
141
|
+
_TEMPERATURE_UNSUPPORTED_TOKENS = (
|
|
142
|
+
"allowed",
|
|
143
|
+
"greater than",
|
|
144
|
+
"invalid",
|
|
145
|
+
"unsupported",
|
|
146
|
+
"not support",
|
|
147
|
+
"not supported",
|
|
148
|
+
"not allowed",
|
|
149
|
+
"out of range",
|
|
150
|
+
"range",
|
|
151
|
+
"deprecated",
|
|
152
|
+
"only the default",
|
|
153
|
+
"cannot be set",
|
|
154
|
+
"must be omitted",
|
|
155
|
+
)
|
|
156
|
+
_CACHE_PARAM_UNSUPPORTED_STATUS_CODES = {400, 422}
|
|
157
|
+
_CACHE_PARAM_UNSUPPORTED_TOKENS = (
|
|
158
|
+
"invalid",
|
|
159
|
+
"unsupported",
|
|
160
|
+
"not support",
|
|
161
|
+
"not supported",
|
|
162
|
+
"not allowed",
|
|
163
|
+
"unknown",
|
|
164
|
+
"unrecognized",
|
|
165
|
+
"unexpected",
|
|
166
|
+
"extra",
|
|
167
|
+
"cannot be set",
|
|
168
|
+
"must be omitted",
|
|
169
|
+
"forbidden",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_CACHE_BODY_FIELDS = (
|
|
174
|
+
PROMPT_CACHE_KEY_FIELD,
|
|
175
|
+
PROMPT_CACHE_RETENTION_FIELD,
|
|
176
|
+
CACHE_CONTROL_FIELD,
|
|
177
|
+
OPENROUTER_SESSION_ID_FIELD,
|
|
178
|
+
)
|
|
179
|
+
_CACHE_HEADER_FIELDS = (
|
|
180
|
+
OPENROUTER_SESSION_ID_HEADER_FIELD,
|
|
181
|
+
XAI_CONVERSATION_ID_HEADER_FIELD,
|
|
182
|
+
)
|
|
183
|
+
_PROMPT_CACHE_FIELDS = (*_CACHE_BODY_FIELDS, *_CACHE_HEADER_FIELDS)
|
|
184
|
+
_TOOL_CHOICE_UNSUPPORTED_STATUS_CODES = {400, 422}
|
|
185
|
+
_TOOL_CHOICE_UNSUPPORTED_TOKENS = (
|
|
186
|
+
"invalid",
|
|
187
|
+
"not allowed",
|
|
188
|
+
"not support",
|
|
189
|
+
"not supported",
|
|
190
|
+
"unsupported",
|
|
191
|
+
"unknown",
|
|
192
|
+
"unrecognized",
|
|
193
|
+
"unexpected",
|
|
194
|
+
)
|
|
195
|
+
_TOOL_CALLING_REJECTION_PARAMS = frozenset({"tool", "tools", "function", "functions"})
|
|
196
|
+
_TOOL_CALLING_REJECTION_TERMS = (
|
|
197
|
+
"tool",
|
|
198
|
+
"tools",
|
|
199
|
+
"function",
|
|
200
|
+
"functions",
|
|
201
|
+
"function calling",
|
|
202
|
+
"function_call",
|
|
203
|
+
"model",
|
|
204
|
+
)
|
|
205
|
+
_PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS: float | None = None
|
|
206
|
+
_ERROR_BODY_DISPLAY_LIMIT = 1000
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _headers_with_default_accept_encoding(headers: dict[str, str]) -> dict[str, str]:
|
|
210
|
+
request_headers = dict(headers)
|
|
211
|
+
if not any(key.lower() == "accept-encoding" for key in request_headers):
|
|
212
|
+
request_headers["accept-encoding"] = _DEFAULT_ACCEPT_ENCODING
|
|
213
|
+
return request_headers
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _httpx_request_timeout(timeout_s: float) -> httpx.Timeout:
|
|
217
|
+
request_timeout = max(float(timeout_s), 0.001)
|
|
218
|
+
connect_timeout = min(request_timeout, _DEFAULT_CONNECT_TIMEOUT_S)
|
|
219
|
+
return httpx.Timeout(request_timeout, connect=connect_timeout)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _iter_exception_chain(exc: BaseException) -> Iterator[BaseException]:
|
|
223
|
+
current: BaseException | None = exc
|
|
224
|
+
seen: set[int] = set()
|
|
225
|
+
while current is not None and id(current) not in seen:
|
|
226
|
+
seen.add(id(current))
|
|
227
|
+
yield current
|
|
228
|
+
cause = current.__cause__
|
|
229
|
+
context = current.__context__
|
|
230
|
+
current = cause if cause is not None else context
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_connect_failure(exc: BaseException) -> bool:
|
|
234
|
+
return any(
|
|
235
|
+
isinstance(item, httpx.ConnectError | httpx.ConnectTimeout)
|
|
236
|
+
for item in _iter_exception_chain(exc)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _is_read_timeout(exc: BaseException) -> bool:
|
|
241
|
+
return any(isinstance(item, httpx.ReadTimeout) for item in _iter_exception_chain(exc))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _common_prefix_length(left: str, right: str) -> int:
|
|
245
|
+
limit = min(len(left), len(right))
|
|
246
|
+
idx = 0
|
|
247
|
+
while idx < limit and left[idx] == right[idx]:
|
|
248
|
+
idx += 1
|
|
249
|
+
return idx
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _suffix_prefix_overlap_length(left: str, right: str) -> int:
|
|
253
|
+
limit = min(len(left), len(right))
|
|
254
|
+
for size in range(limit, 0, -1):
|
|
255
|
+
if left[-size:] == right[:size]:
|
|
256
|
+
return size
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _strip_cumulative_restart_suffix(*, previous: str, incoming: str) -> str | None:
|
|
261
|
+
candidate = incoming
|
|
262
|
+
while candidate:
|
|
263
|
+
trimmed = candidate.lstrip("\r\n ")
|
|
264
|
+
if trimmed.startswith(previous):
|
|
265
|
+
remainder = trimmed[len(previous) :]
|
|
266
|
+
trimmed_remainder = remainder.lstrip("\r\n ")
|
|
267
|
+
if trimmed_remainder.startswith(previous):
|
|
268
|
+
candidate = trimmed_remainder
|
|
269
|
+
continue
|
|
270
|
+
return remainder
|
|
271
|
+
if trimmed != candidate:
|
|
272
|
+
candidate = trimmed
|
|
273
|
+
continue
|
|
274
|
+
break
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _looks_like_alternate_cumulative_restart(*, previous: str, incoming: str) -> bool:
|
|
279
|
+
if len(previous) < 120 or len(incoming) < 80:
|
|
280
|
+
return False
|
|
281
|
+
if incoming.startswith(previous) or previous.startswith(incoming):
|
|
282
|
+
return False
|
|
283
|
+
common_prefix = _common_prefix_length(previous, incoming)
|
|
284
|
+
if common_prefix < 24:
|
|
285
|
+
return False
|
|
286
|
+
return common_prefix < (min(len(previous), len(incoming)) // 2)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _stream_delta_suffix(*, previous: str, incoming: str) -> str:
|
|
290
|
+
if not incoming:
|
|
291
|
+
return ""
|
|
292
|
+
if not previous:
|
|
293
|
+
return incoming
|
|
294
|
+
if incoming == previous:
|
|
295
|
+
return ""
|
|
296
|
+
if _looks_like_alternate_cumulative_restart(previous=previous, incoming=incoming):
|
|
297
|
+
return ""
|
|
298
|
+
cumulative_suffix = _strip_cumulative_restart_suffix(previous=previous, incoming=incoming)
|
|
299
|
+
if cumulative_suffix is not None:
|
|
300
|
+
return cumulative_suffix
|
|
301
|
+
common_prefix = _common_prefix_length(previous, incoming)
|
|
302
|
+
if common_prefix >= max(16, min(len(previous), len(incoming)) // 2):
|
|
303
|
+
return incoming[common_prefix:]
|
|
304
|
+
previous_restart = incoming.rfind(previous)
|
|
305
|
+
if previous_restart > 0:
|
|
306
|
+
prefix = incoming[:previous_restart]
|
|
307
|
+
if not prefix.strip():
|
|
308
|
+
return incoming[previous_restart + len(previous) :]
|
|
309
|
+
overlap = _suffix_prefix_overlap_length(previous, incoming)
|
|
310
|
+
if overlap > 0:
|
|
311
|
+
restarted_suffix = _strip_cumulative_restart_suffix(
|
|
312
|
+
previous=previous,
|
|
313
|
+
incoming=incoming[overlap:],
|
|
314
|
+
)
|
|
315
|
+
if restarted_suffix is not None:
|
|
316
|
+
return restarted_suffix
|
|
317
|
+
if overlap >= max(4, len(incoming) // 2):
|
|
318
|
+
return incoming[overlap:]
|
|
319
|
+
return incoming
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _sanitize_transport_text(text: str) -> str:
|
|
323
|
+
if not text:
|
|
324
|
+
return text
|
|
325
|
+
if not any(0xD800 <= ord(ch) <= 0xDFFF for ch in text):
|
|
326
|
+
return text
|
|
327
|
+
try:
|
|
328
|
+
# Recover surrogate-escaped terminal bytes when possible, and replace
|
|
329
|
+
# genuinely invalid sequences so JSON transport never crashes.
|
|
330
|
+
return text.encode("utf-8", errors="surrogateescape").decode("utf-8", errors="replace")
|
|
331
|
+
except Exception:
|
|
332
|
+
return text.encode("utf-8", errors="replace").decode("utf-8")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _sanitize_transport_value(value: Any) -> Any:
|
|
336
|
+
if isinstance(value, str):
|
|
337
|
+
return _sanitize_transport_text(value)
|
|
338
|
+
if isinstance(value, list):
|
|
339
|
+
return [_sanitize_transport_value(item) for item in value]
|
|
340
|
+
if isinstance(value, tuple):
|
|
341
|
+
return tuple(_sanitize_transport_value(item) for item in value)
|
|
342
|
+
if isinstance(value, dict):
|
|
343
|
+
return {
|
|
344
|
+
_sanitize_transport_text(key)
|
|
345
|
+
if isinstance(key, str)
|
|
346
|
+
else key: _sanitize_transport_value(item)
|
|
347
|
+
for key, item in value.items()
|
|
348
|
+
}
|
|
349
|
+
return value
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _normalize_provider_key(provider_key: str | None) -> str:
|
|
353
|
+
normalized = str(provider_key or "").strip().casefold()
|
|
354
|
+
return "".join(char if char.isalnum() else "_" for char in normalized).strip("_")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _provider_key_from_base_url(base_url: str | None) -> str | None:
|
|
358
|
+
return known_provider_key_from_base_url(base_url)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _transport_provider_key(
|
|
362
|
+
*,
|
|
363
|
+
base_url: str | None,
|
|
364
|
+
provider_key: str | None,
|
|
365
|
+
model: str | None,
|
|
366
|
+
) -> str:
|
|
367
|
+
from_url = _provider_key_from_base_url(base_url)
|
|
368
|
+
if from_url:
|
|
369
|
+
return from_url
|
|
370
|
+
normalized_provider = _normalize_provider_key(provider_key)
|
|
371
|
+
if normalized_provider:
|
|
372
|
+
if normalized_provider in {"dashscope", "qwen", "aliyun", "aliyuncs"}:
|
|
373
|
+
return "qwen"
|
|
374
|
+
return normalized_provider
|
|
375
|
+
return _normalize_provider_key(best_effort_provider_key(base_url=base_url, model=model))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _reasoning_transport_provider_key(
|
|
379
|
+
*,
|
|
380
|
+
transport_provider_key: str | None,
|
|
381
|
+
reasoning_trace_adapter: str | None,
|
|
382
|
+
) -> str | None:
|
|
383
|
+
"""Resolve only the provider dialect used for reasoning state on the wire.
|
|
384
|
+
|
|
385
|
+
Automatic selection preserves the existing provider inference. Explicit
|
|
386
|
+
adapters are authoritative for custom OpenAI-compatible endpoints, while
|
|
387
|
+
``none`` and the passive adapter deliberately inject and replay nothing.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
adapter = validate_reasoning_trace_adapter_for_protocol(
|
|
391
|
+
protocol=OPENAI_COMPAT_PROTOCOL,
|
|
392
|
+
adapter=reasoning_trace_adapter,
|
|
393
|
+
)
|
|
394
|
+
if adapter == "auto":
|
|
395
|
+
return _normalize_provider_key(transport_provider_key) or None
|
|
396
|
+
return _REASONING_PROVIDER_BY_ADAPTER.get(adapter)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _is_deepseek_provider(provider_key: str | None) -> bool:
|
|
400
|
+
return _normalize_provider_key(provider_key) == _DEEPSEEK_PROVIDER_KEY
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _is_openrouter_provider(provider_key: str | None) -> bool:
|
|
404
|
+
return _normalize_provider_key(provider_key) == _OPENROUTER_PROVIDER_KEY
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _is_gemini_provider(provider_key: str | None) -> bool:
|
|
408
|
+
return _normalize_provider_key(provider_key) == _GEMINI_PROVIDER_KEY
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _is_dashscope_provider(provider_key: str | None) -> bool:
|
|
412
|
+
return _normalize_provider_key(provider_key) in {"qwen", "dashscope"}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _is_mistral_provider(provider_key: str | None) -> bool:
|
|
416
|
+
return _normalize_provider_key(provider_key) == _MISTRAL_PROVIDER_KEY
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _is_moonshot_provider(provider_key: str | None) -> bool:
|
|
420
|
+
return _normalize_provider_key(provider_key) in {_MOONSHOT_PROVIDER_KEY, "kimi_code"}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _is_nvidia_provider(provider_key: str | None) -> bool:
|
|
424
|
+
return _normalize_provider_key(provider_key) == _NVIDIA_PROVIDER_KEY
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _is_together_deepseek_pro(provider_key: str | None, model: str | None) -> bool:
|
|
428
|
+
return _normalize_provider_key(provider_key) == _TOGETHER_PROVIDER_KEY and str(
|
|
429
|
+
model or ""
|
|
430
|
+
).strip().casefold().startswith("deepseek-ai/deepseek-v4-pro")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _uses_reasoning_effort(provider_key: str | None) -> bool:
|
|
434
|
+
return _normalize_provider_key(provider_key) in _OPENAI_STYLE_REASONING_EFFORT_PROVIDERS
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _model_name_parts(model: str | None) -> set[str]:
|
|
438
|
+
normalized = str(model or "").strip().casefold()
|
|
439
|
+
return {part for part in re.split(r"[^a-z0-9]+", normalized) if part}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _gemini_model_allows_none_reasoning_effort(model: str | None) -> bool:
|
|
443
|
+
parts = _model_name_parts(model)
|
|
444
|
+
if "gemini" not in parts or "2" not in parts or "5" not in parts:
|
|
445
|
+
return False
|
|
446
|
+
if "pro" in parts:
|
|
447
|
+
return False
|
|
448
|
+
return "flash" in parts
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _gemini_reasoning_effort(
|
|
452
|
+
*,
|
|
453
|
+
model: str | None,
|
|
454
|
+
reasoning_effort: str | None,
|
|
455
|
+
) -> str | None:
|
|
456
|
+
effort = str(reasoning_effort or "").strip().casefold()
|
|
457
|
+
if not effort:
|
|
458
|
+
return None
|
|
459
|
+
contract = reasoning_contract_for("gemini", model)
|
|
460
|
+
if contract.wire == WIRE_THINKING_LEVEL:
|
|
461
|
+
if contract.allows_value(effort):
|
|
462
|
+
return effort
|
|
463
|
+
return contract.default or None
|
|
464
|
+
if effort in _GEMINI_REASONING_EFFORTS:
|
|
465
|
+
return effort
|
|
466
|
+
if effort == "none" and _gemini_model_allows_none_reasoning_effort(model):
|
|
467
|
+
return effort
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _reasoning_effort_enables_thinking(reasoning_effort: str | None) -> bool | None:
|
|
472
|
+
normalized = _normalize_provider_key(reasoning_effort)
|
|
473
|
+
if not normalized:
|
|
474
|
+
return None
|
|
475
|
+
return normalized != "none"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _documented_reasoning_effort(
|
|
479
|
+
*,
|
|
480
|
+
provider_key: str | None,
|
|
481
|
+
model: str | None,
|
|
482
|
+
reasoning_effort: str | None,
|
|
483
|
+
) -> str | None:
|
|
484
|
+
"""Return an exact provider-documented effort value, never a guessed alias."""
|
|
485
|
+
|
|
486
|
+
normalized = str(reasoning_effort or "").strip().casefold()
|
|
487
|
+
if not normalized or normalized == "none":
|
|
488
|
+
return None
|
|
489
|
+
contract = reasoning_contract_for(provider_key, model)
|
|
490
|
+
if contract.wire != WIRE_REASONING_EFFORT or not contract.allows_value(normalized):
|
|
491
|
+
return None
|
|
492
|
+
return normalized
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _documented_flat_reasoning_effort(
|
|
496
|
+
*,
|
|
497
|
+
provider_key: str | None,
|
|
498
|
+
model: str | None,
|
|
499
|
+
enable_thinking: bool | None,
|
|
500
|
+
reasoning_effort: str | None,
|
|
501
|
+
) -> str | None:
|
|
502
|
+
"""Return the contract-approved flat value, including an explicit off value.
|
|
503
|
+
|
|
504
|
+
``_documented_reasoning_effort`` intentionally treats ``none`` as omission
|
|
505
|
+
for transports with a separate thinking toggle. Generic Chat Completions
|
|
506
|
+
providers may instead document ``reasoning_effort='none'`` as their only
|
|
507
|
+
off wire shape, so this path preserves that structural distinction.
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
contract = reasoning_contract_for(provider_key, model)
|
|
511
|
+
if not contract.emits_flat_reasoning_effort or contract.wire != WIRE_REASONING_EFFORT:
|
|
512
|
+
return None
|
|
513
|
+
if enable_thinking is False:
|
|
514
|
+
return "none" if contract.off == OFF_EXPLICIT and contract.allows_value("none") else None
|
|
515
|
+
normalized = str(reasoning_effort or "").strip().casefold()
|
|
516
|
+
if not normalized:
|
|
517
|
+
return None
|
|
518
|
+
if normalized == "none" and enable_thinking is True:
|
|
519
|
+
return None
|
|
520
|
+
return normalized if contract.allows_value(normalized) else None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _deepseek_reasoning_payload_enabled(
|
|
524
|
+
*,
|
|
525
|
+
enable_thinking: bool | None,
|
|
526
|
+
reasoning_effort: str | None,
|
|
527
|
+
) -> bool | None:
|
|
528
|
+
if enable_thinking is not None:
|
|
529
|
+
return enable_thinking
|
|
530
|
+
return _reasoning_effort_enables_thinking(reasoning_effort)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _reasoning_contract_active(
|
|
534
|
+
*,
|
|
535
|
+
provider_key: str | None,
|
|
536
|
+
model: str | None,
|
|
537
|
+
enable_thinking: bool | None,
|
|
538
|
+
reasoning_effort: str | None,
|
|
539
|
+
) -> bool | None:
|
|
540
|
+
"""Resolve reasoning activity from an explicit per-model wire contract.
|
|
541
|
+
|
|
542
|
+
``None`` means that the catalog does not know the model's default. This is
|
|
543
|
+
intentionally tri-state so an unknown OpenAI-compatible route never gains
|
|
544
|
+
provider behavior from a name guess.
|
|
545
|
+
"""
|
|
546
|
+
|
|
547
|
+
contract = reasoning_contract_for(provider_key, model)
|
|
548
|
+
if contract.mode not in {ALWAYS_ON, OPTIONAL}:
|
|
549
|
+
return None
|
|
550
|
+
if contract.mode == ALWAYS_ON:
|
|
551
|
+
return True
|
|
552
|
+
if enable_thinking is not None:
|
|
553
|
+
return enable_thinking
|
|
554
|
+
normalized_effort = str(reasoning_effort or "").strip().casefold()
|
|
555
|
+
if normalized_effort:
|
|
556
|
+
return normalized_effort != "none"
|
|
557
|
+
if contract.default:
|
|
558
|
+
return contract.default.casefold() != "none"
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _openrouter_reasoning_payload(
|
|
563
|
+
*,
|
|
564
|
+
enable_thinking: bool | None,
|
|
565
|
+
reasoning_effort: str | None,
|
|
566
|
+
) -> dict[str, Any] | None:
|
|
567
|
+
normalized_effort = str(reasoning_effort or "").strip().lower()
|
|
568
|
+
if normalized_effort:
|
|
569
|
+
return {"effort": normalized_effort}
|
|
570
|
+
if enable_thinking is True:
|
|
571
|
+
return {"enabled": True}
|
|
572
|
+
if enable_thinking is False:
|
|
573
|
+
return {"enabled": False}
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _tool_choice_forces_a_call(tool_choice: Any) -> bool:
|
|
578
|
+
"""Whether ``tool_choice`` compels the model to emit a tool call.
|
|
579
|
+
|
|
580
|
+
A specific-function object (``{"type": "function", ...}``) or the strings
|
|
581
|
+
``"required"`` / ``"any"`` force a call. ``"auto"`` / ``"none"`` / unset do
|
|
582
|
+
not. Reasoning providers (DeepSeek, OpenRouter/MiMo, DashScope/Qwen, Zhipu
|
|
583
|
+
GLM) reject a *forced* choice while thinking is on -- the API returns
|
|
584
|
+
``400 "Thinking mode does not support this tool_choice"`` -- so the caller
|
|
585
|
+
omits ``tool_choice`` when the request runs in thinking mode.
|
|
586
|
+
"""
|
|
587
|
+
if isinstance(tool_choice, dict):
|
|
588
|
+
return True
|
|
589
|
+
if isinstance(tool_choice, str):
|
|
590
|
+
return tool_choice.strip().lower() in {"required", "any"}
|
|
591
|
+
return False
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _deepseek_reasoning_provider_metadata(reasoning_content: str) -> dict[str, Any] | None:
|
|
595
|
+
reasoning = str(reasoning_content or "")
|
|
596
|
+
if not reasoning:
|
|
597
|
+
return None
|
|
598
|
+
return {
|
|
599
|
+
_DEEPSEEK_PROVIDER_KEY: {
|
|
600
|
+
_DEEPSEEK_REASONING_CONTENT_KEY: reasoning,
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _qwen_reasoning_provider_metadata(reasoning_content: str) -> dict[str, Any] | None:
|
|
606
|
+
reasoning = str(reasoning_content or "")
|
|
607
|
+
if not reasoning:
|
|
608
|
+
return None
|
|
609
|
+
return {
|
|
610
|
+
_QWEN_PROVIDER_KEY: {
|
|
611
|
+
_DEEPSEEK_REASONING_CONTENT_KEY: reasoning,
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _moonshot_reasoning_provider_metadata(reasoning_content: str) -> dict[str, Any] | None:
|
|
617
|
+
reasoning = str(reasoning_content or "")
|
|
618
|
+
if not reasoning:
|
|
619
|
+
return None
|
|
620
|
+
return {
|
|
621
|
+
_MOONSHOT_PROVIDER_KEY: {
|
|
622
|
+
_DEEPSEEK_REASONING_CONTENT_KEY: reasoning,
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _nvidia_reasoning_provider_metadata(reasoning_content: str) -> dict[str, Any] | None:
|
|
628
|
+
reasoning = str(reasoning_content or "")
|
|
629
|
+
if not reasoning:
|
|
630
|
+
return None
|
|
631
|
+
return {
|
|
632
|
+
_NVIDIA_PROVIDER_KEY: {
|
|
633
|
+
_DEEPSEEK_REASONING_CONTENT_KEY: reasoning,
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _is_mistral_thinking_chunk(value: Any) -> bool:
|
|
639
|
+
return isinstance(value, dict) and str(value.get("type") or "").casefold() == "thinking"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _mistral_content_provider_metadata(content: Any) -> dict[str, Any] | None:
|
|
643
|
+
if not isinstance(content, list) or not any(
|
|
644
|
+
_is_mistral_thinking_chunk(chunk) for chunk in content
|
|
645
|
+
):
|
|
646
|
+
return None
|
|
647
|
+
return {
|
|
648
|
+
_MISTRAL_PROVIDER_KEY: {
|
|
649
|
+
_MISTRAL_CONTENT_CHUNKS_KEY: copy.deepcopy(content),
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _openrouter_reasoning_provider_metadata(
|
|
655
|
+
*,
|
|
656
|
+
reasoning: str | None = None,
|
|
657
|
+
reasoning_details: Any = None,
|
|
658
|
+
) -> dict[str, Any] | None:
|
|
659
|
+
payload: dict[str, Any] = {}
|
|
660
|
+
reasoning_text = str(reasoning or "")
|
|
661
|
+
if reasoning_text:
|
|
662
|
+
payload[_OPENROUTER_REASONING_KEY] = reasoning_text
|
|
663
|
+
if isinstance(reasoning_details, list) and reasoning_details:
|
|
664
|
+
payload[_OPENROUTER_REASONING_DETAILS_KEY] = reasoning_details
|
|
665
|
+
if not payload:
|
|
666
|
+
return None
|
|
667
|
+
return {_OPENROUTER_PROVIDER_KEY: payload}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _text_from_reasoning_detail(detail: dict[str, Any]) -> tuple[str, ReasoningOutputKind] | None:
|
|
671
|
+
detail_type = str(detail.get("type") or "").strip().lower()
|
|
672
|
+
if detail_type == "reasoning.summary":
|
|
673
|
+
value = detail.get("summary")
|
|
674
|
+
kind = ReasoningOutputKind.SUMMARY
|
|
675
|
+
elif detail_type == "reasoning.text":
|
|
676
|
+
value = detail.get("text")
|
|
677
|
+
kind = ReasoningOutputKind.PROVIDER_REASONING
|
|
678
|
+
else:
|
|
679
|
+
return None
|
|
680
|
+
if not isinstance(value, str) or not value.strip():
|
|
681
|
+
return None
|
|
682
|
+
return value, kind
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _reasoning_outputs_from_message(
|
|
686
|
+
message: dict[str, Any],
|
|
687
|
+
*,
|
|
688
|
+
provider_key: str | None,
|
|
689
|
+
) -> tuple[ReasoningOutput, ...]:
|
|
690
|
+
provider = _normalize_provider_key(provider_key) or None
|
|
691
|
+
outputs: list[ReasoningOutput] = []
|
|
692
|
+
seen: set[tuple[ReasoningOutputKind, str]] = set()
|
|
693
|
+
|
|
694
|
+
details = message.get(_OPENROUTER_REASONING_DETAILS_KEY)
|
|
695
|
+
if isinstance(details, list):
|
|
696
|
+
for detail in details:
|
|
697
|
+
parsed = _text_from_reasoning_detail(detail) if isinstance(detail, dict) else None
|
|
698
|
+
if parsed is None:
|
|
699
|
+
continue
|
|
700
|
+
text, kind = parsed
|
|
701
|
+
if kind != ReasoningOutputKind.SUMMARY:
|
|
702
|
+
continue
|
|
703
|
+
dedupe_key = (kind, text)
|
|
704
|
+
if dedupe_key in seen:
|
|
705
|
+
continue
|
|
706
|
+
seen.add(dedupe_key)
|
|
707
|
+
outputs.append(ReasoningOutput(text=text, kind=kind, provider=provider))
|
|
708
|
+
return tuple(outputs)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _provider_metadata_for_reasoning(
|
|
712
|
+
*,
|
|
713
|
+
provider_key: str | None,
|
|
714
|
+
message: dict[str, Any],
|
|
715
|
+
model: str | None = None,
|
|
716
|
+
) -> dict[str, Any] | None:
|
|
717
|
+
if _is_together_deepseek_pro(provider_key, model):
|
|
718
|
+
reasoning = message.get(_OPENROUTER_REASONING_KEY)
|
|
719
|
+
if not isinstance(reasoning, str):
|
|
720
|
+
# Together accepts the older reasoning_content spelling on input,
|
|
721
|
+
# so tolerate it in compatible proxy responses as well.
|
|
722
|
+
reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
723
|
+
return _deepseek_reasoning_provider_metadata(
|
|
724
|
+
reasoning if isinstance(reasoning, str) else ""
|
|
725
|
+
)
|
|
726
|
+
if _is_deepseek_provider(provider_key):
|
|
727
|
+
reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
728
|
+
return _deepseek_reasoning_provider_metadata(
|
|
729
|
+
reasoning if isinstance(reasoning, str) else ""
|
|
730
|
+
)
|
|
731
|
+
if _is_dashscope_provider(provider_key):
|
|
732
|
+
reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
733
|
+
return _qwen_reasoning_provider_metadata(reasoning if isinstance(reasoning, str) else "")
|
|
734
|
+
if _is_moonshot_provider(provider_key):
|
|
735
|
+
reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
736
|
+
return _moonshot_reasoning_provider_metadata(
|
|
737
|
+
reasoning if isinstance(reasoning, str) else ""
|
|
738
|
+
)
|
|
739
|
+
if _is_nvidia_provider(provider_key):
|
|
740
|
+
reasoning = message.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
741
|
+
return _nvidia_reasoning_provider_metadata(reasoning if isinstance(reasoning, str) else "")
|
|
742
|
+
if _is_openrouter_provider(provider_key):
|
|
743
|
+
reasoning = message.get(_OPENROUTER_REASONING_KEY)
|
|
744
|
+
reasoning_details = message.get(_OPENROUTER_REASONING_DETAILS_KEY)
|
|
745
|
+
return _openrouter_reasoning_provider_metadata(
|
|
746
|
+
reasoning=reasoning if isinstance(reasoning, str) else None,
|
|
747
|
+
reasoning_details=reasoning_details,
|
|
748
|
+
)
|
|
749
|
+
if _is_mistral_provider(provider_key):
|
|
750
|
+
return _mistral_content_provider_metadata(message.get("content"))
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _deepseek_reasoning_from_provider_metadata(metadata: Any) -> str:
|
|
755
|
+
if not isinstance(metadata, dict):
|
|
756
|
+
return ""
|
|
757
|
+
deepseek = metadata.get(_DEEPSEEK_PROVIDER_KEY)
|
|
758
|
+
if not isinstance(deepseek, dict):
|
|
759
|
+
return ""
|
|
760
|
+
reasoning = deepseek.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
761
|
+
return reasoning if isinstance(reasoning, str) else ""
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _openrouter_reasoning_from_provider_metadata(metadata: Any) -> tuple[str, list[Any] | None]:
|
|
765
|
+
if not isinstance(metadata, dict):
|
|
766
|
+
return "", None
|
|
767
|
+
openrouter = metadata.get(_OPENROUTER_PROVIDER_KEY)
|
|
768
|
+
if not isinstance(openrouter, dict):
|
|
769
|
+
return "", None
|
|
770
|
+
reasoning = openrouter.get(_OPENROUTER_REASONING_KEY)
|
|
771
|
+
reasoning_details = openrouter.get(_OPENROUTER_REASONING_DETAILS_KEY)
|
|
772
|
+
return (
|
|
773
|
+
reasoning if isinstance(reasoning, str) else "",
|
|
774
|
+
list(reasoning_details) if isinstance(reasoning_details, list) else None,
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _qwen_reasoning_from_provider_metadata(metadata: Any) -> str:
|
|
779
|
+
if not isinstance(metadata, dict):
|
|
780
|
+
return ""
|
|
781
|
+
qwen = metadata.get(_QWEN_PROVIDER_KEY)
|
|
782
|
+
if not isinstance(qwen, dict):
|
|
783
|
+
return ""
|
|
784
|
+
reasoning = qwen.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
785
|
+
return reasoning if isinstance(reasoning, str) else ""
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _moonshot_reasoning_from_provider_metadata(metadata: Any) -> str:
|
|
789
|
+
if not isinstance(metadata, dict):
|
|
790
|
+
return ""
|
|
791
|
+
moonshot = metadata.get(_MOONSHOT_PROVIDER_KEY)
|
|
792
|
+
if not isinstance(moonshot, dict):
|
|
793
|
+
return ""
|
|
794
|
+
reasoning = moonshot.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
795
|
+
return reasoning if isinstance(reasoning, str) else ""
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _nvidia_reasoning_from_provider_metadata(metadata: Any) -> str:
|
|
799
|
+
if not isinstance(metadata, dict):
|
|
800
|
+
return ""
|
|
801
|
+
nvidia = metadata.get(_NVIDIA_PROVIDER_KEY)
|
|
802
|
+
if not isinstance(nvidia, dict):
|
|
803
|
+
return ""
|
|
804
|
+
reasoning = nvidia.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
805
|
+
return reasoning if isinstance(reasoning, str) else ""
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _mistral_content_from_provider_metadata(metadata: Any) -> list[Any] | None:
|
|
809
|
+
if not isinstance(metadata, dict):
|
|
810
|
+
return None
|
|
811
|
+
mistral = metadata.get(_MISTRAL_PROVIDER_KEY)
|
|
812
|
+
if not isinstance(mistral, dict):
|
|
813
|
+
return None
|
|
814
|
+
content = mistral.get(_MISTRAL_CONTENT_CHUNKS_KEY)
|
|
815
|
+
if not isinstance(content, list) or not any(
|
|
816
|
+
_is_mistral_thinking_chunk(chunk) for chunk in content
|
|
817
|
+
):
|
|
818
|
+
return None
|
|
819
|
+
return copy.deepcopy(content)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _gemini_tool_call_provider_metadata(tool_call: dict[str, Any]) -> dict[str, Any] | None:
|
|
823
|
+
extra_content = tool_call.get(_GEMINI_EXTRA_CONTENT_KEY)
|
|
824
|
+
if not isinstance(extra_content, dict) or not extra_content:
|
|
825
|
+
return None
|
|
826
|
+
return {
|
|
827
|
+
_GEMINI_PROVIDER_KEY: {
|
|
828
|
+
_GEMINI_EXTRA_CONTENT_KEY: copy.deepcopy(extra_content),
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _copy_transport_tool_calls(
|
|
834
|
+
tool_calls: Any,
|
|
835
|
+
*,
|
|
836
|
+
preserve_extra_content: bool,
|
|
837
|
+
) -> list[Any] | Any:
|
|
838
|
+
if not isinstance(tool_calls, list):
|
|
839
|
+
return tool_calls
|
|
840
|
+
copied_tool_calls: list[Any] = []
|
|
841
|
+
for tool_call in tool_calls:
|
|
842
|
+
if not isinstance(tool_call, dict):
|
|
843
|
+
copied_tool_calls.append(tool_call)
|
|
844
|
+
continue
|
|
845
|
+
copied_tool_call = copy.deepcopy(tool_call)
|
|
846
|
+
if not preserve_extra_content:
|
|
847
|
+
copied_tool_call.pop(_GEMINI_EXTRA_CONTENT_KEY, None)
|
|
848
|
+
copied_tool_calls.append(copied_tool_call)
|
|
849
|
+
return copied_tool_calls
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _gemini_extra_content_indexes(metadata: Any) -> tuple[dict[str, Any], dict[int, Any]]:
|
|
853
|
+
if not isinstance(metadata, dict):
|
|
854
|
+
return {}, {}
|
|
855
|
+
entries = metadata.get(_TOOL_CALL_PROVIDER_METADATA_KEY)
|
|
856
|
+
if not isinstance(entries, list):
|
|
857
|
+
return {}, {}
|
|
858
|
+
by_id: dict[str, Any] = {}
|
|
859
|
+
by_index: dict[int, Any] = {}
|
|
860
|
+
for entry in entries:
|
|
861
|
+
if not isinstance(entry, dict):
|
|
862
|
+
continue
|
|
863
|
+
entry_metadata = entry.get("metadata")
|
|
864
|
+
if not isinstance(entry_metadata, dict):
|
|
865
|
+
continue
|
|
866
|
+
gemini = entry_metadata.get(_GEMINI_PROVIDER_KEY)
|
|
867
|
+
if not isinstance(gemini, dict):
|
|
868
|
+
continue
|
|
869
|
+
extra_content = gemini.get(_GEMINI_EXTRA_CONTENT_KEY)
|
|
870
|
+
if not isinstance(extra_content, dict) or not extra_content:
|
|
871
|
+
continue
|
|
872
|
+
tool_call_id = entry.get("id")
|
|
873
|
+
if isinstance(tool_call_id, str) and tool_call_id:
|
|
874
|
+
by_id[tool_call_id] = copy.deepcopy(extra_content)
|
|
875
|
+
index = entry.get("index")
|
|
876
|
+
if isinstance(index, int):
|
|
877
|
+
by_index[index] = copy.deepcopy(extra_content)
|
|
878
|
+
return by_id, by_index
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _reattach_gemini_tool_call_extra_content(
|
|
882
|
+
message: dict[str, Any],
|
|
883
|
+
metadata: Any,
|
|
884
|
+
) -> None:
|
|
885
|
+
tool_calls = message.get("tool_calls")
|
|
886
|
+
if not isinstance(tool_calls, list) or not tool_calls:
|
|
887
|
+
return
|
|
888
|
+
by_id, by_index = _gemini_extra_content_indexes(metadata)
|
|
889
|
+
if not by_id and not by_index:
|
|
890
|
+
return
|
|
891
|
+
reattached: list[Any] = []
|
|
892
|
+
for index, tool_call in enumerate(tool_calls):
|
|
893
|
+
if not isinstance(tool_call, dict):
|
|
894
|
+
reattached.append(tool_call)
|
|
895
|
+
continue
|
|
896
|
+
copied_tool_call = dict(tool_call)
|
|
897
|
+
tool_call_id = str(copied_tool_call.get("id") or "")
|
|
898
|
+
extra_content = by_id.get(tool_call_id) if tool_call_id else None
|
|
899
|
+
if extra_content is None:
|
|
900
|
+
extra_content = by_index.get(index)
|
|
901
|
+
if isinstance(extra_content, dict) and extra_content:
|
|
902
|
+
copied_tool_call[_GEMINI_EXTRA_CONTENT_KEY] = copy.deepcopy(extra_content)
|
|
903
|
+
reattached.append(copied_tool_call)
|
|
904
|
+
message["tool_calls"] = reattached
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _message_for_transport(
|
|
908
|
+
message: dict[str, Any],
|
|
909
|
+
*,
|
|
910
|
+
provider_key: str | None,
|
|
911
|
+
reasoning_provider_key: str | None = None,
|
|
912
|
+
model: str | None = None,
|
|
913
|
+
) -> dict[str, Any]:
|
|
914
|
+
metadata = message.get(PROVIDER_METADATA_KEY)
|
|
915
|
+
copied = strip_provider_metadata_from_message(message)
|
|
916
|
+
if str(copied.get("role") or "") != "assistant":
|
|
917
|
+
return copied
|
|
918
|
+
has_tool_calls = bool(copied.get("tool_calls"))
|
|
919
|
+
if has_tool_calls:
|
|
920
|
+
copied["tool_calls"] = _copy_transport_tool_calls(
|
|
921
|
+
copied.get("tool_calls"),
|
|
922
|
+
preserve_extra_content=_is_gemini_provider(provider_key),
|
|
923
|
+
)
|
|
924
|
+
if _is_gemini_provider(provider_key):
|
|
925
|
+
_reattach_gemini_tool_call_extra_content(copied, metadata)
|
|
926
|
+
together_deepseek_transport = _is_together_deepseek_pro(provider_key, model)
|
|
927
|
+
deepseek_transport = _is_deepseek_provider(reasoning_provider_key)
|
|
928
|
+
replay_all_deepseek_turns = reasoning_contract_for(
|
|
929
|
+
reasoning_provider_key,
|
|
930
|
+
model,
|
|
931
|
+
).replay_reasoning_content
|
|
932
|
+
if (deepseek_transport or together_deepseek_transport) and (
|
|
933
|
+
has_tool_calls or replay_all_deepseek_turns
|
|
934
|
+
):
|
|
935
|
+
reasoning = _deepseek_reasoning_from_provider_metadata(metadata)
|
|
936
|
+
if reasoning:
|
|
937
|
+
if together_deepseek_transport:
|
|
938
|
+
copied[_OPENROUTER_REASONING_KEY] = reasoning
|
|
939
|
+
else:
|
|
940
|
+
copied[_DEEPSEEK_REASONING_CONTENT_KEY] = reasoning
|
|
941
|
+
elif _is_dashscope_provider(reasoning_provider_key) and (
|
|
942
|
+
has_tool_calls or reasoning_contract_for(_QWEN_PROVIDER_KEY, model).replay_reasoning_content
|
|
943
|
+
):
|
|
944
|
+
reasoning = _qwen_reasoning_from_provider_metadata(metadata)
|
|
945
|
+
if reasoning:
|
|
946
|
+
copied[_DEEPSEEK_REASONING_CONTENT_KEY] = reasoning
|
|
947
|
+
elif has_tool_calls and _is_openrouter_provider(reasoning_provider_key):
|
|
948
|
+
reasoning, reasoning_details = _openrouter_reasoning_from_provider_metadata(metadata)
|
|
949
|
+
if reasoning:
|
|
950
|
+
copied[_OPENROUTER_REASONING_KEY] = reasoning
|
|
951
|
+
if reasoning_details:
|
|
952
|
+
copied[_OPENROUTER_REASONING_DETAILS_KEY] = reasoning_details
|
|
953
|
+
elif has_tool_calls and _is_nvidia_provider(reasoning_provider_key):
|
|
954
|
+
reasoning = _nvidia_reasoning_from_provider_metadata(metadata)
|
|
955
|
+
if reasoning:
|
|
956
|
+
copied[_DEEPSEEK_REASONING_CONTENT_KEY] = reasoning
|
|
957
|
+
elif _is_moonshot_provider(reasoning_provider_key):
|
|
958
|
+
reasoning = _moonshot_reasoning_from_provider_metadata(metadata)
|
|
959
|
+
if reasoning:
|
|
960
|
+
copied[_DEEPSEEK_REASONING_CONTENT_KEY] = reasoning
|
|
961
|
+
elif _is_mistral_provider(reasoning_provider_key):
|
|
962
|
+
content = _mistral_content_from_provider_metadata(metadata)
|
|
963
|
+
if content:
|
|
964
|
+
copied["content"] = content
|
|
965
|
+
return copied
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _messages_for_transport(
|
|
969
|
+
messages: list[dict[str, Any]],
|
|
970
|
+
*,
|
|
971
|
+
provider_key: str | None,
|
|
972
|
+
reasoning_provider_key: str | None = None,
|
|
973
|
+
model: str | None = None,
|
|
974
|
+
) -> list[dict[str, Any]]:
|
|
975
|
+
return [
|
|
976
|
+
_message_for_transport(
|
|
977
|
+
message,
|
|
978
|
+
provider_key=provider_key,
|
|
979
|
+
reasoning_provider_key=reasoning_provider_key,
|
|
980
|
+
model=model,
|
|
981
|
+
)
|
|
982
|
+
for message in messages
|
|
983
|
+
if isinstance(message, dict)
|
|
984
|
+
]
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _normalize_assistant_content_to_text(raw: Any) -> str:
|
|
988
|
+
if raw is None:
|
|
989
|
+
return ""
|
|
990
|
+
if isinstance(raw, str):
|
|
991
|
+
return raw
|
|
992
|
+
if isinstance(raw, list):
|
|
993
|
+
return "".join(_normalize_assistant_content_to_text(item) for item in raw)
|
|
994
|
+
if isinstance(raw, dict):
|
|
995
|
+
part_type = raw.get("type")
|
|
996
|
+
text = raw.get("text")
|
|
997
|
+
if isinstance(text, str) and (
|
|
998
|
+
part_type in _TEXT_LIKE_CONTENT_PART_TYPES or part_type is None
|
|
999
|
+
):
|
|
1000
|
+
return text
|
|
1001
|
+
return ""
|
|
1002
|
+
return ""
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _append_mistral_stream_content(chunks: list[dict[str, Any]], raw: Any) -> None:
|
|
1006
|
+
"""Reconstruct replayable Mistral content without exposing ThinkChunk text."""
|
|
1007
|
+
|
|
1008
|
+
if isinstance(raw, str):
|
|
1009
|
+
incoming: list[Any] = [{"type": "text", "text": raw}] if raw else []
|
|
1010
|
+
elif isinstance(raw, list):
|
|
1011
|
+
incoming = raw
|
|
1012
|
+
else:
|
|
1013
|
+
return
|
|
1014
|
+
|
|
1015
|
+
for item in incoming:
|
|
1016
|
+
if not isinstance(item, dict):
|
|
1017
|
+
continue
|
|
1018
|
+
copied = copy.deepcopy(item)
|
|
1019
|
+
item_type = str(copied.get("type") or "").casefold()
|
|
1020
|
+
if item_type == "text" and chunks:
|
|
1021
|
+
previous = chunks[-1]
|
|
1022
|
+
previous_text = previous.get("text")
|
|
1023
|
+
incoming_text = copied.get("text")
|
|
1024
|
+
if (
|
|
1025
|
+
str(previous.get("type") or "").casefold() == "text"
|
|
1026
|
+
and isinstance(previous_text, str)
|
|
1027
|
+
and isinstance(incoming_text, str)
|
|
1028
|
+
):
|
|
1029
|
+
previous["text"] = previous_text + incoming_text
|
|
1030
|
+
continue
|
|
1031
|
+
if item_type == "thinking" and chunks:
|
|
1032
|
+
previous = chunks[-1]
|
|
1033
|
+
previous_thinking = previous.get("thinking")
|
|
1034
|
+
incoming_thinking = copied.get("thinking")
|
|
1035
|
+
if (
|
|
1036
|
+
str(previous.get("type") or "").casefold() == "thinking"
|
|
1037
|
+
and previous.get("closed") is not True
|
|
1038
|
+
):
|
|
1039
|
+
if isinstance(previous_thinking, list) and isinstance(incoming_thinking, list):
|
|
1040
|
+
previous["thinking"] = previous_thinking + incoming_thinking
|
|
1041
|
+
elif isinstance(previous_thinking, str) and isinstance(incoming_thinking, str):
|
|
1042
|
+
previous["thinking"] = previous_thinking + incoming_thinking
|
|
1043
|
+
else:
|
|
1044
|
+
chunks.append(copied)
|
|
1045
|
+
continue
|
|
1046
|
+
for key, value in copied.items():
|
|
1047
|
+
if key == "thinking":
|
|
1048
|
+
continue
|
|
1049
|
+
if key == "signature" and value is None:
|
|
1050
|
+
continue
|
|
1051
|
+
previous[key] = value
|
|
1052
|
+
continue
|
|
1053
|
+
chunks.append(copied)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _parse_arguments(args_s: str) -> dict[str, Any]:
|
|
1057
|
+
try:
|
|
1058
|
+
args = json.loads(args_s)
|
|
1059
|
+
except json.JSONDecodeError:
|
|
1060
|
+
return {"_raw_arguments": args_s}
|
|
1061
|
+
if not isinstance(args, dict):
|
|
1062
|
+
return {"_raw_arguments": args_s}
|
|
1063
|
+
return args
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _parse_tool_calls(tool_calls_raw: list[dict[str, Any]]) -> list[ToolCall]:
|
|
1067
|
+
tool_calls: list[ToolCall] = []
|
|
1068
|
+
for tc in tool_calls_raw:
|
|
1069
|
+
try:
|
|
1070
|
+
tc_id = tc["id"]
|
|
1071
|
+
fn = tc["function"]
|
|
1072
|
+
name = fn["name"]
|
|
1073
|
+
args_s = fn.get("arguments") or "{}"
|
|
1074
|
+
if not isinstance(args_s, str):
|
|
1075
|
+
args_s = json.dumps(args_s)
|
|
1076
|
+
tool_calls.append(
|
|
1077
|
+
ToolCall(
|
|
1078
|
+
id=tc_id,
|
|
1079
|
+
name=name,
|
|
1080
|
+
arguments=_parse_arguments(args_s),
|
|
1081
|
+
provider_metadata=_gemini_tool_call_provider_metadata(tc),
|
|
1082
|
+
)
|
|
1083
|
+
)
|
|
1084
|
+
except Exception:
|
|
1085
|
+
continue
|
|
1086
|
+
return tool_calls
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _parse_stream_tool_calls(tool_chunks: dict[int, dict[str, Any]]) -> list[ToolCall]:
|
|
1090
|
+
out: list[ToolCall] = []
|
|
1091
|
+
for idx in sorted(tool_chunks):
|
|
1092
|
+
chunk = tool_chunks[idx]
|
|
1093
|
+
name = chunk.get("name") or ""
|
|
1094
|
+
if not name:
|
|
1095
|
+
continue
|
|
1096
|
+
tc_id = chunk.get("id") or f"call_{idx}"
|
|
1097
|
+
args_s = chunk.get("arguments") or "{}"
|
|
1098
|
+
metadata = chunk.get("provider_metadata")
|
|
1099
|
+
out.append(
|
|
1100
|
+
ToolCall(
|
|
1101
|
+
id=tc_id,
|
|
1102
|
+
name=name,
|
|
1103
|
+
arguments=_parse_arguments(args_s),
|
|
1104
|
+
provider_metadata=dict(metadata)
|
|
1105
|
+
if isinstance(metadata, dict) and metadata
|
|
1106
|
+
else None,
|
|
1107
|
+
)
|
|
1108
|
+
)
|
|
1109
|
+
return out
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _parse_usage(raw: Any, *, provider_key: str | None = None) -> LLMUsage | None:
|
|
1113
|
+
return parse_compatible_usage(raw, provider_key=provider_key)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _is_stream_options_unsupported_error(err: LLMError) -> bool:
|
|
1117
|
+
msg = str(err).lower()
|
|
1118
|
+
if "stream_options" not in msg:
|
|
1119
|
+
return False
|
|
1120
|
+
return any(token in msg for token in ("unsupported", "unknown", "invalid", "not allowed"))
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _llm_error_status_code(err: LLMError) -> int | None:
|
|
1124
|
+
status_code = getattr(err, "provider_status_code", None)
|
|
1125
|
+
if isinstance(status_code, int):
|
|
1126
|
+
return status_code
|
|
1127
|
+
match = re.match(r"LLM error\s+(\d{3}):", str(err or "").strip())
|
|
1128
|
+
if match is None:
|
|
1129
|
+
return None
|
|
1130
|
+
try:
|
|
1131
|
+
return int(match.group(1))
|
|
1132
|
+
except ValueError:
|
|
1133
|
+
return None
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _llm_error_body(err: LLMError) -> str:
|
|
1137
|
+
full_body = getattr(err, "provider_error_body", None)
|
|
1138
|
+
if isinstance(full_body, str):
|
|
1139
|
+
return full_body.strip()
|
|
1140
|
+
_prefix, sep, body = str(err or "").partition(":")
|
|
1141
|
+
return body.strip() if sep else str(err or "").strip()
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _json_error_payload(body: str) -> dict[str, Any]:
|
|
1145
|
+
try:
|
|
1146
|
+
payload = json.loads(body)
|
|
1147
|
+
except json.JSONDecodeError:
|
|
1148
|
+
return {}
|
|
1149
|
+
return payload if isinstance(payload, dict) else {}
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
# Friendly, user-facing copy for the Alysis Code MiMo trial proxy's error codes.
|
|
1153
|
+
# The proxy (Supabase Edge Function) returns an OpenAI-shaped envelope
|
|
1154
|
+
# {"error": {"message": ..., "type": ..., "code": "<reason>"}} with these string
|
|
1155
|
+
# codes; upstream/other-provider errors use different codes and fall through.
|
|
1156
|
+
_ALYSIS_PROXY_ERROR_MESSAGES: dict[str, str] = {
|
|
1157
|
+
"invalid_key": (
|
|
1158
|
+
"Your Alysis Code session is invalid or has been reset. "
|
|
1159
|
+
"Run `alysis login` to reconnect your account."
|
|
1160
|
+
),
|
|
1161
|
+
"trial_expired": ("Your 10-day free MiMo trial has ended. See your options at {account_url}"),
|
|
1162
|
+
"quota_exhausted": (
|
|
1163
|
+
"You've used all of your free MiMo trial tokens. See your options at {account_url}"
|
|
1164
|
+
),
|
|
1165
|
+
"email_not_verified": (
|
|
1166
|
+
"Please confirm your email to use the MiMo trial — "
|
|
1167
|
+
"check your inbox for the verification link."
|
|
1168
|
+
),
|
|
1169
|
+
"plan_inactive": ("Your Alysis Code plan is not active. Visit {account_url} to continue."),
|
|
1170
|
+
"rate_limit_exceeded": (
|
|
1171
|
+
"You're sending requests too quickly. Please wait a moment and try again."
|
|
1172
|
+
),
|
|
1173
|
+
"global_budget_exceeded": (
|
|
1174
|
+
"The free MiMo trial is at capacity right now. Please try again shortly."
|
|
1175
|
+
),
|
|
1176
|
+
"proxy_unconfigured": ("The MiMo service is temporarily unavailable. Please try again later."),
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def alysis_trial_error_message(err: LLMError) -> str | None:
|
|
1181
|
+
"""Friendly message for an Alysis Code MiMo proxy error, or None if not ours.
|
|
1182
|
+
|
|
1183
|
+
Maps the proxy's known error ``code`` (trial_expired, quota_exhausted, ...) to
|
|
1184
|
+
human copy so a user whose trial ended sees a clear next step instead of a raw
|
|
1185
|
+
``LLM error 402: {...}`` dump. Returns None for any other failure (including
|
|
1186
|
+
upstream OpenRouter errors, which use numeric codes), so non-proxy errors
|
|
1187
|
+
render unchanged.
|
|
1188
|
+
"""
|
|
1189
|
+
payload = _json_error_payload(_llm_error_body(err))
|
|
1190
|
+
error = payload.get("error")
|
|
1191
|
+
if not isinstance(error, dict):
|
|
1192
|
+
return None
|
|
1193
|
+
code = str(error.get("code") or "").strip()
|
|
1194
|
+
template = _ALYSIS_PROXY_ERROR_MESSAGES.get(code)
|
|
1195
|
+
if template is None or "{account_url}" not in template:
|
|
1196
|
+
# str.format would also choke on any literal brace in an unrelated
|
|
1197
|
+
# message, so substitute only where the placeholder actually appears.
|
|
1198
|
+
return template
|
|
1199
|
+
# Resolved at render time, not import time: the account URL follows
|
|
1200
|
+
# ALYSIS_SITE_URL, which a staging deployment overrides per-process.
|
|
1201
|
+
from ..alysis_cloud import account_url
|
|
1202
|
+
|
|
1203
|
+
return template.replace("{account_url}", account_url())
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _temperature_error_fields(err: LLMError) -> tuple[str, str, str] | None:
|
|
1207
|
+
status_code = _llm_error_status_code(err)
|
|
1208
|
+
if status_code not in _TEMPERATURE_UNSUPPORTED_STATUS_CODES:
|
|
1209
|
+
return None
|
|
1210
|
+
|
|
1211
|
+
body = _llm_error_body(err)
|
|
1212
|
+
payload = _json_error_payload(body)
|
|
1213
|
+
if not payload:
|
|
1214
|
+
message = body.strip().casefold()
|
|
1215
|
+
if "temperature" in message and any(
|
|
1216
|
+
token in message for token in _TEMPERATURE_UNSUPPORTED_TOKENS
|
|
1217
|
+
):
|
|
1218
|
+
return "", "", message
|
|
1219
|
+
return None
|
|
1220
|
+
|
|
1221
|
+
raw_error = payload.get("error")
|
|
1222
|
+
error: dict[str, Any] = raw_error if isinstance(raw_error, dict) else payload
|
|
1223
|
+
param = str(error.get("param") or "").strip().casefold()
|
|
1224
|
+
code = str(error.get("code") or "").strip().casefold()
|
|
1225
|
+
message = str(error.get("message") or "").strip().casefold()
|
|
1226
|
+
combined = f"{code} {message}"
|
|
1227
|
+
has_unsupported_marker = any(token in combined for token in _TEMPERATURE_UNSUPPORTED_TOKENS)
|
|
1228
|
+
if param == "temperature" and (code or message) and has_unsupported_marker:
|
|
1229
|
+
return param, code, message
|
|
1230
|
+
if "temperature" in message and has_unsupported_marker:
|
|
1231
|
+
return param, code, message
|
|
1232
|
+
return None
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _temperature_unsupported_error(err: LLMError) -> bool:
|
|
1236
|
+
return _temperature_error_fields(err) is not None
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def _tool_choice_unsupported_error(err: LLMError) -> bool:
|
|
1240
|
+
status_code = _llm_error_status_code(err)
|
|
1241
|
+
if status_code not in _TOOL_CHOICE_UNSUPPORTED_STATUS_CODES:
|
|
1242
|
+
return False
|
|
1243
|
+
|
|
1244
|
+
body = _llm_error_body(err)
|
|
1245
|
+
payload = _json_error_payload(body)
|
|
1246
|
+
if not payload:
|
|
1247
|
+
message = body.strip().casefold()
|
|
1248
|
+
return "tool_choice" in message and any(
|
|
1249
|
+
token in message for token in _TOOL_CHOICE_UNSUPPORTED_TOKENS
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
raw_error = payload.get("error")
|
|
1253
|
+
error: dict[str, Any] = raw_error if isinstance(raw_error, dict) else payload
|
|
1254
|
+
param = str(error.get("param") or "").strip().casefold()
|
|
1255
|
+
code = str(error.get("code") or "").strip().casefold()
|
|
1256
|
+
message = str(error.get("message") or "").strip().casefold()
|
|
1257
|
+
combined = f"{param} {code} {message}"
|
|
1258
|
+
if param == "tool_choice":
|
|
1259
|
+
return True
|
|
1260
|
+
return "tool_choice" in combined and any(
|
|
1261
|
+
token in combined for token in _TOOL_CHOICE_UNSUPPORTED_TOKENS
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _tool_calling_unsupported_error(err: LLMError) -> bool:
|
|
1266
|
+
status_code = _llm_error_status_code(err)
|
|
1267
|
+
if status_code is None or status_code < 400 or status_code >= 500 or status_code == 429:
|
|
1268
|
+
return False
|
|
1269
|
+
|
|
1270
|
+
body = _llm_error_body(err)
|
|
1271
|
+
payload = _json_error_payload(body)
|
|
1272
|
+
if not payload:
|
|
1273
|
+
combined = body.strip().casefold()
|
|
1274
|
+
else:
|
|
1275
|
+
raw_error = payload.get("error")
|
|
1276
|
+
error: dict[str, Any] = raw_error if isinstance(raw_error, dict) else payload
|
|
1277
|
+
param = str(error.get("param") or "").strip().casefold()
|
|
1278
|
+
code = str(error.get("code") or "").strip().casefold()
|
|
1279
|
+
message = str(error.get("message") or "").strip().casefold()
|
|
1280
|
+
if param in _TOOL_CALLING_REJECTION_PARAMS:
|
|
1281
|
+
return True
|
|
1282
|
+
combined = f"{param} {code} {message}"
|
|
1283
|
+
if "tool_choice" in combined:
|
|
1284
|
+
return False
|
|
1285
|
+
return any(term in combined for term in _TOOL_CALLING_REJECTION_TERMS) and any(
|
|
1286
|
+
token in combined for token in _TOOL_CHOICE_UNSUPPORTED_TOKENS
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _is_temperature_default_value(value: Any) -> bool:
|
|
1291
|
+
try:
|
|
1292
|
+
return float(value) == _TEMPERATURE_DEFAULT_VALUE
|
|
1293
|
+
except (TypeError, ValueError):
|
|
1294
|
+
return False
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _temperature_compat_mode_for_error(
|
|
1298
|
+
err: LLMError,
|
|
1299
|
+
*,
|
|
1300
|
+
current_temperature: Any,
|
|
1301
|
+
) -> str | None:
|
|
1302
|
+
fields = _temperature_error_fields(err)
|
|
1303
|
+
if fields is None:
|
|
1304
|
+
return None
|
|
1305
|
+
_param, _code, message = fields
|
|
1306
|
+
if "deprecated" in message:
|
|
1307
|
+
return _TEMPERATURE_COMPAT_MODE_OMIT
|
|
1308
|
+
if not _is_temperature_default_value(current_temperature):
|
|
1309
|
+
return _TEMPERATURE_COMPAT_MODE_DEFAULT
|
|
1310
|
+
return _TEMPERATURE_COMPAT_MODE_OMIT
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _safe_cache_request_field_values(values: Mapping[str, Any] | None) -> dict[str, str]:
|
|
1314
|
+
if not isinstance(values, Mapping):
|
|
1315
|
+
return {}
|
|
1316
|
+
safe: dict[str, str] = {}
|
|
1317
|
+
for field in _PROMPT_CACHE_FIELDS:
|
|
1318
|
+
value = values.get(field)
|
|
1319
|
+
text = str(value or "").strip()
|
|
1320
|
+
if not text or "\r" in text or "\n" in text:
|
|
1321
|
+
continue
|
|
1322
|
+
safe[field] = text
|
|
1323
|
+
return safe
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _set_header_if_absent(headers: dict[str, str], field: str, value: str) -> None:
|
|
1327
|
+
if not value:
|
|
1328
|
+
return
|
|
1329
|
+
lowered = field.casefold()
|
|
1330
|
+
if any(str(key).casefold() == lowered for key in headers):
|
|
1331
|
+
return
|
|
1332
|
+
headers[field] = value
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def _strip_header_case_insensitive(headers: dict[str, str], field: str) -> None:
|
|
1336
|
+
lowered = field.casefold()
|
|
1337
|
+
for key in list(headers):
|
|
1338
|
+
if str(key).casefold() == lowered:
|
|
1339
|
+
headers.pop(key, None)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _cache_fields_in_request(
|
|
1343
|
+
*,
|
|
1344
|
+
payload: Mapping[str, Any],
|
|
1345
|
+
headers: Mapping[str, str],
|
|
1346
|
+
) -> tuple[str, ...]:
|
|
1347
|
+
header_keys = {str(key).casefold() for key in headers}
|
|
1348
|
+
fields: list[str] = []
|
|
1349
|
+
for field in _PROMPT_CACHE_FIELDS:
|
|
1350
|
+
if field in _CACHE_HEADER_FIELDS:
|
|
1351
|
+
if field.casefold() in header_keys:
|
|
1352
|
+
fields.append(field)
|
|
1353
|
+
elif field == CACHE_CONTROL_FIELD:
|
|
1354
|
+
if count_cache_control_blocks(payload) > 0:
|
|
1355
|
+
fields.append(field)
|
|
1356
|
+
elif field in payload:
|
|
1357
|
+
fields.append(field)
|
|
1358
|
+
return tuple(fields)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _strip_cache_request_fields(
|
|
1362
|
+
*,
|
|
1363
|
+
payload: dict[str, Any],
|
|
1364
|
+
headers: dict[str, str],
|
|
1365
|
+
fields: tuple[str, ...],
|
|
1366
|
+
) -> None:
|
|
1367
|
+
for field in fields:
|
|
1368
|
+
if field in _CACHE_HEADER_FIELDS:
|
|
1369
|
+
_strip_header_case_insensitive(headers, field)
|
|
1370
|
+
elif field == CACHE_CONTROL_FIELD:
|
|
1371
|
+
payload.pop(field, None)
|
|
1372
|
+
strip_cache_control_blocks(payload)
|
|
1373
|
+
else:
|
|
1374
|
+
payload.pop(field, None)
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _cache_param_rejected_fields(
|
|
1378
|
+
err: LLMError,
|
|
1379
|
+
*,
|
|
1380
|
+
payload: Mapping[str, Any],
|
|
1381
|
+
headers: Mapping[str, str],
|
|
1382
|
+
) -> tuple[str, ...]:
|
|
1383
|
+
active_fields = _cache_fields_in_request(payload=payload, headers=headers)
|
|
1384
|
+
if not active_fields:
|
|
1385
|
+
return ()
|
|
1386
|
+
status_code = _llm_error_status_code(err)
|
|
1387
|
+
if status_code not in _CACHE_PARAM_UNSUPPORTED_STATUS_CODES:
|
|
1388
|
+
return ()
|
|
1389
|
+
|
|
1390
|
+
body = _llm_error_body(err)
|
|
1391
|
+
payload_error = _json_error_payload(body)
|
|
1392
|
+
if payload_error:
|
|
1393
|
+
raw_error = payload_error.get("error")
|
|
1394
|
+
error = raw_error if isinstance(raw_error, dict) else payload_error
|
|
1395
|
+
param = str(error.get("param") or "").strip().casefold()
|
|
1396
|
+
code = str(error.get("code") or "").strip().casefold()
|
|
1397
|
+
message = str(error.get("message") or "").strip().casefold()
|
|
1398
|
+
combined = f"{param} {code} {message}"
|
|
1399
|
+
if not combined.strip():
|
|
1400
|
+
# JSON error envelopes without param/code/message (FastAPI/pydantic
|
|
1401
|
+
# detail lists, bare {"error": "<string>"}) still name the field in
|
|
1402
|
+
# the raw body.
|
|
1403
|
+
combined = body.strip().casefold()
|
|
1404
|
+
else:
|
|
1405
|
+
combined = body.strip().casefold()
|
|
1406
|
+
|
|
1407
|
+
rejected = [field for field in active_fields if field.casefold() in combined]
|
|
1408
|
+
if PROMPT_CACHE_KEY_FIELD in rejected and PROMPT_CACHE_RETENTION_FIELD in active_fields:
|
|
1409
|
+
rejected.append(PROMPT_CACHE_RETENTION_FIELD)
|
|
1410
|
+
if (
|
|
1411
|
+
OPENROUTER_SESSION_ID_FIELD in rejected
|
|
1412
|
+
and OPENROUTER_SESSION_ID_HEADER_FIELD in active_fields
|
|
1413
|
+
):
|
|
1414
|
+
rejected.append(OPENROUTER_SESSION_ID_HEADER_FIELD)
|
|
1415
|
+
if (
|
|
1416
|
+
OPENROUTER_SESSION_ID_HEADER_FIELD in rejected
|
|
1417
|
+
and OPENROUTER_SESSION_ID_FIELD in active_fields
|
|
1418
|
+
):
|
|
1419
|
+
rejected.append(OPENROUTER_SESSION_ID_FIELD)
|
|
1420
|
+
if rejected:
|
|
1421
|
+
return tuple(dict.fromkeys(rejected))
|
|
1422
|
+
|
|
1423
|
+
has_cache_signal = (
|
|
1424
|
+
"prompt cache" in combined
|
|
1425
|
+
or "prompt_cache" in combined
|
|
1426
|
+
or "cache_control" in combined
|
|
1427
|
+
or "cache control" in combined
|
|
1428
|
+
or "cache routing" in combined
|
|
1429
|
+
or "sticky" in combined
|
|
1430
|
+
)
|
|
1431
|
+
has_unsupported_marker = any(token in combined for token in _CACHE_PARAM_UNSUPPORTED_TOKENS)
|
|
1432
|
+
if has_cache_signal and has_unsupported_marker:
|
|
1433
|
+
return active_fields
|
|
1434
|
+
return ()
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _cache_policy_after_fields_disabled(
|
|
1438
|
+
cache_policy: Mapping[str, Any] | None,
|
|
1439
|
+
*,
|
|
1440
|
+
fields: tuple[str, ...],
|
|
1441
|
+
fallback: str,
|
|
1442
|
+
) -> dict[str, Any] | None:
|
|
1443
|
+
if not isinstance(cache_policy, Mapping) or not fields:
|
|
1444
|
+
return None if cache_policy is None else dict(cache_policy)
|
|
1445
|
+
disabled = set(fields)
|
|
1446
|
+
updated = dict(cache_policy)
|
|
1447
|
+
for key in ("emitted_fields", "allowed_fields"):
|
|
1448
|
+
value = updated.get(key)
|
|
1449
|
+
if isinstance(value, (list, tuple)):
|
|
1450
|
+
updated[key] = [field for field in value if str(field) not in disabled]
|
|
1451
|
+
existing_disabled = updated.get("disabled_fields")
|
|
1452
|
+
disabled_fields = []
|
|
1453
|
+
if isinstance(existing_disabled, (list, tuple)):
|
|
1454
|
+
disabled_fields.extend(str(field) for field in existing_disabled)
|
|
1455
|
+
disabled_fields.extend(fields)
|
|
1456
|
+
unique_disabled_fields = list(dict.fromkeys(disabled_fields))
|
|
1457
|
+
updated["disabled_fields"] = unique_disabled_fields
|
|
1458
|
+
updated["runtime_disabled_fields"] = unique_disabled_fields
|
|
1459
|
+
updated["capability_downgrade"] = "session_local_provider_rejection"
|
|
1460
|
+
updated["fallback"] = fallback
|
|
1461
|
+
if not updated.get("emitted_fields") and updated.get("status") == "enabled":
|
|
1462
|
+
updated["status"] = "available"
|
|
1463
|
+
updated["enabled"] = bool(updated.get("emitted_fields"))
|
|
1464
|
+
updated["emits_request_fields"] = bool(updated.get("allowed_fields"))
|
|
1465
|
+
return updated
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _merge_transport_metadata(
|
|
1469
|
+
response: LLMResponse,
|
|
1470
|
+
*,
|
|
1471
|
+
transport_metadata: dict[str, Any] | None,
|
|
1472
|
+
) -> LLMResponse:
|
|
1473
|
+
if not transport_metadata:
|
|
1474
|
+
return response
|
|
1475
|
+
provider_metadata = _merge_provider_metadata(
|
|
1476
|
+
response.provider_metadata,
|
|
1477
|
+
{"transport": transport_metadata},
|
|
1478
|
+
)
|
|
1479
|
+
return LLMResponse(
|
|
1480
|
+
content=response.content,
|
|
1481
|
+
tool_calls=list(response.tool_calls),
|
|
1482
|
+
raw=response.raw,
|
|
1483
|
+
response_model=response.response_model,
|
|
1484
|
+
usage=response.usage,
|
|
1485
|
+
provider_metadata=provider_metadata,
|
|
1486
|
+
reasoning=response.reasoning,
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _merge_request_plan_metadata(
|
|
1491
|
+
response: LLMResponse,
|
|
1492
|
+
*,
|
|
1493
|
+
request_plan_metadata: dict[str, Any] | None,
|
|
1494
|
+
) -> LLMResponse:
|
|
1495
|
+
if not request_plan_metadata:
|
|
1496
|
+
return response
|
|
1497
|
+
provider_metadata = _merge_provider_metadata(
|
|
1498
|
+
response.provider_metadata,
|
|
1499
|
+
{"openai_compat": {"request_plan": request_plan_metadata}},
|
|
1500
|
+
)
|
|
1501
|
+
return LLMResponse(
|
|
1502
|
+
content=response.content,
|
|
1503
|
+
tool_calls=list(response.tool_calls),
|
|
1504
|
+
raw=response.raw,
|
|
1505
|
+
response_model=response.response_model,
|
|
1506
|
+
usage=response.usage,
|
|
1507
|
+
provider_metadata=provider_metadata,
|
|
1508
|
+
reasoning=response.reasoning,
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def _display_error_body(body: str) -> str:
|
|
1513
|
+
if len(body) > _ERROR_BODY_DISPLAY_LIMIT:
|
|
1514
|
+
return body[:_ERROR_BODY_DISPLAY_LIMIT] + "...(truncated)"
|
|
1515
|
+
return body
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def _error_from_status_body(*, status_code: int, body: str) -> LLMError:
|
|
1519
|
+
safe_body = sanitize_error_text_for_output(body)
|
|
1520
|
+
err = LLMError(f"LLM error {status_code}: {_display_error_body(safe_body)}")
|
|
1521
|
+
err.provider_status_code = int(status_code)
|
|
1522
|
+
err.provider_error_body = safe_body
|
|
1523
|
+
return err
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _response_with_stream_restart_metadata(
|
|
1527
|
+
response: LLMResponse,
|
|
1528
|
+
*,
|
|
1529
|
+
count: int,
|
|
1530
|
+
reason: str,
|
|
1531
|
+
) -> LLMResponse:
|
|
1532
|
+
raw = dict(response.raw) if isinstance(response.raw, dict) else {}
|
|
1533
|
+
raw["stream_restart_count"] = max(0, int(count))
|
|
1534
|
+
if reason:
|
|
1535
|
+
raw["stream_restart_reason"] = str(reason)
|
|
1536
|
+
return LLMResponse(
|
|
1537
|
+
content=response.content,
|
|
1538
|
+
tool_calls=list(response.tool_calls),
|
|
1539
|
+
raw=raw,
|
|
1540
|
+
response_model=response.response_model,
|
|
1541
|
+
usage=response.usage,
|
|
1542
|
+
provider_metadata=response.provider_metadata,
|
|
1543
|
+
reasoning=response.reasoning,
|
|
1544
|
+
)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
class OpenAICompatClient:
|
|
1548
|
+
supports_forced_tool_choice = True
|
|
1549
|
+
|
|
1550
|
+
def __init__(
|
|
1551
|
+
self,
|
|
1552
|
+
*,
|
|
1553
|
+
base_url: str,
|
|
1554
|
+
api_key: str,
|
|
1555
|
+
model: str,
|
|
1556
|
+
timeout_s: float = 60.0,
|
|
1557
|
+
temperature: float = 1.0,
|
|
1558
|
+
prompt_cache_key: str | None = None,
|
|
1559
|
+
prompt_cache_retention: str | None = None,
|
|
1560
|
+
prompt_cache_request_field_values: Mapping[str, Any] | None = None,
|
|
1561
|
+
enable_thinking: bool | None = None,
|
|
1562
|
+
reasoning_effort: str | None = None,
|
|
1563
|
+
transport: httpx.BaseTransport | None = None,
|
|
1564
|
+
extra_headers: dict[str, str] | None = None,
|
|
1565
|
+
provider_key: str | None = None,
|
|
1566
|
+
reasoning_trace_adapter: str | None = "auto",
|
|
1567
|
+
usage_contract: UsageContract | None = None,
|
|
1568
|
+
usage_counts_authoritative: bool | None = None,
|
|
1569
|
+
provider_concurrency_caps: dict[str, int] | None = None,
|
|
1570
|
+
provider_retry_settings: ProviderRetrySettings | None = None,
|
|
1571
|
+
provider_sleep_fn: Callable[[float], None] | None = None,
|
|
1572
|
+
provider_random_fn: Callable[[], float] | None = None,
|
|
1573
|
+
prompt_cache_policy_metadata: Mapping[str, Any] | None = None,
|
|
1574
|
+
route_identity: ProviderRouteIdentity | None = None,
|
|
1575
|
+
stream_no_progress_timeout_s: float = 240.0,
|
|
1576
|
+
stream_progress_clock: Callable[[], float] | None = None,
|
|
1577
|
+
inflight_deadline_grace_s: float = 10.0,
|
|
1578
|
+
) -> None:
|
|
1579
|
+
self.base_url = base_url.rstrip("/")
|
|
1580
|
+
self.api_key = api_key
|
|
1581
|
+
self.model = model
|
|
1582
|
+
self.timeout_s = timeout_s
|
|
1583
|
+
self.temperature = temperature
|
|
1584
|
+
self.prompt_cache_key = str(prompt_cache_key or "").strip() or None
|
|
1585
|
+
self.prompt_cache_retention = str(prompt_cache_retention or "").strip() or None
|
|
1586
|
+
self.prompt_cache_request_field_values = _safe_cache_request_field_values(
|
|
1587
|
+
prompt_cache_request_field_values
|
|
1588
|
+
)
|
|
1589
|
+
self.enable_thinking = enable_thinking
|
|
1590
|
+
self.reasoning_effort = str(reasoning_effort or "").strip().lower() or None
|
|
1591
|
+
self._transport = transport
|
|
1592
|
+
self.extra_headers = canonicalize_extra_headers(extra_headers)
|
|
1593
|
+
self.provider_key = str(provider_key or "").strip() or None
|
|
1594
|
+
self.reasoning_trace_adapter = validate_reasoning_trace_adapter_for_protocol(
|
|
1595
|
+
protocol=OPENAI_COMPAT_PROTOCOL,
|
|
1596
|
+
adapter=reasoning_trace_adapter,
|
|
1597
|
+
)
|
|
1598
|
+
self.route_identity = route_identity or build_provider_route_identity(
|
|
1599
|
+
protocol=OPENAI_COMPAT_PROTOCOL,
|
|
1600
|
+
base_url=self.base_url,
|
|
1601
|
+
provider_key=self.provider_key,
|
|
1602
|
+
model=self.model,
|
|
1603
|
+
credential_scope=credential_scope_fingerprint(self.api_key),
|
|
1604
|
+
routing_headers=self.extra_headers,
|
|
1605
|
+
routing_fields=self.prompt_cache_request_field_values,
|
|
1606
|
+
reasoning_state_adapter=self.reasoning_trace_adapter,
|
|
1607
|
+
)
|
|
1608
|
+
if usage_contract is None:
|
|
1609
|
+
usage_contract = UsageContract(
|
|
1610
|
+
response_usage_confidence=(
|
|
1611
|
+
UsageConfidence.AUTHORITATIVE
|
|
1612
|
+
if usage_counts_authoritative
|
|
1613
|
+
else UsageConfidence.REPORTED
|
|
1614
|
+
),
|
|
1615
|
+
input_token_count_strategy="openai_compat_provider_payload",
|
|
1616
|
+
)
|
|
1617
|
+
self.usage_contract = usage_contract
|
|
1618
|
+
self.usage_counts_authoritative = usage_contract.response_usage_authoritative
|
|
1619
|
+
self.provider_concurrency_caps = dict(
|
|
1620
|
+
DEFAULT_PROVIDER_CONCURRENCY_CAPS
|
|
1621
|
+
if provider_concurrency_caps is None
|
|
1622
|
+
else provider_concurrency_caps
|
|
1623
|
+
)
|
|
1624
|
+
self.provider_retry_settings = provider_retry_settings or ProviderRetrySettings()
|
|
1625
|
+
self._provider_sleep_fn = provider_sleep_fn
|
|
1626
|
+
self._provider_random_fn = provider_random_fn
|
|
1627
|
+
self._provider_retry_deadline_allows: Callable[[float], bool] | None = None
|
|
1628
|
+
self._provider_retry_event_observer: Callable[[dict[str, object]], None] | None = None
|
|
1629
|
+
self.stream_no_progress_timeout_s = max(
|
|
1630
|
+
0.001,
|
|
1631
|
+
float(stream_no_progress_timeout_s),
|
|
1632
|
+
)
|
|
1633
|
+
self._stream_progress_clock = stream_progress_clock or monotonic
|
|
1634
|
+
self.inflight_deadline_grace_s = max(
|
|
1635
|
+
0.0,
|
|
1636
|
+
float(inflight_deadline_grace_s),
|
|
1637
|
+
)
|
|
1638
|
+
self.prompt_cache_policy_metadata = (
|
|
1639
|
+
copy.deepcopy(dict(prompt_cache_policy_metadata))
|
|
1640
|
+
if isinstance(prompt_cache_policy_metadata, Mapping)
|
|
1641
|
+
else None
|
|
1642
|
+
)
|
|
1643
|
+
self._disabled_prompt_cache_fields: set[str] = set()
|
|
1644
|
+
self._disabled_prompt_cache_fields_lock = threading.Lock()
|
|
1645
|
+
self._provider_retry_wall_clock_cap_seconds = _PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS
|
|
1646
|
+
self._temperature_compat_modes: dict[tuple[str, str], str] = {}
|
|
1647
|
+
self._temperature_compat_lock = threading.Lock()
|
|
1648
|
+
self._tool_choice_compat_disabled: set[tuple[str, str]] = set()
|
|
1649
|
+
self._tool_choice_compat_lock = threading.Lock()
|
|
1650
|
+
self._tool_calling_compat_disabled: set[tuple[str, str, str]] = set()
|
|
1651
|
+
self._tool_calling_compat_lock = threading.Lock()
|
|
1652
|
+
|
|
1653
|
+
@property
|
|
1654
|
+
def reasoning_active(self) -> bool | None:
|
|
1655
|
+
"""Whether incompatible reasoning is known to be active on this route.
|
|
1656
|
+
|
|
1657
|
+
Agent recovery uses this to avoid manufacturing a forced tool choice
|
|
1658
|
+
that the provider's active reasoning mode cannot accept. Contracts that
|
|
1659
|
+
accept tool choice, plus unknown custom transports, remain ``None`` and
|
|
1660
|
+
keep the legacy capability fallback.
|
|
1661
|
+
"""
|
|
1662
|
+
|
|
1663
|
+
transport_provider_key = _transport_provider_key(
|
|
1664
|
+
base_url=self.base_url,
|
|
1665
|
+
provider_key=self.provider_key,
|
|
1666
|
+
model=self.model,
|
|
1667
|
+
)
|
|
1668
|
+
reasoning_provider_key = _reasoning_transport_provider_key(
|
|
1669
|
+
transport_provider_key=transport_provider_key,
|
|
1670
|
+
reasoning_trace_adapter=self.reasoning_trace_adapter,
|
|
1671
|
+
)
|
|
1672
|
+
contract = reasoning_contract_for(reasoning_provider_key, self.model)
|
|
1673
|
+
if contract.accepts_tool_choice_while_reasoning:
|
|
1674
|
+
return None
|
|
1675
|
+
return _reasoning_contract_active(
|
|
1676
|
+
provider_key=reasoning_provider_key,
|
|
1677
|
+
model=self.model,
|
|
1678
|
+
enable_thinking=self.enable_thinking,
|
|
1679
|
+
reasoning_effort=self.reasoning_effort,
|
|
1680
|
+
)
|
|
1681
|
+
|
|
1682
|
+
def _temperature_compat_key(self, provider_key: str | None) -> tuple[str, str]:
|
|
1683
|
+
provider = _normalize_provider_key(provider_key) or _normalize_provider_key(self.base_url)
|
|
1684
|
+
model = str(self.model or "").strip().casefold()
|
|
1685
|
+
return provider, model
|
|
1686
|
+
|
|
1687
|
+
def _temperature_compat_mode_for(self, key: tuple[str, str]) -> str | None:
|
|
1688
|
+
with self._temperature_compat_lock:
|
|
1689
|
+
return self._temperature_compat_modes.get(key)
|
|
1690
|
+
|
|
1691
|
+
def _mark_temperature_compat_mode(self, key: tuple[str, str], mode: str) -> None:
|
|
1692
|
+
if mode not in _TEMPERATURE_COMPAT_MODES:
|
|
1693
|
+
raise ValueError(f"Unknown temperature compatibility mode: {mode}")
|
|
1694
|
+
with self._temperature_compat_lock:
|
|
1695
|
+
self._temperature_compat_modes[key] = mode
|
|
1696
|
+
|
|
1697
|
+
def _disabled_prompt_cache_fields_snapshot(self) -> tuple[str, ...]:
|
|
1698
|
+
with self._disabled_prompt_cache_fields_lock:
|
|
1699
|
+
return tuple(
|
|
1700
|
+
field
|
|
1701
|
+
for field in _PROMPT_CACHE_FIELDS
|
|
1702
|
+
if field in self._disabled_prompt_cache_fields
|
|
1703
|
+
)
|
|
1704
|
+
|
|
1705
|
+
def _disable_prompt_cache_fields(self, fields: tuple[str, ...]) -> tuple[str, ...]:
|
|
1706
|
+
requested = set(fields)
|
|
1707
|
+
clean_fields = tuple(field for field in _PROMPT_CACHE_FIELDS if field in requested)
|
|
1708
|
+
if not clean_fields:
|
|
1709
|
+
return ()
|
|
1710
|
+
with self._disabled_prompt_cache_fields_lock:
|
|
1711
|
+
for field in clean_fields:
|
|
1712
|
+
self._disabled_prompt_cache_fields.add(field)
|
|
1713
|
+
return tuple(
|
|
1714
|
+
field
|
|
1715
|
+
for field in _PROMPT_CACHE_FIELDS
|
|
1716
|
+
if field in self._disabled_prompt_cache_fields
|
|
1717
|
+
)
|
|
1718
|
+
|
|
1719
|
+
def _active_prompt_cache_request_field_values(
|
|
1720
|
+
self,
|
|
1721
|
+
disabled_fields: tuple[str, ...],
|
|
1722
|
+
) -> dict[str, str]:
|
|
1723
|
+
disabled = set(disabled_fields)
|
|
1724
|
+
safe_values = _safe_cache_request_field_values(self.prompt_cache_request_field_values)
|
|
1725
|
+
return {field: value for field, value in safe_values.items() if field not in disabled}
|
|
1726
|
+
|
|
1727
|
+
def _tool_choice_compat_key(self, provider_key: str | None) -> tuple[str, str]:
|
|
1728
|
+
provider = _normalize_provider_key(provider_key) or _normalize_provider_key(self.base_url)
|
|
1729
|
+
model = str(self.model or "").strip().casefold()
|
|
1730
|
+
return provider, model
|
|
1731
|
+
|
|
1732
|
+
def _tool_choice_compat_disabled_for(self, key: tuple[str, str]) -> bool:
|
|
1733
|
+
with self._tool_choice_compat_lock:
|
|
1734
|
+
return key in self._tool_choice_compat_disabled
|
|
1735
|
+
|
|
1736
|
+
def _mark_tool_choice_compat_disabled(self, key: tuple[str, str]) -> None:
|
|
1737
|
+
with self._tool_choice_compat_lock:
|
|
1738
|
+
self._tool_choice_compat_disabled.add(key)
|
|
1739
|
+
|
|
1740
|
+
def _tool_calling_compat_key(self, provider_key: str | None) -> tuple[str, str, str]:
|
|
1741
|
+
provider = _normalize_provider_key(provider_key) or _normalize_provider_key(self.base_url)
|
|
1742
|
+
model = str(self.model or "").strip().casefold()
|
|
1743
|
+
base_url = str(self.base_url or "").strip().casefold()
|
|
1744
|
+
return provider, model, base_url
|
|
1745
|
+
|
|
1746
|
+
def _tool_calling_compat_disabled_for(self, key: tuple[str, str, str]) -> bool:
|
|
1747
|
+
with self._tool_calling_compat_lock:
|
|
1748
|
+
return key in self._tool_calling_compat_disabled
|
|
1749
|
+
|
|
1750
|
+
def _mark_tool_calling_compat_disabled(self, key: tuple[str, str, str]) -> None:
|
|
1751
|
+
with self._tool_calling_compat_lock:
|
|
1752
|
+
self._tool_calling_compat_disabled.add(key)
|
|
1753
|
+
|
|
1754
|
+
@property
|
|
1755
|
+
def supports_tool_calling(self) -> bool:
|
|
1756
|
+
provider_key = _transport_provider_key(
|
|
1757
|
+
base_url=self.base_url,
|
|
1758
|
+
provider_key=self.provider_key,
|
|
1759
|
+
model=self.model,
|
|
1760
|
+
)
|
|
1761
|
+
return not self._tool_calling_compat_disabled_for(
|
|
1762
|
+
self._tool_calling_compat_key(provider_key)
|
|
1763
|
+
)
|
|
1764
|
+
|
|
1765
|
+
def count_input_tokens(
|
|
1766
|
+
self,
|
|
1767
|
+
*,
|
|
1768
|
+
messages: list[dict[str, Any]],
|
|
1769
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1770
|
+
tool_choice: Any | None = None,
|
|
1771
|
+
) -> InputTokenCount:
|
|
1772
|
+
"""Estimate the prompt-bearing payload that this compatibility route sends.
|
|
1773
|
+
|
|
1774
|
+
OpenAI-compatible wire format does not imply a shared tokenizer or a
|
|
1775
|
+
standard preflight counting endpoint. This method therefore exposes the
|
|
1776
|
+
adapter's provider-transformed payload through the common counting
|
|
1777
|
+
contract while explicitly retaining ``local_estimate`` / ``estimated``
|
|
1778
|
+
provenance. Response usage and overflow recovery remain the authority.
|
|
1779
|
+
"""
|
|
1780
|
+
|
|
1781
|
+
del tool_choice # The chat estimator intentionally excludes control-only fields.
|
|
1782
|
+
messages = gate_messages_for_provider_route(messages, self.route_identity)
|
|
1783
|
+
transport_provider_key = _transport_provider_key(
|
|
1784
|
+
base_url=self.base_url,
|
|
1785
|
+
provider_key=self.provider_key,
|
|
1786
|
+
model=self.model,
|
|
1787
|
+
)
|
|
1788
|
+
reasoning_provider_key = _reasoning_transport_provider_key(
|
|
1789
|
+
transport_provider_key=transport_provider_key,
|
|
1790
|
+
reasoning_trace_adapter=self.reasoning_trace_adapter,
|
|
1791
|
+
)
|
|
1792
|
+
disabled_prompt_cache_fields = self._disabled_prompt_cache_fields_snapshot()
|
|
1793
|
+
active_cache_field_values = self._active_prompt_cache_request_field_values(
|
|
1794
|
+
disabled_prompt_cache_fields
|
|
1795
|
+
)
|
|
1796
|
+
effective_tools = tools
|
|
1797
|
+
if self._tool_calling_compat_disabled_for(
|
|
1798
|
+
self._tool_calling_compat_key(transport_provider_key)
|
|
1799
|
+
):
|
|
1800
|
+
effective_tools = None
|
|
1801
|
+
|
|
1802
|
+
provider_messages = _messages_for_transport(
|
|
1803
|
+
messages,
|
|
1804
|
+
provider_key=transport_provider_key,
|
|
1805
|
+
reasoning_provider_key=reasoning_provider_key,
|
|
1806
|
+
model=self.model,
|
|
1807
|
+
)
|
|
1808
|
+
if CACHE_CONTROL_FIELD in active_cache_field_values:
|
|
1809
|
+
cache_policy = merge_cache_policy_metadata(
|
|
1810
|
+
self.prompt_cache_policy_metadata,
|
|
1811
|
+
RequestCachePlan(
|
|
1812
|
+
strategy="openai_prompt_cache",
|
|
1813
|
+
mode="automatic",
|
|
1814
|
+
prompt_cache_key=self.prompt_cache_key,
|
|
1815
|
+
prompt_cache_retention=self.prompt_cache_retention,
|
|
1816
|
+
).openai_prompt_cache_policy_metadata(),
|
|
1817
|
+
)
|
|
1818
|
+
provider_messages = apply_openai_compatible_cache_control_breakpoint(
|
|
1819
|
+
provider_messages,
|
|
1820
|
+
cache_policy=cache_policy,
|
|
1821
|
+
).messages
|
|
1822
|
+
|
|
1823
|
+
prompt_payload: dict[str, Any] = {"messages": provider_messages}
|
|
1824
|
+
if effective_tools:
|
|
1825
|
+
prompt_payload["tools"] = effective_tools
|
|
1826
|
+
prompt_payload = _sanitize_transport_value(prompt_payload)
|
|
1827
|
+
return InputTokenCount(
|
|
1828
|
+
input_tokens=estimate_provider_payload_tokens(prompt_payload),
|
|
1829
|
+
source=UsageSource.LOCAL_ESTIMATE,
|
|
1830
|
+
confidence=UsageConfidence.ESTIMATED,
|
|
1831
|
+
raw_provider_usage={
|
|
1832
|
+
"estimator": "cl100k_base",
|
|
1833
|
+
"estimate_basis": "provider_prompt_payload",
|
|
1834
|
+
"provider_key": transport_provider_key,
|
|
1835
|
+
"protocol": "openai_compat",
|
|
1836
|
+
"model": self.model,
|
|
1837
|
+
"message_count": len(prompt_payload.get("messages") or []),
|
|
1838
|
+
"tool_count": len(prompt_payload.get("tools") or []),
|
|
1839
|
+
},
|
|
1840
|
+
)
|
|
1841
|
+
|
|
1842
|
+
def chat(
|
|
1843
|
+
self,
|
|
1844
|
+
*,
|
|
1845
|
+
messages: list[dict[str, Any]],
|
|
1846
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1847
|
+
tool_choice: Any | None = None,
|
|
1848
|
+
response_format: dict[str, Any] | None = None,
|
|
1849
|
+
stream: bool = False,
|
|
1850
|
+
on_text_delta: Callable[[str], None] | None = None,
|
|
1851
|
+
on_reasoning_delta: Callable[[str], None] | None = None,
|
|
1852
|
+
temperature: float | None = None,
|
|
1853
|
+
max_tokens: int | None = None,
|
|
1854
|
+
cancellation_token: Any | None = None,
|
|
1855
|
+
) -> LLMResponse:
|
|
1856
|
+
messages = gate_messages_for_provider_route(messages, self.route_identity)
|
|
1857
|
+
url = f"{self.base_url}/chat/completions"
|
|
1858
|
+
headers = merge_canonical_headers(
|
|
1859
|
+
{
|
|
1860
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
1861
|
+
"Content-Type": "application/json",
|
|
1862
|
+
"User-Agent": "alysis-code/0.1.0",
|
|
1863
|
+
},
|
|
1864
|
+
self.extra_headers,
|
|
1865
|
+
)
|
|
1866
|
+
headers = _headers_with_default_accept_encoding(headers)
|
|
1867
|
+
resolved_temperature = self.temperature if temperature is None else float(temperature)
|
|
1868
|
+
transport_provider_key = _transport_provider_key(
|
|
1869
|
+
base_url=self.base_url,
|
|
1870
|
+
provider_key=self.provider_key,
|
|
1871
|
+
model=self.model,
|
|
1872
|
+
)
|
|
1873
|
+
reasoning_provider_key = _reasoning_transport_provider_key(
|
|
1874
|
+
transport_provider_key=transport_provider_key,
|
|
1875
|
+
reasoning_trace_adapter=self.reasoning_trace_adapter,
|
|
1876
|
+
)
|
|
1877
|
+
deepseek_thinking_enabled = (
|
|
1878
|
+
_deepseek_reasoning_payload_enabled(
|
|
1879
|
+
enable_thinking=self.enable_thinking,
|
|
1880
|
+
reasoning_effort=self.reasoning_effort,
|
|
1881
|
+
)
|
|
1882
|
+
if _is_deepseek_provider(reasoning_provider_key)
|
|
1883
|
+
else None
|
|
1884
|
+
)
|
|
1885
|
+
documented_temperature_reason = documented_temperature_omit_reason(
|
|
1886
|
+
self.model,
|
|
1887
|
+
provider_key=transport_provider_key,
|
|
1888
|
+
thinking_enabled=deepseek_thinking_enabled,
|
|
1889
|
+
)
|
|
1890
|
+
temperature_key = self._temperature_compat_key(transport_provider_key)
|
|
1891
|
+
cached_temperature_compat_mode = self._temperature_compat_mode_for(temperature_key)
|
|
1892
|
+
disabled_prompt_cache_fields = self._disabled_prompt_cache_fields_snapshot()
|
|
1893
|
+
active_cache_field_values = self._active_prompt_cache_request_field_values(
|
|
1894
|
+
disabled_prompt_cache_fields
|
|
1895
|
+
)
|
|
1896
|
+
active_prompt_cache_key = (
|
|
1897
|
+
None
|
|
1898
|
+
if PROMPT_CACHE_KEY_FIELD in disabled_prompt_cache_fields
|
|
1899
|
+
else self.prompt_cache_key or active_cache_field_values.get(PROMPT_CACHE_KEY_FIELD)
|
|
1900
|
+
)
|
|
1901
|
+
active_prompt_cache_retention = (
|
|
1902
|
+
None
|
|
1903
|
+
if PROMPT_CACHE_RETENTION_FIELD in disabled_prompt_cache_fields
|
|
1904
|
+
else self.prompt_cache_retention
|
|
1905
|
+
or active_cache_field_values.get(PROMPT_CACHE_RETENTION_FIELD)
|
|
1906
|
+
)
|
|
1907
|
+
tool_choice_key = self._tool_choice_compat_key(transport_provider_key)
|
|
1908
|
+
cached_tool_choice_compat_disabled = self._tool_choice_compat_disabled_for(tool_choice_key)
|
|
1909
|
+
tool_calling_key = self._tool_calling_compat_key(transport_provider_key)
|
|
1910
|
+
cached_tool_calling_compat_disabled = self._tool_calling_compat_disabled_for(
|
|
1911
|
+
tool_calling_key
|
|
1912
|
+
)
|
|
1913
|
+
transport_metadata: dict[str, Any] = {}
|
|
1914
|
+
reasoning_contract = reasoning_contract_for(reasoning_provider_key, self.model)
|
|
1915
|
+
payload: dict[str, Any] = {
|
|
1916
|
+
"model": self.model,
|
|
1917
|
+
"messages": _messages_for_transport(
|
|
1918
|
+
messages,
|
|
1919
|
+
provider_key=transport_provider_key,
|
|
1920
|
+
reasoning_provider_key=reasoning_provider_key,
|
|
1921
|
+
model=self.model,
|
|
1922
|
+
),
|
|
1923
|
+
}
|
|
1924
|
+
if documented_temperature_reason is not None:
|
|
1925
|
+
transport_metadata["temperature_adjusted"] = True
|
|
1926
|
+
transport_metadata["temperature_adjustment"] = _TEMPERATURE_COMPAT_MODE_OMIT
|
|
1927
|
+
transport_metadata["temperature_adjustment_reason"] = "documented_model_policy"
|
|
1928
|
+
transport_metadata["temperature_omitted"] = True
|
|
1929
|
+
transport_metadata["temperature_omit_reason"] = documented_temperature_reason
|
|
1930
|
+
elif cached_temperature_compat_mode == _TEMPERATURE_COMPAT_MODE_DEFAULT:
|
|
1931
|
+
payload["temperature"] = _TEMPERATURE_DEFAULT_VALUE
|
|
1932
|
+
transport_metadata["temperature_adjusted"] = True
|
|
1933
|
+
transport_metadata["temperature_adjustment"] = _TEMPERATURE_COMPAT_MODE_DEFAULT
|
|
1934
|
+
transport_metadata["temperature_adjustment_reason"] = "cached_provider_rejection"
|
|
1935
|
+
elif cached_temperature_compat_mode == _TEMPERATURE_COMPAT_MODE_OMIT:
|
|
1936
|
+
transport_metadata["temperature_adjusted"] = True
|
|
1937
|
+
transport_metadata["temperature_adjustment"] = _TEMPERATURE_COMPAT_MODE_OMIT
|
|
1938
|
+
transport_metadata["temperature_adjustment_reason"] = "cached_provider_rejection"
|
|
1939
|
+
transport_metadata["temperature_omitted"] = True
|
|
1940
|
+
transport_metadata["temperature_omit_reason"] = "cached_provider_rejection"
|
|
1941
|
+
else:
|
|
1942
|
+
payload["temperature"] = resolved_temperature
|
|
1943
|
+
if active_prompt_cache_key:
|
|
1944
|
+
payload[PROMPT_CACHE_KEY_FIELD] = active_prompt_cache_key
|
|
1945
|
+
if active_prompt_cache_retention:
|
|
1946
|
+
payload[PROMPT_CACHE_RETENTION_FIELD] = active_prompt_cache_retention
|
|
1947
|
+
openrouter_session_id = active_cache_field_values.get(OPENROUTER_SESSION_ID_FIELD)
|
|
1948
|
+
if openrouter_session_id:
|
|
1949
|
+
payload[OPENROUTER_SESSION_ID_FIELD] = openrouter_session_id
|
|
1950
|
+
_set_header_if_absent(
|
|
1951
|
+
headers,
|
|
1952
|
+
OPENROUTER_SESSION_ID_HEADER_FIELD,
|
|
1953
|
+
active_cache_field_values.get(OPENROUTER_SESSION_ID_HEADER_FIELD, ""),
|
|
1954
|
+
)
|
|
1955
|
+
_set_header_if_absent(
|
|
1956
|
+
headers,
|
|
1957
|
+
XAI_CONVERSATION_ID_HEADER_FIELD,
|
|
1958
|
+
active_cache_field_values.get(XAI_CONVERSATION_ID_HEADER_FIELD, ""),
|
|
1959
|
+
)
|
|
1960
|
+
# Track whether this request runs in thinking/reasoning mode on a provider
|
|
1961
|
+
# that rejects a forced tool_choice while thinking (DeepSeek, OpenRouter/
|
|
1962
|
+
# MiMo, DashScope/Qwen, Zhipu GLM). OpenAI/Gemini-style reasoning accept a
|
|
1963
|
+
# forced choice, so those branches leave this False.
|
|
1964
|
+
thinking_active = False
|
|
1965
|
+
if _is_moonshot_provider(transport_provider_key):
|
|
1966
|
+
# Moonshot Platform and Kimi Code share response-state behavior but
|
|
1967
|
+
# expose different request contracts. Resolve the endpoint surface
|
|
1968
|
+
# first, then shape the request from its contract rather than from a
|
|
1969
|
+
# model-name heuristic.
|
|
1970
|
+
contract = reasoning_contract_for(transport_provider_key, self.model)
|
|
1971
|
+
if contract.wire == WIRE_REASONING_EFFORT:
|
|
1972
|
+
reasoning_effort = _documented_reasoning_effort(
|
|
1973
|
+
provider_key=transport_provider_key,
|
|
1974
|
+
model=self.model,
|
|
1975
|
+
reasoning_effort=self.reasoning_effort,
|
|
1976
|
+
)
|
|
1977
|
+
if not reasoning_effort and contract.mode == ALWAYS_ON:
|
|
1978
|
+
reasoning_effort = contract.default or None
|
|
1979
|
+
if reasoning_effort:
|
|
1980
|
+
payload["reasoning_effort"] = reasoning_effort
|
|
1981
|
+
elif contract.wire == WIRE_THINKING_TYPE and contract.mode == ALWAYS_ON:
|
|
1982
|
+
thinking_active = True
|
|
1983
|
+
elif contract.wire == WIRE_THINKING_TYPE:
|
|
1984
|
+
thinking_enabled = self.enable_thinking
|
|
1985
|
+
if thinking_enabled is None:
|
|
1986
|
+
thinking_enabled = _reasoning_effort_enables_thinking(self.reasoning_effort)
|
|
1987
|
+
# Optional Moonshot thinking defaults on when the toggle is
|
|
1988
|
+
# omitted; preserve that default for forced-tool handling.
|
|
1989
|
+
thinking_active = thinking_enabled is not False
|
|
1990
|
+
if thinking_enabled is not None:
|
|
1991
|
+
payload["thinking"] = {"type": "enabled" if thinking_enabled else "disabled"}
|
|
1992
|
+
elif _is_dashscope_provider(reasoning_provider_key):
|
|
1993
|
+
enable_thinking = self.enable_thinking
|
|
1994
|
+
if enable_thinking is None:
|
|
1995
|
+
enable_thinking = _reasoning_effort_enables_thinking(self.reasoning_effort)
|
|
1996
|
+
if enable_thinking is not None:
|
|
1997
|
+
payload["enable_thinking"] = enable_thinking
|
|
1998
|
+
reasoning_effort = _documented_reasoning_effort(
|
|
1999
|
+
provider_key=reasoning_provider_key,
|
|
2000
|
+
model=self.model,
|
|
2001
|
+
reasoning_effort=self.reasoning_effort,
|
|
2002
|
+
)
|
|
2003
|
+
if enable_thinking is not False and reasoning_effort:
|
|
2004
|
+
payload["reasoning_effort"] = reasoning_effort
|
|
2005
|
+
thinking_active = enable_thinking is True
|
|
2006
|
+
elif _is_deepseek_provider(reasoning_provider_key):
|
|
2007
|
+
thinking_enabled = deepseek_thinking_enabled
|
|
2008
|
+
if thinking_enabled is not None:
|
|
2009
|
+
payload["thinking"] = {"type": "enabled" if thinking_enabled else "disabled"}
|
|
2010
|
+
reasoning_effort = _documented_reasoning_effort(
|
|
2011
|
+
provider_key=reasoning_provider_key,
|
|
2012
|
+
model=self.model,
|
|
2013
|
+
reasoning_effort=self.reasoning_effort,
|
|
2014
|
+
)
|
|
2015
|
+
if thinking_enabled is not False and reasoning_effort:
|
|
2016
|
+
payload["reasoning_effort"] = reasoning_effort
|
|
2017
|
+
thinking_active = (
|
|
2018
|
+
_reasoning_contract_active(
|
|
2019
|
+
provider_key=reasoning_provider_key,
|
|
2020
|
+
model=self.model,
|
|
2021
|
+
enable_thinking=self.enable_thinking,
|
|
2022
|
+
reasoning_effort=self.reasoning_effort,
|
|
2023
|
+
)
|
|
2024
|
+
is True
|
|
2025
|
+
)
|
|
2026
|
+
elif _is_together_deepseek_pro(transport_provider_key, self.model):
|
|
2027
|
+
# Together exposes DeepSeek's effort field but uses its own object
|
|
2028
|
+
# shape for disabling reasoning. The model otherwise reasons by
|
|
2029
|
+
# default, so omission preserves provider behavior.
|
|
2030
|
+
if self.enable_thinking is False:
|
|
2031
|
+
payload["reasoning"] = {"enabled": False}
|
|
2032
|
+
else:
|
|
2033
|
+
reasoning_effort = _documented_reasoning_effort(
|
|
2034
|
+
provider_key=_TOGETHER_PROVIDER_KEY,
|
|
2035
|
+
model=self.model,
|
|
2036
|
+
reasoning_effort=self.reasoning_effort,
|
|
2037
|
+
)
|
|
2038
|
+
if reasoning_effort:
|
|
2039
|
+
payload["reasoning_effort"] = reasoning_effort
|
|
2040
|
+
elif _is_openrouter_provider(reasoning_provider_key):
|
|
2041
|
+
reasoning = _openrouter_reasoning_payload(
|
|
2042
|
+
enable_thinking=self.enable_thinking,
|
|
2043
|
+
reasoning_effort=self.reasoning_effort,
|
|
2044
|
+
)
|
|
2045
|
+
if reasoning is not None:
|
|
2046
|
+
payload["reasoning"] = reasoning
|
|
2047
|
+
thinking_active = reasoning is not None and reasoning.get("enabled") is not False
|
|
2048
|
+
elif _is_gemini_provider(reasoning_provider_key):
|
|
2049
|
+
reasoning_effort = _gemini_reasoning_effort(
|
|
2050
|
+
model=self.model,
|
|
2051
|
+
reasoning_effort=self.reasoning_effort,
|
|
2052
|
+
)
|
|
2053
|
+
if reasoning_effort:
|
|
2054
|
+
payload["reasoning_effort"] = reasoning_effort
|
|
2055
|
+
elif _is_nvidia_provider(reasoning_provider_key):
|
|
2056
|
+
# NVIDIA hosts models from many vendors behind one endpoint. Request
|
|
2057
|
+
# shaping is therefore model-contract-driven: the host alone never
|
|
2058
|
+
# implies Nemotron controls, and unknown models receive no guessed
|
|
2059
|
+
# reasoning parameter.
|
|
2060
|
+
contract = reasoning_contract_for(_NVIDIA_PROVIDER_KEY, self.model)
|
|
2061
|
+
thinking_enabled = self.enable_thinking
|
|
2062
|
+
if thinking_enabled is None:
|
|
2063
|
+
thinking_enabled = _reasoning_effort_enables_thinking(self.reasoning_effort)
|
|
2064
|
+
chat_template_kwargs: dict[str, bool] = {}
|
|
2065
|
+
if contract.wire == WIRE_REASONING_EFFORT:
|
|
2066
|
+
if thinking_enabled is False and contract.off == OFF_EXPLICIT:
|
|
2067
|
+
payload["reasoning_effort"] = "none"
|
|
2068
|
+
elif thinking_enabled is not False:
|
|
2069
|
+
reasoning_effort = _documented_reasoning_effort(
|
|
2070
|
+
provider_key=_NVIDIA_PROVIDER_KEY,
|
|
2071
|
+
model=self.model,
|
|
2072
|
+
reasoning_effort=self.reasoning_effort,
|
|
2073
|
+
)
|
|
2074
|
+
if reasoning_effort:
|
|
2075
|
+
payload["reasoning_effort"] = reasoning_effort
|
|
2076
|
+
elif (
|
|
2077
|
+
contract.wire == WIRE_CHAT_TEMPLATE_ENABLE_THINKING and thinking_enabled is not None
|
|
2078
|
+
):
|
|
2079
|
+
chat_template_kwargs["enable_thinking"] = thinking_enabled
|
|
2080
|
+
if chat_template_kwargs:
|
|
2081
|
+
payload["chat_template_kwargs"] = chat_template_kwargs
|
|
2082
|
+
else:
|
|
2083
|
+
# Any researched provider contract that uses the flat Chat
|
|
2084
|
+
# Completions effort field can opt in here without another
|
|
2085
|
+
# provider-specific request branch. Unknown routes retain the
|
|
2086
|
+
# legacy OpenAI/Azure/Mistral behavior and receive no newly guessed
|
|
2087
|
+
# controls.
|
|
2088
|
+
documented_effort = _documented_flat_reasoning_effort(
|
|
2089
|
+
provider_key=reasoning_provider_key,
|
|
2090
|
+
model=self.model,
|
|
2091
|
+
enable_thinking=self.enable_thinking,
|
|
2092
|
+
reasoning_effort=self.reasoning_effort,
|
|
2093
|
+
)
|
|
2094
|
+
if documented_effort:
|
|
2095
|
+
payload["reasoning_effort"] = documented_effort
|
|
2096
|
+
elif _uses_reasoning_effort(reasoning_provider_key) and self.reasoning_effort:
|
|
2097
|
+
payload["reasoning_effort"] = self.reasoning_effort
|
|
2098
|
+
# A reasoning model in thinking mode can 400 on any tool_choice parameter.
|
|
2099
|
+
# Omitting it preserves the default "auto" behavior while keeping tools
|
|
2100
|
+
# available to the model.
|
|
2101
|
+
if (
|
|
2102
|
+
thinking_active
|
|
2103
|
+
and tool_choice is not None
|
|
2104
|
+
and (
|
|
2105
|
+
not reasoning_contract.accepts_tool_choice_while_reasoning
|
|
2106
|
+
or _tool_choice_forces_a_call(tool_choice)
|
|
2107
|
+
)
|
|
2108
|
+
):
|
|
2109
|
+
tool_choice = None
|
|
2110
|
+
transport_metadata["tool_choice_omitted"] = True
|
|
2111
|
+
transport_metadata["tool_choice_omit_reason"] = "thinking_mode"
|
|
2112
|
+
elif cached_tool_choice_compat_disabled and tool_choice is not None:
|
|
2113
|
+
tool_choice = None
|
|
2114
|
+
transport_metadata["tool_choice_omitted"] = True
|
|
2115
|
+
transport_metadata["tool_choice_omit_reason"] = "cached_provider_rejection"
|
|
2116
|
+
if cached_tool_calling_compat_disabled and (tools or tool_choice is not None):
|
|
2117
|
+
tools = None
|
|
2118
|
+
tool_choice = None
|
|
2119
|
+
transport_metadata["tools_omitted"] = True
|
|
2120
|
+
transport_metadata["tools_omit_reason"] = "cached_provider_rejection"
|
|
2121
|
+
if tools:
|
|
2122
|
+
payload["tools"] = tools
|
|
2123
|
+
if tool_choice is not None:
|
|
2124
|
+
payload["tool_choice"] = tool_choice
|
|
2125
|
+
elif tool_choice is not None:
|
|
2126
|
+
payload["tool_choice"] = tool_choice
|
|
2127
|
+
if response_format:
|
|
2128
|
+
payload["response_format"] = response_format
|
|
2129
|
+
if max_tokens is not None:
|
|
2130
|
+
output_limit_field = (
|
|
2131
|
+
"max_completion_tokens"
|
|
2132
|
+
if _is_moonshot_provider(transport_provider_key)
|
|
2133
|
+
else "max_tokens"
|
|
2134
|
+
)
|
|
2135
|
+
payload[output_limit_field] = int(max_tokens)
|
|
2136
|
+
if stream:
|
|
2137
|
+
payload["stream"] = True
|
|
2138
|
+
payload["stream_options"] = {"include_usage": True}
|
|
2139
|
+
provider_key = (
|
|
2140
|
+
transport_provider_key
|
|
2141
|
+
or self.provider_key
|
|
2142
|
+
or best_effort_provider_key(
|
|
2143
|
+
base_url=self.base_url,
|
|
2144
|
+
model=self.model,
|
|
2145
|
+
)
|
|
2146
|
+
)
|
|
2147
|
+
cache_policy = merge_cache_policy_metadata(
|
|
2148
|
+
self.prompt_cache_policy_metadata,
|
|
2149
|
+
RequestCachePlan(
|
|
2150
|
+
strategy=(
|
|
2151
|
+
"openai_prompt_cache"
|
|
2152
|
+
if active_prompt_cache_key or active_prompt_cache_retention
|
|
2153
|
+
else "none"
|
|
2154
|
+
),
|
|
2155
|
+
mode=(
|
|
2156
|
+
"automatic"
|
|
2157
|
+
if active_prompt_cache_key or active_prompt_cache_retention
|
|
2158
|
+
else "manual"
|
|
2159
|
+
),
|
|
2160
|
+
prompt_cache_key=active_prompt_cache_key,
|
|
2161
|
+
prompt_cache_retention=active_prompt_cache_retention,
|
|
2162
|
+
).openai_prompt_cache_policy_metadata(),
|
|
2163
|
+
)
|
|
2164
|
+
if CACHE_CONTROL_FIELD in active_cache_field_values:
|
|
2165
|
+
application = apply_openai_compatible_cache_control_breakpoint(
|
|
2166
|
+
payload.get("messages", []),
|
|
2167
|
+
cache_policy=cache_policy,
|
|
2168
|
+
)
|
|
2169
|
+
payload["messages"] = application.messages
|
|
2170
|
+
if cache_policy is not None:
|
|
2171
|
+
cache_policy = merge_cache_policy_metadata(
|
|
2172
|
+
cache_policy,
|
|
2173
|
+
application.policy_metadata(),
|
|
2174
|
+
)
|
|
2175
|
+
if disabled_prompt_cache_fields:
|
|
2176
|
+
cache_policy = _cache_policy_after_fields_disabled(
|
|
2177
|
+
cache_policy,
|
|
2178
|
+
fields=disabled_prompt_cache_fields,
|
|
2179
|
+
fallback="runtime_disabled_rejected_cache_fields",
|
|
2180
|
+
)
|
|
2181
|
+
# Sampling determinism controls, applied last so this is the single
|
|
2182
|
+
# place temperature/top_p/seed can enter the request. With none of the
|
|
2183
|
+
# three configured -- the default -- this writes nothing and the
|
|
2184
|
+
# payload keeps the exact keys, order and values the branches above
|
|
2185
|
+
# produced, so the request stays byte-identical to the pre-PR6
|
|
2186
|
+
# transport. The temperature override is declined whenever the
|
|
2187
|
+
# transport already rewrote or omitted the field for
|
|
2188
|
+
# provider-compatibility reasons, because that adjustment exists to
|
|
2189
|
+
# avoid a 400 this must not reintroduce.
|
|
2190
|
+
sampling_settings = active_sampling_settings()
|
|
2191
|
+
applied_sampling_fields = apply_sampling_to_payload(
|
|
2192
|
+
payload,
|
|
2193
|
+
sampling_settings,
|
|
2194
|
+
allow_temperature_override=not transport_metadata.get("temperature_adjusted"),
|
|
2195
|
+
)
|
|
2196
|
+
if applied_sampling_fields:
|
|
2197
|
+
transport_metadata["sampling_fields_applied"] = list(applied_sampling_fields)
|
|
2198
|
+
payload = _sanitize_transport_value(payload)
|
|
2199
|
+
prompt_estimation_payload = {
|
|
2200
|
+
"messages": payload.get("messages", []),
|
|
2201
|
+
}
|
|
2202
|
+
for key in ("tools", "response_format"):
|
|
2203
|
+
if key in payload:
|
|
2204
|
+
prompt_estimation_payload[key] = payload[key]
|
|
2205
|
+
request_shape = build_request_shape_report(
|
|
2206
|
+
messages=messages,
|
|
2207
|
+
tools=tools,
|
|
2208
|
+
cache_policy=cache_policy,
|
|
2209
|
+
provider_payload=prompt_estimation_payload,
|
|
2210
|
+
)
|
|
2211
|
+
input_estimate_tokens = estimate_provider_payload_tokens(prompt_estimation_payload)
|
|
2212
|
+
layout_plan = LLMRequestPlan.from_chat_args(
|
|
2213
|
+
messages=messages,
|
|
2214
|
+
tools=tools,
|
|
2215
|
+
tool_choice=tool_choice,
|
|
2216
|
+
response_format=response_format,
|
|
2217
|
+
stream=stream,
|
|
2218
|
+
temperature=temperature,
|
|
2219
|
+
max_tokens=max_tokens,
|
|
2220
|
+
cache=RequestCachePlan(
|
|
2221
|
+
strategy=(
|
|
2222
|
+
"openai_prompt_cache"
|
|
2223
|
+
if active_prompt_cache_key or active_prompt_cache_retention
|
|
2224
|
+
else "none"
|
|
2225
|
+
),
|
|
2226
|
+
mode=(
|
|
2227
|
+
"automatic"
|
|
2228
|
+
if active_prompt_cache_key or active_prompt_cache_retention
|
|
2229
|
+
else "manual"
|
|
2230
|
+
),
|
|
2231
|
+
prompt_cache_key=active_prompt_cache_key,
|
|
2232
|
+
prompt_cache_retention=active_prompt_cache_retention,
|
|
2233
|
+
),
|
|
2234
|
+
)
|
|
2235
|
+
request_plan_metadata = layout_plan.request_plan_metadata(
|
|
2236
|
+
input_mode="full",
|
|
2237
|
+
continuation_strategy="full_replay",
|
|
2238
|
+
provider_payload=prompt_estimation_payload,
|
|
2239
|
+
sent_provider_payload=prompt_estimation_payload,
|
|
2240
|
+
cache_policy_metadata=cache_policy,
|
|
2241
|
+
)
|
|
2242
|
+
token_reconciliation = {
|
|
2243
|
+
"input_estimate_tokens": input_estimate_tokens,
|
|
2244
|
+
"sent_input_estimate_tokens": input_estimate_tokens,
|
|
2245
|
+
"estimator": "cl100k_base",
|
|
2246
|
+
"estimate_basis": "provider_prompt_payload",
|
|
2247
|
+
"input_mode": "full",
|
|
2248
|
+
}
|
|
2249
|
+
telemetry = ProviderCallTelemetryRecorder(
|
|
2250
|
+
provider_key=provider_key,
|
|
2251
|
+
protocol="openai_compat",
|
|
2252
|
+
model=self.model,
|
|
2253
|
+
base_url=self.base_url,
|
|
2254
|
+
stream=stream,
|
|
2255
|
+
tools=tools,
|
|
2256
|
+
cache_policy=cache_policy,
|
|
2257
|
+
request_plan=request_plan_metadata,
|
|
2258
|
+
request_shape=request_shape,
|
|
2259
|
+
token_reconciliation=token_reconciliation,
|
|
2260
|
+
operation="chat_completions",
|
|
2261
|
+
sampling=sampling_settings,
|
|
2262
|
+
)
|
|
2263
|
+
telemetry_on_text_delta = telemetry.wrap_text_delta(on_text_delta)
|
|
2264
|
+
telemetry_on_reasoning_delta = telemetry.wrap_reasoning_delta(on_reasoning_delta)
|
|
2265
|
+
stream_restart_count = 0
|
|
2266
|
+
stream_restart_reason = ""
|
|
2267
|
+
any_text_delta_emitted = False
|
|
2268
|
+
|
|
2269
|
+
def _record_retry(attempt: int, reason: str, wait_seconds: float) -> None:
|
|
2270
|
+
nonlocal stream_restart_count, stream_restart_reason, any_text_delta_emitted
|
|
2271
|
+
telemetry.on_retry(attempt, reason, wait_seconds)
|
|
2272
|
+
if stream and str(reason).startswith("provider_stream_"):
|
|
2273
|
+
stream_restart_count += 1
|
|
2274
|
+
stream_restart_reason = str(reason)
|
|
2275
|
+
transport_metadata["stream_restart_count"] = stream_restart_count
|
|
2276
|
+
transport_metadata["stream_restart_reason"] = stream_restart_reason
|
|
2277
|
+
if stream and any_text_delta_emitted:
|
|
2278
|
+
# Tokens from the abandoned attempt already rendered; give the
|
|
2279
|
+
# surface a chance to reset its live block before the retry
|
|
2280
|
+
# restreams the reply (duck-typed hook — see the chat loop's
|
|
2281
|
+
# _on_text_delta). Observers must never change retry behavior.
|
|
2282
|
+
any_text_delta_emitted = False
|
|
2283
|
+
restart_hook = getattr(on_text_delta, "stream_restart", None)
|
|
2284
|
+
if callable(restart_hook):
|
|
2285
|
+
try:
|
|
2286
|
+
restart_hook()
|
|
2287
|
+
except Exception: # noqa: BLE001
|
|
2288
|
+
_LOGGER.debug("stream_restart_hook_failed", exc_info=True)
|
|
2289
|
+
|
|
2290
|
+
def _send_request() -> LLMResponse:
|
|
2291
|
+
nonlocal cache_policy, request_plan_metadata, request_shape, token_reconciliation
|
|
2292
|
+
nonlocal stream_restart_count, stream_restart_reason
|
|
2293
|
+
temperature_retry_count = 0
|
|
2294
|
+
temperature_retry_modes: set[str] = set()
|
|
2295
|
+
cache_param_retry_used = False
|
|
2296
|
+
try:
|
|
2297
|
+
with httpx.Client(
|
|
2298
|
+
timeout=_httpx_request_timeout(self.timeout_s),
|
|
2299
|
+
transport=self._transport,
|
|
2300
|
+
) as client:
|
|
2301
|
+
while True:
|
|
2302
|
+
try:
|
|
2303
|
+
if stream:
|
|
2304
|
+
with client.stream(
|
|
2305
|
+
"POST", url, headers=headers, json=payload
|
|
2306
|
+
) as resp:
|
|
2307
|
+
# Before parsing: the routing headers are
|
|
2308
|
+
# the evidence for which backend answered,
|
|
2309
|
+
# and they must survive a stream that then
|
|
2310
|
+
# fails or is retried.
|
|
2311
|
+
telemetry.set_response_headers(resp.headers)
|
|
2312
|
+
attempt_deltas: list[str] = []
|
|
2313
|
+
attempt_reasoning_deltas: list[str] = []
|
|
2314
|
+
|
|
2315
|
+
def _attempt_text_delta(
|
|
2316
|
+
delta: str,
|
|
2317
|
+
*,
|
|
2318
|
+
_attempt_deltas: list[str] = attempt_deltas,
|
|
2319
|
+
) -> None:
|
|
2320
|
+
nonlocal any_text_delta_emitted
|
|
2321
|
+
_attempt_deltas.append(delta)
|
|
2322
|
+
if delta:
|
|
2323
|
+
any_text_delta_emitted = True
|
|
2324
|
+
if telemetry_on_text_delta is not None:
|
|
2325
|
+
telemetry_on_text_delta(delta)
|
|
2326
|
+
|
|
2327
|
+
def _attempt_reasoning_delta(
|
|
2328
|
+
delta: str,
|
|
2329
|
+
*,
|
|
2330
|
+
_attempt_reasoning_deltas: list[str] = (
|
|
2331
|
+
attempt_reasoning_deltas
|
|
2332
|
+
),
|
|
2333
|
+
) -> None:
|
|
2334
|
+
_attempt_reasoning_deltas.append(delta)
|
|
2335
|
+
if telemetry_on_reasoning_delta is not None:
|
|
2336
|
+
telemetry_on_reasoning_delta(delta)
|
|
2337
|
+
|
|
2338
|
+
try:
|
|
2339
|
+
response = self._parse_stream_response(
|
|
2340
|
+
resp,
|
|
2341
|
+
on_text_delta=(
|
|
2342
|
+
_attempt_text_delta
|
|
2343
|
+
if on_text_delta is not None
|
|
2344
|
+
else None
|
|
2345
|
+
),
|
|
2346
|
+
on_reasoning_delta=(
|
|
2347
|
+
_attempt_reasoning_delta
|
|
2348
|
+
if on_reasoning_delta is not None
|
|
2349
|
+
else None
|
|
2350
|
+
),
|
|
2351
|
+
provider_key=reasoning_provider_key,
|
|
2352
|
+
cancellation_token=cancellation_token,
|
|
2353
|
+
)
|
|
2354
|
+
except Exception as stream_error:
|
|
2355
|
+
if isinstance(stream_error, DeadlineExhausted):
|
|
2356
|
+
raise
|
|
2357
|
+
if attempt_deltas or attempt_reasoning_deltas:
|
|
2358
|
+
stream_restart_count += 1
|
|
2359
|
+
stream_restart_reason = (
|
|
2360
|
+
provider_unavailable_retry_reason(stream_error)
|
|
2361
|
+
or "provider_stream_interrupted"
|
|
2362
|
+
)
|
|
2363
|
+
transport_metadata["stream_restart_count"] = (
|
|
2364
|
+
stream_restart_count
|
|
2365
|
+
)
|
|
2366
|
+
transport_metadata["stream_restart_reason"] = (
|
|
2367
|
+
stream_restart_reason
|
|
2368
|
+
)
|
|
2369
|
+
# Once public output reached the UI, replaying the
|
|
2370
|
+
# request would duplicate text/trace content —
|
|
2371
|
+
# UNLESS the delta callback carries the
|
|
2372
|
+
# stream_restart reset channel, in which case the
|
|
2373
|
+
# surface erases the partial output right before
|
|
2374
|
+
# the retry restreams (the hook fires from
|
|
2375
|
+
# _record_retry, i.e. only when a retry actually
|
|
2376
|
+
# runs), and replaying is safe.
|
|
2377
|
+
surface_can_reset = callable(
|
|
2378
|
+
getattr(on_text_delta, "stream_restart", None)
|
|
2379
|
+
)
|
|
2380
|
+
if surface_can_reset:
|
|
2381
|
+
raise
|
|
2382
|
+
if isinstance(stream_error, LLMError):
|
|
2383
|
+
mark_provider_call_non_retryable(stream_error)
|
|
2384
|
+
raise
|
|
2385
|
+
interrupted = LLMError(
|
|
2386
|
+
"LLM stream interrupted after partial output: "
|
|
2387
|
+
f"{stream_error}"
|
|
2388
|
+
)
|
|
2389
|
+
mark_provider_call_non_retryable(interrupted)
|
|
2390
|
+
raise interrupted from stream_error
|
|
2391
|
+
raise
|
|
2392
|
+
if stream_restart_count:
|
|
2393
|
+
response = _response_with_stream_restart_metadata(
|
|
2394
|
+
response,
|
|
2395
|
+
count=stream_restart_count,
|
|
2396
|
+
reason=stream_restart_reason,
|
|
2397
|
+
)
|
|
2398
|
+
else:
|
|
2399
|
+
resp = client.post(url, headers=headers, json=payload)
|
|
2400
|
+
# Captured before the status check so a 4xx/5xx
|
|
2401
|
+
# keeps its request id, which is usually the
|
|
2402
|
+
# only handle a provider accepts when asked
|
|
2403
|
+
# what happened to a specific call.
|
|
2404
|
+
telemetry.set_response_headers(resp.headers)
|
|
2405
|
+
if resp.status_code >= 400:
|
|
2406
|
+
raise self._error_from_response(resp)
|
|
2407
|
+
response = self._parse_non_stream_response(
|
|
2408
|
+
resp,
|
|
2409
|
+
provider_key=reasoning_provider_key,
|
|
2410
|
+
model=self.model,
|
|
2411
|
+
)
|
|
2412
|
+
except LLMError as e:
|
|
2413
|
+
if stream and _is_stream_options_unsupported_error(e):
|
|
2414
|
+
payload.pop("stream_options", None)
|
|
2415
|
+
continue
|
|
2416
|
+
rejected_cache_fields = _cache_param_rejected_fields(
|
|
2417
|
+
e,
|
|
2418
|
+
payload=payload,
|
|
2419
|
+
headers=headers,
|
|
2420
|
+
)
|
|
2421
|
+
if rejected_cache_fields and not cache_param_retry_used:
|
|
2422
|
+
cache_param_retry_used = True
|
|
2423
|
+
disabled_fields = self._disable_prompt_cache_fields(
|
|
2424
|
+
rejected_cache_fields
|
|
2425
|
+
)
|
|
2426
|
+
_strip_cache_request_fields(
|
|
2427
|
+
payload=payload,
|
|
2428
|
+
headers=headers,
|
|
2429
|
+
fields=rejected_cache_fields,
|
|
2430
|
+
)
|
|
2431
|
+
cache_policy = _cache_policy_after_fields_disabled(
|
|
2432
|
+
cache_policy,
|
|
2433
|
+
fields=disabled_fields or rejected_cache_fields,
|
|
2434
|
+
fallback="stripped_rejected_cache_fields",
|
|
2435
|
+
)
|
|
2436
|
+
prompt_estimation_payload = {
|
|
2437
|
+
"messages": payload.get("messages", []),
|
|
2438
|
+
}
|
|
2439
|
+
for key in ("tools", "response_format"):
|
|
2440
|
+
if key in payload:
|
|
2441
|
+
prompt_estimation_payload[key] = payload[key]
|
|
2442
|
+
request_shape = build_request_shape_report(
|
|
2443
|
+
messages=messages,
|
|
2444
|
+
tools=tools,
|
|
2445
|
+
cache_policy=cache_policy,
|
|
2446
|
+
provider_payload=prompt_estimation_payload,
|
|
2447
|
+
input_mode="cache_param_fallback",
|
|
2448
|
+
)
|
|
2449
|
+
request_plan_metadata = layout_plan.request_plan_metadata(
|
|
2450
|
+
input_mode="cache_param_fallback",
|
|
2451
|
+
continuation_strategy="full_replay",
|
|
2452
|
+
provider_payload=prompt_estimation_payload,
|
|
2453
|
+
sent_provider_payload=prompt_estimation_payload,
|
|
2454
|
+
cache_policy_metadata=cache_policy,
|
|
2455
|
+
extra={"fallback_used": True},
|
|
2456
|
+
)
|
|
2457
|
+
fallback_input_estimate = estimate_provider_payload_tokens(
|
|
2458
|
+
prompt_estimation_payload
|
|
2459
|
+
)
|
|
2460
|
+
token_reconciliation = {
|
|
2461
|
+
**token_reconciliation,
|
|
2462
|
+
"input_estimate_tokens": fallback_input_estimate,
|
|
2463
|
+
"sent_input_estimate_tokens": fallback_input_estimate,
|
|
2464
|
+
"input_mode": "cache_param_fallback",
|
|
2465
|
+
}
|
|
2466
|
+
telemetry.set_cache_policy(cache_policy)
|
|
2467
|
+
telemetry.set_request_plan(request_plan_metadata)
|
|
2468
|
+
telemetry.set_request_shape(request_shape)
|
|
2469
|
+
telemetry.set_token_reconciliation(token_reconciliation)
|
|
2470
|
+
transport_metadata["cache_param_fallback_used"] = True
|
|
2471
|
+
transport_metadata["cache_param_retry_used"] = True
|
|
2472
|
+
transport_metadata["cache_param_disabled_fields"] = list(
|
|
2473
|
+
disabled_fields or rejected_cache_fields
|
|
2474
|
+
)
|
|
2475
|
+
_LOGGER.info(
|
|
2476
|
+
"llm_cache_parameter_rejected_retrying_without_cache_fields",
|
|
2477
|
+
extra={
|
|
2478
|
+
"provider_key": provider_key,
|
|
2479
|
+
"model": self.model,
|
|
2480
|
+
"disabled_fields": list(
|
|
2481
|
+
disabled_fields or rejected_cache_fields
|
|
2482
|
+
),
|
|
2483
|
+
},
|
|
2484
|
+
)
|
|
2485
|
+
continue
|
|
2486
|
+
if "tool_choice" in payload and _tool_choice_unsupported_error(e):
|
|
2487
|
+
rejected_tool_choice = payload.pop("tool_choice", None)
|
|
2488
|
+
self._mark_tool_choice_compat_disabled(tool_choice_key)
|
|
2489
|
+
transport_metadata["tool_choice_omitted"] = True
|
|
2490
|
+
transport_metadata["tool_choice_omit_reason"] = (
|
|
2491
|
+
"provider_rejected_parameter"
|
|
2492
|
+
)
|
|
2493
|
+
transport_metadata["tool_choice_retry_used"] = True
|
|
2494
|
+
_LOGGER.info(
|
|
2495
|
+
"llm_tool_choice_parameter_rejected_retrying_without_it",
|
|
2496
|
+
extra={
|
|
2497
|
+
"provider_key": provider_key,
|
|
2498
|
+
"model": self.model,
|
|
2499
|
+
"tool_choice": rejected_tool_choice,
|
|
2500
|
+
},
|
|
2501
|
+
)
|
|
2502
|
+
continue
|
|
2503
|
+
if "tools" in payload and _tool_calling_unsupported_error(e):
|
|
2504
|
+
payload.pop("tools", None)
|
|
2505
|
+
payload.pop("tool_choice", None)
|
|
2506
|
+
self._mark_tool_calling_compat_disabled(tool_calling_key)
|
|
2507
|
+
fallback_prompt_payload: dict[str, Any] = {
|
|
2508
|
+
"messages": payload.get("messages", []),
|
|
2509
|
+
}
|
|
2510
|
+
if "response_format" in payload:
|
|
2511
|
+
fallback_prompt_payload["response_format"] = payload[
|
|
2512
|
+
"response_format"
|
|
2513
|
+
]
|
|
2514
|
+
request_shape = build_request_shape_report(
|
|
2515
|
+
messages=messages,
|
|
2516
|
+
tools=None,
|
|
2517
|
+
cache_policy=cache_policy,
|
|
2518
|
+
provider_payload=fallback_prompt_payload,
|
|
2519
|
+
input_mode="tool_calling_fallback",
|
|
2520
|
+
)
|
|
2521
|
+
fallback_layout = LLMRequestPlan.from_chat_args(
|
|
2522
|
+
messages=messages,
|
|
2523
|
+
tools=None,
|
|
2524
|
+
tool_choice=None,
|
|
2525
|
+
response_format=response_format,
|
|
2526
|
+
stream=stream,
|
|
2527
|
+
temperature=temperature,
|
|
2528
|
+
max_tokens=max_tokens,
|
|
2529
|
+
cache=layout_plan.cache,
|
|
2530
|
+
)
|
|
2531
|
+
request_plan_metadata = fallback_layout.request_plan_metadata(
|
|
2532
|
+
input_mode="tool_calling_fallback",
|
|
2533
|
+
continuation_strategy="full_replay",
|
|
2534
|
+
provider_payload=fallback_prompt_payload,
|
|
2535
|
+
sent_provider_payload=fallback_prompt_payload,
|
|
2536
|
+
cache_policy_metadata=cache_policy,
|
|
2537
|
+
extra={"fallback_used": True},
|
|
2538
|
+
)
|
|
2539
|
+
fallback_input_estimate = estimate_provider_payload_tokens(
|
|
2540
|
+
fallback_prompt_payload
|
|
2541
|
+
)
|
|
2542
|
+
token_reconciliation = {
|
|
2543
|
+
**token_reconciliation,
|
|
2544
|
+
"input_estimate_tokens": fallback_input_estimate,
|
|
2545
|
+
"sent_input_estimate_tokens": fallback_input_estimate,
|
|
2546
|
+
"input_mode": "tool_calling_fallback",
|
|
2547
|
+
}
|
|
2548
|
+
telemetry.set_request_plan(request_plan_metadata)
|
|
2549
|
+
telemetry.set_request_shape(request_shape)
|
|
2550
|
+
telemetry.set_token_reconciliation(token_reconciliation)
|
|
2551
|
+
transport_metadata["tools_omitted"] = True
|
|
2552
|
+
transport_metadata["tools_omit_reason"] = (
|
|
2553
|
+
"provider_rejected_tool_calling"
|
|
2554
|
+
)
|
|
2555
|
+
transport_metadata["tools_retry_used"] = True
|
|
2556
|
+
_LOGGER.info(
|
|
2557
|
+
"llm_tool_calling_rejected_retrying_without_tools",
|
|
2558
|
+
extra={
|
|
2559
|
+
"provider_key": provider_key,
|
|
2560
|
+
"model": self.model,
|
|
2561
|
+
"base_url_descriptor": endpoint_descriptor(self.base_url),
|
|
2562
|
+
},
|
|
2563
|
+
)
|
|
2564
|
+
continue
|
|
2565
|
+
if "temperature" in payload and _temperature_unsupported_error(e):
|
|
2566
|
+
rejected_temperature = payload.get("temperature")
|
|
2567
|
+
compat_mode = _temperature_compat_mode_for_error(
|
|
2568
|
+
e,
|
|
2569
|
+
current_temperature=rejected_temperature,
|
|
2570
|
+
)
|
|
2571
|
+
if compat_mode is None or compat_mode in temperature_retry_modes:
|
|
2572
|
+
raise
|
|
2573
|
+
temperature_retry_modes.add(compat_mode)
|
|
2574
|
+
temperature_retry_count += 1
|
|
2575
|
+
self._mark_temperature_compat_mode(temperature_key, compat_mode)
|
|
2576
|
+
transport_metadata["temperature_adjusted"] = True
|
|
2577
|
+
transport_metadata["temperature_adjustment"] = compat_mode
|
|
2578
|
+
transport_metadata["temperature_adjustment_reason"] = (
|
|
2579
|
+
"provider_rejected_parameter"
|
|
2580
|
+
)
|
|
2581
|
+
transport_metadata["temperature_retry_used"] = True
|
|
2582
|
+
transport_metadata["temperature_retry_count"] = (
|
|
2583
|
+
temperature_retry_count
|
|
2584
|
+
)
|
|
2585
|
+
if compat_mode == _TEMPERATURE_COMPAT_MODE_DEFAULT:
|
|
2586
|
+
payload["temperature"] = _TEMPERATURE_DEFAULT_VALUE
|
|
2587
|
+
else:
|
|
2588
|
+
payload.pop("temperature", None)
|
|
2589
|
+
transport_metadata["temperature_omitted"] = True
|
|
2590
|
+
transport_metadata["temperature_omit_reason"] = (
|
|
2591
|
+
"provider_rejected_parameter"
|
|
2592
|
+
)
|
|
2593
|
+
_LOGGER.info(
|
|
2594
|
+
"llm_temperature_parameter_rejected_retrying_with_compat_mode",
|
|
2595
|
+
extra={
|
|
2596
|
+
"provider_key": provider_key,
|
|
2597
|
+
"model": self.model,
|
|
2598
|
+
"temperature": rejected_temperature,
|
|
2599
|
+
"temperature_compat_mode": compat_mode,
|
|
2600
|
+
},
|
|
2601
|
+
)
|
|
2602
|
+
continue
|
|
2603
|
+
raise
|
|
2604
|
+
if not stream and telemetry_on_reasoning_delta is not None:
|
|
2605
|
+
for reasoning_output in response.reasoning:
|
|
2606
|
+
if reasoning_output.kind == ReasoningOutputKind.SUMMARY:
|
|
2607
|
+
telemetry_on_reasoning_delta(reasoning_output.text)
|
|
2608
|
+
return _merge_transport_metadata(
|
|
2609
|
+
_merge_request_plan_metadata(
|
|
2610
|
+
response,
|
|
2611
|
+
request_plan_metadata=request_plan_metadata,
|
|
2612
|
+
),
|
|
2613
|
+
transport_metadata=transport_metadata,
|
|
2614
|
+
)
|
|
2615
|
+
except LLMError:
|
|
2616
|
+
raise
|
|
2617
|
+
except DeadlineExhausted:
|
|
2618
|
+
raise
|
|
2619
|
+
except httpx.DecodingError as e:
|
|
2620
|
+
raise LLMError(
|
|
2621
|
+
f"LLM response decompression failed: {sanitize_error_text_for_output(e)}"
|
|
2622
|
+
) from e
|
|
2623
|
+
except Exception as e: # noqa: BLE001 - network errors vary
|
|
2624
|
+
if _is_connect_failure(e):
|
|
2625
|
+
raise LLMError(
|
|
2626
|
+
"LLM request failed for "
|
|
2627
|
+
f"{endpoint_label(self.base_url)}: {sanitize_error_text_for_output(e)}"
|
|
2628
|
+
) from e
|
|
2629
|
+
if _is_read_timeout(e):
|
|
2630
|
+
raise LLMError(
|
|
2631
|
+
"LLM request failed for "
|
|
2632
|
+
f"{endpoint_label(self.base_url)}: {sanitize_error_text_for_output(e)}"
|
|
2633
|
+
) from e
|
|
2634
|
+
raise LLMError(f"LLM request failed: {sanitize_error_text_for_output(e)}") from e
|
|
2635
|
+
|
|
2636
|
+
return stamp_response_for_route(
|
|
2637
|
+
telemetry.run(
|
|
2638
|
+
lambda: run_provider_limited_call(
|
|
2639
|
+
call=_send_request,
|
|
2640
|
+
provider_key=provider_key,
|
|
2641
|
+
provider_concurrency_caps=self.provider_concurrency_caps,
|
|
2642
|
+
retry_settings=self.provider_retry_settings,
|
|
2643
|
+
operation="chat_completions",
|
|
2644
|
+
sleep_fn=self._provider_sleep_fn,
|
|
2645
|
+
random_fn=self._provider_random_fn,
|
|
2646
|
+
on_retry=_record_retry,
|
|
2647
|
+
on_retry_event=self._provider_retry_event_observer,
|
|
2648
|
+
retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
|
|
2649
|
+
retry_wall_clock_cap_seconds=getattr(
|
|
2650
|
+
self,
|
|
2651
|
+
"_provider_retry_wall_clock_cap_seconds",
|
|
2652
|
+
_PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS,
|
|
2653
|
+
),
|
|
2654
|
+
)
|
|
2655
|
+
),
|
|
2656
|
+
self.route_identity,
|
|
2657
|
+
)
|
|
2658
|
+
|
|
2659
|
+
@staticmethod
|
|
2660
|
+
def _parse_non_stream_response(
|
|
2661
|
+
resp: httpx.Response,
|
|
2662
|
+
*,
|
|
2663
|
+
provider_key: str | None,
|
|
2664
|
+
model: str | None = None,
|
|
2665
|
+
) -> LLMResponse:
|
|
2666
|
+
try:
|
|
2667
|
+
data = resp.json()
|
|
2668
|
+
except Exception as e: # noqa: BLE001
|
|
2669
|
+
raise LLMError("LLM returned non-JSON response") from e
|
|
2670
|
+
|
|
2671
|
+
if not isinstance(data, dict):
|
|
2672
|
+
raise LLMError("Unexpected LLM response shape: expected a JSON object")
|
|
2673
|
+
choices = data.get("choices")
|
|
2674
|
+
if not isinstance(choices, list) or not choices:
|
|
2675
|
+
raise LLMError("Unexpected LLM response shape: missing choices[0]")
|
|
2676
|
+
choice0 = choices[0]
|
|
2677
|
+
if not isinstance(choice0, dict) or not isinstance(choice0.get("message"), dict):
|
|
2678
|
+
raise LLMError("Unexpected LLM response shape: missing choices[0].message")
|
|
2679
|
+
msg = choice0["message"]
|
|
2680
|
+
|
|
2681
|
+
content = _normalize_assistant_content_to_text(msg.get("content"))
|
|
2682
|
+
tool_calls_raw = msg.get("tool_calls") or []
|
|
2683
|
+
tool_calls = _parse_tool_calls(tool_calls_raw)
|
|
2684
|
+
response_model = data.get("model") if isinstance(data.get("model"), str) else None
|
|
2685
|
+
return LLMResponse(
|
|
2686
|
+
content=content,
|
|
2687
|
+
tool_calls=tool_calls,
|
|
2688
|
+
raw=data,
|
|
2689
|
+
response_model=response_model,
|
|
2690
|
+
usage=_parse_usage(data.get("usage"), provider_key=provider_key),
|
|
2691
|
+
provider_metadata=_provider_metadata_for_reasoning(
|
|
2692
|
+
provider_key=provider_key,
|
|
2693
|
+
message=msg,
|
|
2694
|
+
model=model,
|
|
2695
|
+
),
|
|
2696
|
+
reasoning=_reasoning_outputs_from_message(msg, provider_key=provider_key),
|
|
2697
|
+
)
|
|
2698
|
+
|
|
2699
|
+
@staticmethod
|
|
2700
|
+
def _error_from_response(resp: httpx.Response) -> LLMError:
|
|
2701
|
+
try:
|
|
2702
|
+
body = resp.text
|
|
2703
|
+
except Exception:
|
|
2704
|
+
body = "<unable to read response body>"
|
|
2705
|
+
return _error_from_status_body(status_code=resp.status_code, body=body)
|
|
2706
|
+
|
|
2707
|
+
def _parse_stream_response(
|
|
2708
|
+
self,
|
|
2709
|
+
resp: httpx.Response,
|
|
2710
|
+
*,
|
|
2711
|
+
on_text_delta: Callable[[str], None] | None,
|
|
2712
|
+
on_reasoning_delta: Callable[[str], None] | None = None,
|
|
2713
|
+
provider_key: str | None,
|
|
2714
|
+
cancellation_token: Any | None = None,
|
|
2715
|
+
) -> LLMResponse:
|
|
2716
|
+
if resp.status_code >= 400:
|
|
2717
|
+
body = self._safe_error_body(resp)
|
|
2718
|
+
raise _error_from_status_body(status_code=resp.status_code, body=body)
|
|
2719
|
+
|
|
2720
|
+
content_parts: list[str] = []
|
|
2721
|
+
tool_chunks: dict[int, dict[str, Any]] = {}
|
|
2722
|
+
event_count = 0
|
|
2723
|
+
response_model: str | None = None
|
|
2724
|
+
# Streaming chunks carry system_fingerprint the same way they carry
|
|
2725
|
+
# model. The non-stream path keeps the whole body in ``raw``, so it
|
|
2726
|
+
# already has it; without this the streaming path -- which is the
|
|
2727
|
+
# default -- would silently record no fingerprint at all.
|
|
2728
|
+
system_fingerprint: str | None = None
|
|
2729
|
+
usage: LLMUsage | None = None
|
|
2730
|
+
accumulated_content = ""
|
|
2731
|
+
reasoning_parts: list[str] = []
|
|
2732
|
+
reasoning_details: list[Any] = []
|
|
2733
|
+
reasoning_summary_parts: dict[str, str] = {}
|
|
2734
|
+
mistral_content_chunks: list[dict[str, Any]] = []
|
|
2735
|
+
saw_done = False
|
|
2736
|
+
progress_clock = self._stream_progress_clock
|
|
2737
|
+
last_meaningful_progress = progress_clock()
|
|
2738
|
+
|
|
2739
|
+
# Make the (possibly long) initial read interruptible: register the live
|
|
2740
|
+
# response's close so a cancel from another thread unblocks iter_lines, and
|
|
2741
|
+
# re-check the flag per line. A close mid-read surfaces as a read error that
|
|
2742
|
+
# we translate into a clean interrupt below.
|
|
2743
|
+
_set_abort = getattr(cancellation_token, "set_abort_callback", None)
|
|
2744
|
+
_clear_abort = getattr(cancellation_token, "clear_abort_callback", None)
|
|
2745
|
+
if callable(_set_abort):
|
|
2746
|
+
_set_abort(resp.close)
|
|
2747
|
+
_stream_iter = resp.iter_lines()
|
|
2748
|
+
while True:
|
|
2749
|
+
try:
|
|
2750
|
+
line = next(_stream_iter)
|
|
2751
|
+
except StopIteration:
|
|
2752
|
+
break
|
|
2753
|
+
except Exception:
|
|
2754
|
+
if cancellation_token is not None and getattr(
|
|
2755
|
+
cancellation_token, "is_cancelled", False
|
|
2756
|
+
):
|
|
2757
|
+
raise KeyboardInterrupt("cancelled_by_user") from None
|
|
2758
|
+
raise
|
|
2759
|
+
if cancellation_token is not None and getattr(
|
|
2760
|
+
cancellation_token, "is_cancelled", False
|
|
2761
|
+
):
|
|
2762
|
+
raise KeyboardInterrupt("cancelled_by_user")
|
|
2763
|
+
stream_deadline_exhausted = getattr(
|
|
2764
|
+
self,
|
|
2765
|
+
"_stream_deadline_exhausted",
|
|
2766
|
+
None,
|
|
2767
|
+
)
|
|
2768
|
+
if callable(stream_deadline_exhausted) and stream_deadline_exhausted():
|
|
2769
|
+
raise DeadlineExhausted("run deadline and in-flight provider grace elapsed")
|
|
2770
|
+
progress_now = progress_clock()
|
|
2771
|
+
if progress_now - last_meaningful_progress >= self.stream_no_progress_timeout_s:
|
|
2772
|
+
raise LLMStreamNoProgressError(
|
|
2773
|
+
"LLM stream produced no meaningful payload within "
|
|
2774
|
+
f"{self.stream_no_progress_timeout_s:g}s."
|
|
2775
|
+
)
|
|
2776
|
+
if not line:
|
|
2777
|
+
continue
|
|
2778
|
+
if isinstance(line, bytes):
|
|
2779
|
+
text = line.decode("utf-8", errors="ignore")
|
|
2780
|
+
else:
|
|
2781
|
+
text = line
|
|
2782
|
+
if not text.startswith("data:"):
|
|
2783
|
+
continue
|
|
2784
|
+
payload = text[5:].strip()
|
|
2785
|
+
if not payload:
|
|
2786
|
+
continue
|
|
2787
|
+
if payload == "[DONE]":
|
|
2788
|
+
last_meaningful_progress = progress_now
|
|
2789
|
+
saw_done = True
|
|
2790
|
+
break
|
|
2791
|
+
|
|
2792
|
+
try:
|
|
2793
|
+
event = json.loads(payload)
|
|
2794
|
+
except json.JSONDecodeError:
|
|
2795
|
+
continue
|
|
2796
|
+
if not isinstance(event, dict):
|
|
2797
|
+
continue
|
|
2798
|
+
last_meaningful_progress = progress_now
|
|
2799
|
+
event_count += 1
|
|
2800
|
+
model = event.get("model")
|
|
2801
|
+
if isinstance(model, str) and model:
|
|
2802
|
+
response_model = model
|
|
2803
|
+
chunk_fingerprint = event.get("system_fingerprint")
|
|
2804
|
+
if isinstance(chunk_fingerprint, str) and chunk_fingerprint:
|
|
2805
|
+
system_fingerprint = chunk_fingerprint
|
|
2806
|
+
parsed_usage = _parse_usage(event.get("usage"), provider_key=provider_key)
|
|
2807
|
+
if parsed_usage is not None:
|
|
2808
|
+
usage = parsed_usage
|
|
2809
|
+
|
|
2810
|
+
choices = event.get("choices") or []
|
|
2811
|
+
if not isinstance(choices, list) or not choices:
|
|
2812
|
+
continue
|
|
2813
|
+
choice0 = choices[0]
|
|
2814
|
+
if not isinstance(choice0, dict):
|
|
2815
|
+
continue
|
|
2816
|
+
delta = choice0.get("delta") or {}
|
|
2817
|
+
if not isinstance(delta, dict):
|
|
2818
|
+
continue
|
|
2819
|
+
|
|
2820
|
+
reasoning_delta = delta.get(_DEEPSEEK_REASONING_CONTENT_KEY)
|
|
2821
|
+
if not isinstance(reasoning_delta, str):
|
|
2822
|
+
reasoning_delta = delta.get(_OPENROUTER_REASONING_KEY)
|
|
2823
|
+
if isinstance(reasoning_delta, str) and reasoning_delta:
|
|
2824
|
+
reasoning_parts.append(reasoning_delta)
|
|
2825
|
+
details_delta = delta.get(_OPENROUTER_REASONING_DETAILS_KEY)
|
|
2826
|
+
if isinstance(details_delta, list) and details_delta:
|
|
2827
|
+
reasoning_details.extend(details_delta)
|
|
2828
|
+
for detail_index, detail in enumerate(details_delta):
|
|
2829
|
+
parsed = (
|
|
2830
|
+
_text_from_reasoning_detail(detail) if isinstance(detail, dict) else None
|
|
2831
|
+
)
|
|
2832
|
+
if parsed is None:
|
|
2833
|
+
continue
|
|
2834
|
+
detail_text, detail_kind = parsed
|
|
2835
|
+
if detail_kind != ReasoningOutputKind.SUMMARY:
|
|
2836
|
+
continue
|
|
2837
|
+
key = str(detail.get("id") or detail.get("index") or f"summary_{detail_index}")
|
|
2838
|
+
previous = reasoning_summary_parts.get(key, "")
|
|
2839
|
+
suffix = _stream_delta_suffix(previous=previous, incoming=detail_text)
|
|
2840
|
+
if not suffix:
|
|
2841
|
+
continue
|
|
2842
|
+
reasoning_summary_parts[key] = previous + suffix
|
|
2843
|
+
if on_reasoning_delta is not None:
|
|
2844
|
+
on_reasoning_delta(suffix)
|
|
2845
|
+
|
|
2846
|
+
raw_content_delta = delta.get("content")
|
|
2847
|
+
if _is_mistral_provider(provider_key):
|
|
2848
|
+
_append_mistral_stream_content(mistral_content_chunks, raw_content_delta)
|
|
2849
|
+
content_delta = _normalize_assistant_content_to_text(raw_content_delta)
|
|
2850
|
+
if content_delta:
|
|
2851
|
+
content_suffix = _stream_delta_suffix(
|
|
2852
|
+
previous=accumulated_content,
|
|
2853
|
+
incoming=content_delta,
|
|
2854
|
+
)
|
|
2855
|
+
if content_suffix:
|
|
2856
|
+
content_parts.append(content_suffix)
|
|
2857
|
+
accumulated_content += content_suffix
|
|
2858
|
+
if on_text_delta is not None:
|
|
2859
|
+
on_text_delta(content_suffix)
|
|
2860
|
+
|
|
2861
|
+
tc_delta = delta.get("tool_calls") or []
|
|
2862
|
+
if not isinstance(tc_delta, list):
|
|
2863
|
+
continue
|
|
2864
|
+
for raw_tc in tc_delta:
|
|
2865
|
+
if not isinstance(raw_tc, dict):
|
|
2866
|
+
continue
|
|
2867
|
+
idx = raw_tc.get("index")
|
|
2868
|
+
if not isinstance(idx, int):
|
|
2869
|
+
continue
|
|
2870
|
+
entry = tool_chunks.setdefault(
|
|
2871
|
+
idx,
|
|
2872
|
+
{"id": "", "name": "", "arguments": "", "provider_metadata": None},
|
|
2873
|
+
)
|
|
2874
|
+
|
|
2875
|
+
tc_id = raw_tc.get("id")
|
|
2876
|
+
if isinstance(tc_id, str) and tc_id:
|
|
2877
|
+
entry["id"] = tc_id
|
|
2878
|
+
|
|
2879
|
+
provider_metadata = _gemini_tool_call_provider_metadata(raw_tc)
|
|
2880
|
+
if provider_metadata:
|
|
2881
|
+
existing_metadata = entry.get("provider_metadata")
|
|
2882
|
+
entry["provider_metadata"] = _merge_provider_metadata(
|
|
2883
|
+
existing_metadata if isinstance(existing_metadata, dict) else None,
|
|
2884
|
+
provider_metadata,
|
|
2885
|
+
)
|
|
2886
|
+
|
|
2887
|
+
fn = raw_tc.get("function")
|
|
2888
|
+
if not isinstance(fn, dict):
|
|
2889
|
+
continue
|
|
2890
|
+
name = fn.get("name")
|
|
2891
|
+
if isinstance(name, str) and name:
|
|
2892
|
+
entry["name"] = name
|
|
2893
|
+
args_piece = fn.get("arguments")
|
|
2894
|
+
if isinstance(args_piece, str):
|
|
2895
|
+
entry["arguments"] += _stream_delta_suffix(
|
|
2896
|
+
previous=entry["arguments"],
|
|
2897
|
+
incoming=args_piece,
|
|
2898
|
+
)
|
|
2899
|
+
|
|
2900
|
+
if callable(_clear_abort):
|
|
2901
|
+
_clear_abort()
|
|
2902
|
+
if cancellation_token is not None and getattr(cancellation_token, "is_cancelled", False):
|
|
2903
|
+
# Stream ended because the abort closed it (clean EOF, not an error).
|
|
2904
|
+
raise KeyboardInterrupt("cancelled_by_user")
|
|
2905
|
+
if not saw_done:
|
|
2906
|
+
raise LLMError("LLM stream truncated before [DONE]")
|
|
2907
|
+
streamed_tool_calls = _parse_stream_tool_calls(tool_chunks)
|
|
2908
|
+
streamed_content = "".join(content_parts)
|
|
2909
|
+
reasoning_message = {
|
|
2910
|
+
_DEEPSEEK_REASONING_CONTENT_KEY: "".join(reasoning_parts),
|
|
2911
|
+
_OPENROUTER_REASONING_KEY: "".join(reasoning_parts),
|
|
2912
|
+
_OPENROUTER_REASONING_DETAILS_KEY: reasoning_details,
|
|
2913
|
+
"content": mistral_content_chunks,
|
|
2914
|
+
}
|
|
2915
|
+
stream_raw: dict[str, Any] = {"stream": True, "events": event_count}
|
|
2916
|
+
if system_fingerprint:
|
|
2917
|
+
# Added only when the provider actually sent one, so a provider
|
|
2918
|
+
# that never emits it leaves ``raw`` exactly as before.
|
|
2919
|
+
stream_raw["system_fingerprint"] = system_fingerprint
|
|
2920
|
+
return LLMResponse(
|
|
2921
|
+
content=streamed_content,
|
|
2922
|
+
tool_calls=streamed_tool_calls,
|
|
2923
|
+
raw=stream_raw,
|
|
2924
|
+
response_model=response_model,
|
|
2925
|
+
usage=usage,
|
|
2926
|
+
provider_metadata=_provider_metadata_for_reasoning(
|
|
2927
|
+
provider_key=provider_key,
|
|
2928
|
+
message=reasoning_message,
|
|
2929
|
+
model=self.model,
|
|
2930
|
+
),
|
|
2931
|
+
reasoning=_reasoning_outputs_from_message(
|
|
2932
|
+
reasoning_message,
|
|
2933
|
+
provider_key=provider_key,
|
|
2934
|
+
),
|
|
2935
|
+
)
|
|
2936
|
+
|
|
2937
|
+
@staticmethod
|
|
2938
|
+
def _safe_error_body(resp: httpx.Response) -> str:
|
|
2939
|
+
try:
|
|
2940
|
+
resp.read()
|
|
2941
|
+
except Exception:
|
|
2942
|
+
pass
|
|
2943
|
+
try:
|
|
2944
|
+
body = resp.text
|
|
2945
|
+
except Exception:
|
|
2946
|
+
body = "<unable to read response body>"
|
|
2947
|
+
return body
|