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,2604 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ..error_text import sanitize_error_text_for_output
|
|
15
|
+
from ..provider_auth import ProviderAuthAdapter
|
|
16
|
+
from ..provider_telemetry import ProviderCallTelemetryRecorder
|
|
17
|
+
from ..request_estimation import estimate_provider_payload_tokens
|
|
18
|
+
from ..web_search_adapters import AUTO_WEB_SEARCH_ADAPTER, OPENAI_RESPONSES_ADAPTER
|
|
19
|
+
from .cache_policy import merge_cache_policy_metadata
|
|
20
|
+
from .metadata import (
|
|
21
|
+
OPENAI_RESPONSES_PROVIDER_METADATA_KEY,
|
|
22
|
+
PROVIDER_METADATA_KEY,
|
|
23
|
+
ProviderRouteIdentity,
|
|
24
|
+
build_provider_route_identity,
|
|
25
|
+
canonicalize_extra_headers,
|
|
26
|
+
credential_scope_fingerprint,
|
|
27
|
+
gate_messages_for_provider_route,
|
|
28
|
+
merge_canonical_headers,
|
|
29
|
+
stamp_response_for_route,
|
|
30
|
+
)
|
|
31
|
+
from .provider_limits import (
|
|
32
|
+
DEFAULT_PROVIDER_CONCURRENCY_CAPS,
|
|
33
|
+
ProviderRetrySettings,
|
|
34
|
+
best_effort_provider_key,
|
|
35
|
+
mark_provider_call_non_retryable,
|
|
36
|
+
run_provider_limited_call,
|
|
37
|
+
)
|
|
38
|
+
from .request_plan import LLMRequestPlan, RequestCachePlan
|
|
39
|
+
from .request_shape import build_request_shape_report
|
|
40
|
+
from .streaming import SSEFrame, iter_sse_frames, parse_sse_json_frame
|
|
41
|
+
from .types import (
|
|
42
|
+
AssistantResponsePhase,
|
|
43
|
+
InputTokenCount,
|
|
44
|
+
LLMError,
|
|
45
|
+
LLMResponse,
|
|
46
|
+
LLMUsage,
|
|
47
|
+
ReasoningOutput,
|
|
48
|
+
ReasoningOutputKind,
|
|
49
|
+
ToolCall,
|
|
50
|
+
UsageConfidence,
|
|
51
|
+
UsageContract,
|
|
52
|
+
)
|
|
53
|
+
from .usage_normalization import parse_compatible_usage
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ResponsesError(RuntimeError):
|
|
57
|
+
def __init__(self, message: object = "") -> None:
|
|
58
|
+
super().__init__(sanitize_error_text_for_output(message))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_DEFAULT_ACCEPT_ENCODING = "identity"
|
|
62
|
+
_PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS = 60.0
|
|
63
|
+
_OPENAI_RESPONSES_METADATA_KEY = OPENAI_RESPONSES_PROVIDER_METADATA_KEY
|
|
64
|
+
_WEB_SEARCH_MODES_ALLOWING_OPENAI_BUILTIN = frozenset({"auto", "native"})
|
|
65
|
+
_RESPONSES_TOOL_CHOICE_STRINGS = frozenset({"auto", "none", "required"})
|
|
66
|
+
_RESPONSES_REASONING_EFFORTS = frozenset(
|
|
67
|
+
{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
|
|
68
|
+
)
|
|
69
|
+
_RESPONSES_JSON_SCHEMA_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
70
|
+
_ALYSIS_WEB_SEARCH_FUNCTION_NAME = "web_search"
|
|
71
|
+
_RESPONSES_HOSTED_WEB_SEARCH_TYPES = frozenset({"web_search", "web_search_preview"})
|
|
72
|
+
_MIN_RESPONSES_OUTPUT_TOKENS = 16
|
|
73
|
+
_LOGGER = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class _ResponsesContinuation:
|
|
78
|
+
previous_response_id: str
|
|
79
|
+
suffix_messages: list[dict[str, Any]]
|
|
80
|
+
anchor_index: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _stable_request_signature(value: Any) -> str:
|
|
84
|
+
payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
85
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _non_negative_int(value: Any) -> int | None:
|
|
89
|
+
try:
|
|
90
|
+
parsed = int(value)
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return None
|
|
93
|
+
return parsed if parsed >= 0 else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _clamp_responses_max_output_tokens(value: int) -> int:
|
|
97
|
+
requested = int(value)
|
|
98
|
+
if requested >= _MIN_RESPONSES_OUTPUT_TOKENS:
|
|
99
|
+
return requested
|
|
100
|
+
_LOGGER.warning(
|
|
101
|
+
"OpenAI Responses max_output_tokens adjusted from %d to documented minimum %d",
|
|
102
|
+
requested,
|
|
103
|
+
_MIN_RESPONSES_OUTPUT_TOKENS,
|
|
104
|
+
)
|
|
105
|
+
return _MIN_RESPONSES_OUTPUT_TOKENS
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Some models (notably the GPT-5 reasoning family) reject a non-default
|
|
109
|
+
# ``temperature`` on the Responses API with a 400/422 ("temperature is not
|
|
110
|
+
# supported … only the default (1) value is supported"). Unlike the chat-compat
|
|
111
|
+
# client, this one had no fallback, so such a model failed on every call — and on
|
|
112
|
+
# setup-wizard key/model validation. We now detect that error once per
|
|
113
|
+
# (base_url, model), drop ``temperature`` from the payload, retry the offending
|
|
114
|
+
# call immediately, and omit it from every later call to the same model.
|
|
115
|
+
_TEMPERATURE_UNSUPPORTED_STATUS_MARKERS = ("error 400", "error 422")
|
|
116
|
+
_TEMPERATURE_UNSUPPORTED_TOKENS = (
|
|
117
|
+
"unsupported",
|
|
118
|
+
"not support",
|
|
119
|
+
"not allowed",
|
|
120
|
+
"only the default",
|
|
121
|
+
"out of range",
|
|
122
|
+
"deprecated",
|
|
123
|
+
)
|
|
124
|
+
_RESPONSES_OMIT_TEMPERATURE_MODELS: set[str] = set()
|
|
125
|
+
|
|
126
|
+
# Endpoints (base_url + model) whose gateway rejected the optional ``include``
|
|
127
|
+
# entries (e.g. ``web_search_call.action.sources``). Source metadata is an
|
|
128
|
+
# enhancement, not a requirement, so once a gateway rejects it we stop sending
|
|
129
|
+
# it for the rest of the process instead of failing every request.
|
|
130
|
+
_RESPONSES_OMIT_INCLUDE_ENDPOINTS: set[str] = set()
|
|
131
|
+
|
|
132
|
+
_INCLUDE_UNSUPPORTED_STATUS_MARKERS = ("error 400", "error 422", "web_search unsupported")
|
|
133
|
+
_INCLUDE_VALUE_CONTEXT_TOKENS = (
|
|
134
|
+
"web_search_call.action.sources",
|
|
135
|
+
"include value",
|
|
136
|
+
"include values",
|
|
137
|
+
"'include'",
|
|
138
|
+
'"include"',
|
|
139
|
+
"include parameter",
|
|
140
|
+
"include field",
|
|
141
|
+
"param 'include'",
|
|
142
|
+
'param "include"',
|
|
143
|
+
"parameter: include",
|
|
144
|
+
"parameter 'include'",
|
|
145
|
+
)
|
|
146
|
+
_INCLUDE_UNSUPPORTED_TOKENS = (
|
|
147
|
+
"invalid",
|
|
148
|
+
"unsupported",
|
|
149
|
+
"not support",
|
|
150
|
+
"not allowed",
|
|
151
|
+
"not permitted",
|
|
152
|
+
"unknown",
|
|
153
|
+
"unrecognized",
|
|
154
|
+
"unexpected",
|
|
155
|
+
"cannot be produced",
|
|
156
|
+
"cannot",
|
|
157
|
+
"forbidden",
|
|
158
|
+
"must be omitted",
|
|
159
|
+
"extra",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
_REASONING_SUMMARY_UNSUPPORTED_STATUS_MARKERS = ("error 400", "error 422")
|
|
163
|
+
_REASONING_SUMMARY_UNSUPPORTED_TOKENS = (
|
|
164
|
+
"invalid",
|
|
165
|
+
"unsupported",
|
|
166
|
+
"not support",
|
|
167
|
+
"not allowed",
|
|
168
|
+
"not permitted",
|
|
169
|
+
"unknown",
|
|
170
|
+
"unknown parameter",
|
|
171
|
+
"unknown field",
|
|
172
|
+
"unrecognized",
|
|
173
|
+
"unexpected",
|
|
174
|
+
"invalid parameter",
|
|
175
|
+
"extra",
|
|
176
|
+
"extra inputs",
|
|
177
|
+
"forbidden",
|
|
178
|
+
"cannot be set",
|
|
179
|
+
"must be omitted",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _responses_endpoint_model_key(base_url: str, model: str) -> str:
|
|
184
|
+
return f"{str(base_url).strip().rstrip('/')}\n{str(model).strip()}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _responses_temperature_omit_key(base_url: str, model: str) -> str:
|
|
188
|
+
return _responses_endpoint_model_key(base_url, model)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _responses_include_unsupported(err: Exception) -> bool:
|
|
192
|
+
"""Return whether a 400/422 clearly rejects the optional ``include`` entries.
|
|
193
|
+
|
|
194
|
+
Requires an explicit ``include``-value context token so unrelated 400s that
|
|
195
|
+
merely contain the word "include" (e.g. "input must include a message") do
|
|
196
|
+
not permanently drop source metadata for the endpoint.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
text = str(err).casefold()
|
|
200
|
+
if not any(marker in text for marker in _INCLUDE_UNSUPPORTED_STATUS_MARKERS):
|
|
201
|
+
return False
|
|
202
|
+
if not any(token in text for token in _INCLUDE_VALUE_CONTEXT_TOKENS):
|
|
203
|
+
return False
|
|
204
|
+
return any(token in text for token in _INCLUDE_UNSUPPORTED_TOKENS)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _responses_temperature_unsupported(err: Exception) -> bool:
|
|
208
|
+
text = str(err).casefold()
|
|
209
|
+
if "temperature" not in text:
|
|
210
|
+
return False
|
|
211
|
+
if not any(marker in text for marker in _TEMPERATURE_UNSUPPORTED_STATUS_MARKERS):
|
|
212
|
+
return False
|
|
213
|
+
return any(token in text for token in _TEMPERATURE_UNSUPPORTED_TOKENS)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _responses_reasoning_summary_unsupported(err: Exception) -> bool:
|
|
217
|
+
"""Return whether a 400/422 clearly rejects ``reasoning.summary``.
|
|
218
|
+
|
|
219
|
+
Keep this deliberately narrower than the generic unsupported-parameter
|
|
220
|
+
fallback: an unrelated response summary error must not disable reasoning
|
|
221
|
+
summaries for the rest of the client session.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
text = str(err).casefold()
|
|
225
|
+
if not any(marker in text for marker in _REASONING_SUMMARY_UNSUPPORTED_STATUS_MARKERS):
|
|
226
|
+
return False
|
|
227
|
+
if "summary" not in text:
|
|
228
|
+
return False
|
|
229
|
+
if not any(
|
|
230
|
+
context in text
|
|
231
|
+
for context in (
|
|
232
|
+
"reasoning",
|
|
233
|
+
"parameter",
|
|
234
|
+
"field",
|
|
235
|
+
"request argument",
|
|
236
|
+
"extra inputs",
|
|
237
|
+
)
|
|
238
|
+
):
|
|
239
|
+
return False
|
|
240
|
+
return any(token in text for token in _REASONING_SUMMARY_UNSUPPORTED_TOKENS)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _without_responses_reasoning_summary(payload: dict[str, Any]) -> bool:
|
|
244
|
+
"""Remove only ``reasoning.summary`` from an already-adapted payload."""
|
|
245
|
+
|
|
246
|
+
raw_reasoning = payload.get("reasoning")
|
|
247
|
+
if not isinstance(raw_reasoning, dict) or "summary" not in raw_reasoning:
|
|
248
|
+
return False
|
|
249
|
+
reasoning = copy.deepcopy(raw_reasoning)
|
|
250
|
+
reasoning.pop("summary", None)
|
|
251
|
+
if reasoning:
|
|
252
|
+
payload["reasoning"] = reasoning
|
|
253
|
+
else:
|
|
254
|
+
payload.pop("reasoning", None)
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _headers_with_default_accept_encoding(headers: dict[str, str]) -> dict[str, str]:
|
|
259
|
+
request_headers = dict(headers)
|
|
260
|
+
if not any(key.lower() == "accept-encoding" for key in request_headers):
|
|
261
|
+
request_headers["accept-encoding"] = _DEFAULT_ACCEPT_ENCODING
|
|
262
|
+
return request_headers
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True)
|
|
266
|
+
class WebSearchCitation:
|
|
267
|
+
title: str
|
|
268
|
+
url: str
|
|
269
|
+
start_index: int | None = None
|
|
270
|
+
end_index: int | None = None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True)
|
|
274
|
+
class WebSearchSource:
|
|
275
|
+
url: str
|
|
276
|
+
title: str = ""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class WebSearchResponse:
|
|
281
|
+
answer: str
|
|
282
|
+
citations: list[WebSearchCitation]
|
|
283
|
+
sources: list[WebSearchSource]
|
|
284
|
+
queries: list[str]
|
|
285
|
+
raw: dict[str, Any]
|
|
286
|
+
response_id: str | None = None
|
|
287
|
+
model: str | None = None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _coerce_int(value: Any) -> int | None:
|
|
291
|
+
try:
|
|
292
|
+
return int(value)
|
|
293
|
+
except (TypeError, ValueError):
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _extract_error_message(data: Any) -> str | None:
|
|
298
|
+
if not isinstance(data, dict):
|
|
299
|
+
return None
|
|
300
|
+
error_obj = data.get("error")
|
|
301
|
+
if isinstance(error_obj, dict):
|
|
302
|
+
message = str(error_obj.get("message") or "").strip()
|
|
303
|
+
if message:
|
|
304
|
+
return message
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _parse_arguments(args_s: Any) -> dict[str, Any]:
|
|
309
|
+
if isinstance(args_s, dict):
|
|
310
|
+
return dict(args_s)
|
|
311
|
+
if not isinstance(args_s, str):
|
|
312
|
+
args_s = json.dumps(args_s if args_s is not None else {})
|
|
313
|
+
try:
|
|
314
|
+
args = json.loads(args_s)
|
|
315
|
+
except json.JSONDecodeError:
|
|
316
|
+
return {"_raw_arguments": args_s}
|
|
317
|
+
if not isinstance(args, dict):
|
|
318
|
+
return {"_raw_arguments": args_s}
|
|
319
|
+
return args
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _json_arguments(args: Any) -> str:
|
|
323
|
+
if isinstance(args, str):
|
|
324
|
+
try:
|
|
325
|
+
parsed = json.loads(args)
|
|
326
|
+
except json.JSONDecodeError:
|
|
327
|
+
return args
|
|
328
|
+
return json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
|
|
329
|
+
if args is None:
|
|
330
|
+
return "{}"
|
|
331
|
+
return json.dumps(args, ensure_ascii=False, separators=(",", ":"))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _content_to_text(raw: Any) -> str:
|
|
335
|
+
if raw is None:
|
|
336
|
+
return ""
|
|
337
|
+
if isinstance(raw, str):
|
|
338
|
+
return raw
|
|
339
|
+
if isinstance(raw, list):
|
|
340
|
+
parts: list[str] = []
|
|
341
|
+
for item in raw:
|
|
342
|
+
if isinstance(item, str):
|
|
343
|
+
parts.append(item)
|
|
344
|
+
elif isinstance(item, dict):
|
|
345
|
+
text = item.get("text") or item.get("content")
|
|
346
|
+
if isinstance(text, str):
|
|
347
|
+
parts.append(text)
|
|
348
|
+
return "".join(parts)
|
|
349
|
+
if isinstance(raw, dict):
|
|
350
|
+
text = raw.get("text") or raw.get("content")
|
|
351
|
+
return text if isinstance(text, str) else ""
|
|
352
|
+
return str(raw)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _responses_message_content(raw: Any, *, role: str) -> str | list[dict[str, Any]]:
|
|
356
|
+
if raw is None:
|
|
357
|
+
return ""
|
|
358
|
+
if isinstance(raw, str):
|
|
359
|
+
return raw
|
|
360
|
+
if not isinstance(raw, list):
|
|
361
|
+
return _content_to_text(raw)
|
|
362
|
+
|
|
363
|
+
parts: list[dict[str, Any]] = []
|
|
364
|
+
for item in raw:
|
|
365
|
+
if isinstance(item, str):
|
|
366
|
+
text = item
|
|
367
|
+
if text:
|
|
368
|
+
parts.append(
|
|
369
|
+
{"type": "output_text" if role == "assistant" else "input_text", "text": text}
|
|
370
|
+
)
|
|
371
|
+
continue
|
|
372
|
+
if not isinstance(item, dict):
|
|
373
|
+
continue
|
|
374
|
+
part_type = str(item.get("type") or "").strip()
|
|
375
|
+
text = item.get("text") or item.get("content")
|
|
376
|
+
if part_type in {"text", "input_text", "output_text"} and isinstance(text, str):
|
|
377
|
+
parts.append(
|
|
378
|
+
{
|
|
379
|
+
"type": "output_text" if role == "assistant" else "input_text",
|
|
380
|
+
"text": text,
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
continue
|
|
384
|
+
if role != "user":
|
|
385
|
+
continue
|
|
386
|
+
if part_type == "image_url":
|
|
387
|
+
image_url = item.get("image_url")
|
|
388
|
+
url = ""
|
|
389
|
+
if isinstance(image_url, dict):
|
|
390
|
+
url = str(image_url.get("url") or "").strip()
|
|
391
|
+
elif isinstance(image_url, str):
|
|
392
|
+
url = image_url.strip()
|
|
393
|
+
if url:
|
|
394
|
+
parts.append({"type": "input_image", "image_url": url})
|
|
395
|
+
continue
|
|
396
|
+
if part_type == "input_image":
|
|
397
|
+
copied = {key: copy.deepcopy(value) for key, value in item.items()}
|
|
398
|
+
if copied.get("image_url") or copied.get("file_id"):
|
|
399
|
+
parts.append(copied)
|
|
400
|
+
return parts if parts else ""
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _chat_tool_call_parts(raw_tool_call: Any) -> tuple[str, str, str] | None:
|
|
404
|
+
if not isinstance(raw_tool_call, dict):
|
|
405
|
+
return None
|
|
406
|
+
call_id = str(raw_tool_call.get("id") or raw_tool_call.get("call_id") or "").strip()
|
|
407
|
+
function = raw_tool_call.get("function")
|
|
408
|
+
if isinstance(function, dict):
|
|
409
|
+
name = str(function.get("name") or "").strip()
|
|
410
|
+
arguments = _json_arguments(function.get("arguments"))
|
|
411
|
+
else:
|
|
412
|
+
name = str(raw_tool_call.get("name") or "").strip()
|
|
413
|
+
arguments = _json_arguments(raw_tool_call.get("arguments"))
|
|
414
|
+
if not name:
|
|
415
|
+
return None
|
|
416
|
+
if not call_id:
|
|
417
|
+
call_id = f"call_{name}"
|
|
418
|
+
return call_id, name, arguments
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _metadata_output_items(message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
422
|
+
metadata = message.get(PROVIDER_METADATA_KEY)
|
|
423
|
+
if not isinstance(metadata, dict):
|
|
424
|
+
return []
|
|
425
|
+
responses_metadata = metadata.get(_OPENAI_RESPONSES_METADATA_KEY)
|
|
426
|
+
if not isinstance(responses_metadata, dict):
|
|
427
|
+
return []
|
|
428
|
+
output_items = responses_metadata.get("output_items")
|
|
429
|
+
if not isinstance(output_items, list):
|
|
430
|
+
return []
|
|
431
|
+
copied: list[dict[str, Any]] = []
|
|
432
|
+
for item in output_items:
|
|
433
|
+
if isinstance(item, dict):
|
|
434
|
+
copied.append(copy.deepcopy(item))
|
|
435
|
+
return copied
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _metadata_responses_payload(message: dict[str, Any]) -> dict[str, Any] | None:
|
|
439
|
+
metadata = message.get(PROVIDER_METADATA_KEY)
|
|
440
|
+
if not isinstance(metadata, dict):
|
|
441
|
+
return None
|
|
442
|
+
responses_metadata = metadata.get(_OPENAI_RESPONSES_METADATA_KEY)
|
|
443
|
+
if not isinstance(responses_metadata, dict):
|
|
444
|
+
return None
|
|
445
|
+
return responses_metadata
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _response_with_request_plan_metadata(
|
|
449
|
+
response: LLMResponse,
|
|
450
|
+
request_plan_metadata: dict[str, Any],
|
|
451
|
+
) -> LLMResponse:
|
|
452
|
+
provider_metadata = copy.deepcopy(response.provider_metadata) or {}
|
|
453
|
+
responses_metadata = provider_metadata.setdefault(_OPENAI_RESPONSES_METADATA_KEY, {})
|
|
454
|
+
if isinstance(responses_metadata, dict):
|
|
455
|
+
responses_metadata["request_plan"] = copy.deepcopy(request_plan_metadata)
|
|
456
|
+
return LLMResponse(
|
|
457
|
+
content=response.content,
|
|
458
|
+
tool_calls=response.tool_calls,
|
|
459
|
+
raw=response.raw,
|
|
460
|
+
response_model=response.response_model,
|
|
461
|
+
usage=response.usage,
|
|
462
|
+
provider_metadata=provider_metadata,
|
|
463
|
+
reasoning=response.reasoning,
|
|
464
|
+
assistant_phase=response.assistant_phase,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _responses_continuation_from_messages(
|
|
469
|
+
messages: list[dict[str, Any]],
|
|
470
|
+
) -> _ResponsesContinuation | None:
|
|
471
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
472
|
+
message = messages[index]
|
|
473
|
+
if not isinstance(message, dict):
|
|
474
|
+
continue
|
|
475
|
+
if str(message.get("role") or "").strip() != "assistant":
|
|
476
|
+
continue
|
|
477
|
+
responses_metadata = _metadata_responses_payload(message)
|
|
478
|
+
if not responses_metadata:
|
|
479
|
+
continue
|
|
480
|
+
previous_response_id = str(responses_metadata.get("response_id") or "").strip()
|
|
481
|
+
request_plan_metadata = responses_metadata.get("request_plan")
|
|
482
|
+
if not previous_response_id or not isinstance(request_plan_metadata, dict):
|
|
483
|
+
continue
|
|
484
|
+
request_message_count = _non_negative_int(
|
|
485
|
+
request_plan_metadata.get("request_message_count")
|
|
486
|
+
)
|
|
487
|
+
request_messages_signature = str(
|
|
488
|
+
request_plan_metadata.get("request_messages_signature") or ""
|
|
489
|
+
).strip()
|
|
490
|
+
if request_message_count != index or not request_messages_signature:
|
|
491
|
+
continue
|
|
492
|
+
prefix_messages = messages[:index]
|
|
493
|
+
if _stable_request_signature(prefix_messages) != request_messages_signature:
|
|
494
|
+
continue
|
|
495
|
+
suffix_messages = messages[index + 1 :]
|
|
496
|
+
if not suffix_messages:
|
|
497
|
+
continue
|
|
498
|
+
return _ResponsesContinuation(
|
|
499
|
+
previous_response_id=previous_response_id,
|
|
500
|
+
suffix_messages=copy.deepcopy(suffix_messages),
|
|
501
|
+
anchor_index=index,
|
|
502
|
+
)
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _responses_previous_response_rejected(err: Exception) -> bool:
|
|
507
|
+
text = str(err).casefold()
|
|
508
|
+
if "previous_response_id" in text:
|
|
509
|
+
return True
|
|
510
|
+
return "previous response" in text and any(
|
|
511
|
+
marker in text
|
|
512
|
+
for marker in ("not found", "invalid", "expired", "unknown", "does not exist")
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _responses_input_from_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
517
|
+
input_items: list[dict[str, Any]] = []
|
|
518
|
+
for message in messages:
|
|
519
|
+
if not isinstance(message, dict):
|
|
520
|
+
continue
|
|
521
|
+
role = str(message.get("role") or "").strip()
|
|
522
|
+
if role in {"system", "developer", "user"}:
|
|
523
|
+
input_items.append(
|
|
524
|
+
{
|
|
525
|
+
"role": role,
|
|
526
|
+
"content": _responses_message_content(message.get("content"), role=role),
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
continue
|
|
530
|
+
if role == "assistant":
|
|
531
|
+
metadata_items = _metadata_output_items(message)
|
|
532
|
+
if metadata_items:
|
|
533
|
+
input_items.extend(metadata_items)
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
content = _responses_message_content(message.get("content"), role=role)
|
|
537
|
+
if content:
|
|
538
|
+
input_items.append({"role": "assistant", "content": content})
|
|
539
|
+
raw_tool_calls = message.get("tool_calls")
|
|
540
|
+
if isinstance(raw_tool_calls, list):
|
|
541
|
+
for raw_tool_call in raw_tool_calls:
|
|
542
|
+
parts = _chat_tool_call_parts(raw_tool_call)
|
|
543
|
+
if parts is None:
|
|
544
|
+
continue
|
|
545
|
+
call_id, name, arguments = parts
|
|
546
|
+
input_items.append(
|
|
547
|
+
{
|
|
548
|
+
"type": "function_call",
|
|
549
|
+
"call_id": call_id,
|
|
550
|
+
"name": name,
|
|
551
|
+
"arguments": arguments,
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
continue
|
|
555
|
+
if role == "tool":
|
|
556
|
+
call_id = str(message.get("tool_call_id") or message.get("call_id") or "").strip()
|
|
557
|
+
if not call_id:
|
|
558
|
+
raise LLMError("OpenAI Responses tool message is missing tool_call_id")
|
|
559
|
+
input_items.append(
|
|
560
|
+
{
|
|
561
|
+
"type": "function_call_output",
|
|
562
|
+
"call_id": call_id,
|
|
563
|
+
"output": _content_to_text(message.get("content")),
|
|
564
|
+
}
|
|
565
|
+
)
|
|
566
|
+
continue
|
|
567
|
+
raise LLMError(f"OpenAI Responses cannot send message role {role!r}")
|
|
568
|
+
return input_items
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _assistant_output_parts(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
572
|
+
output = data.get("output")
|
|
573
|
+
if not isinstance(output, list):
|
|
574
|
+
return []
|
|
575
|
+
parts: list[dict[str, Any]] = []
|
|
576
|
+
for item in output:
|
|
577
|
+
if not isinstance(item, dict):
|
|
578
|
+
continue
|
|
579
|
+
if str(item.get("type") or "") != "message":
|
|
580
|
+
continue
|
|
581
|
+
if str(item.get("role") or "") != "assistant":
|
|
582
|
+
continue
|
|
583
|
+
content = item.get("content")
|
|
584
|
+
if not isinstance(content, list):
|
|
585
|
+
continue
|
|
586
|
+
for part in content:
|
|
587
|
+
if isinstance(part, dict):
|
|
588
|
+
parts.append(part)
|
|
589
|
+
return parts
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _extract_answer_text(data: dict[str, Any]) -> str:
|
|
593
|
+
output_text = data.get("output_text")
|
|
594
|
+
if isinstance(output_text, str) and output_text.strip():
|
|
595
|
+
return output_text
|
|
596
|
+
|
|
597
|
+
text_parts: list[str] = []
|
|
598
|
+
for part in _assistant_output_parts(data):
|
|
599
|
+
part_type = str(part.get("type") or "")
|
|
600
|
+
if part_type not in {"output_text", "text"}:
|
|
601
|
+
continue
|
|
602
|
+
text = part.get("text")
|
|
603
|
+
if isinstance(text, str):
|
|
604
|
+
text_parts.append(text)
|
|
605
|
+
return "".join(text_parts)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _assistant_response_phase(data: dict[str, Any]) -> AssistantResponsePhase | None:
|
|
609
|
+
"""Normalize a provider-declared assistant phase without inferring from prose."""
|
|
610
|
+
|
|
611
|
+
output = data.get("output")
|
|
612
|
+
if not isinstance(output, list):
|
|
613
|
+
return None
|
|
614
|
+
for item in reversed(output):
|
|
615
|
+
if not isinstance(item, dict):
|
|
616
|
+
continue
|
|
617
|
+
if str(item.get("type") or "") != "message":
|
|
618
|
+
continue
|
|
619
|
+
if str(item.get("role") or "") != "assistant":
|
|
620
|
+
continue
|
|
621
|
+
try:
|
|
622
|
+
return AssistantResponsePhase(str(item.get("phase") or "").strip())
|
|
623
|
+
except ValueError:
|
|
624
|
+
return None
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _extract_citations(data: dict[str, Any]) -> list[WebSearchCitation]:
|
|
629
|
+
citations: list[WebSearchCitation] = []
|
|
630
|
+
for part in _assistant_output_parts(data):
|
|
631
|
+
annotations = part.get("annotations")
|
|
632
|
+
if not isinstance(annotations, list):
|
|
633
|
+
continue
|
|
634
|
+
for annotation in annotations:
|
|
635
|
+
if not isinstance(annotation, dict):
|
|
636
|
+
continue
|
|
637
|
+
if str(annotation.get("type") or "") != "url_citation":
|
|
638
|
+
continue
|
|
639
|
+
url = str(annotation.get("url") or "").strip()
|
|
640
|
+
if not url:
|
|
641
|
+
continue
|
|
642
|
+
citations.append(
|
|
643
|
+
WebSearchCitation(
|
|
644
|
+
title=str(annotation.get("title") or "").strip(),
|
|
645
|
+
url=url,
|
|
646
|
+
start_index=_coerce_int(annotation.get("start_index")),
|
|
647
|
+
end_index=_coerce_int(annotation.get("end_index")),
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
raw_citations = data.get("citations")
|
|
651
|
+
if isinstance(raw_citations, list):
|
|
652
|
+
for raw_citation in raw_citations:
|
|
653
|
+
citation = _coerce_citation(raw_citation)
|
|
654
|
+
if citation is not None:
|
|
655
|
+
citations.append(citation)
|
|
656
|
+
return _dedupe_citations(citations)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _coerce_citation(raw_citation: Any) -> WebSearchCitation | None:
|
|
660
|
+
if isinstance(raw_citation, str):
|
|
661
|
+
url = raw_citation.strip()
|
|
662
|
+
if not url:
|
|
663
|
+
return None
|
|
664
|
+
return WebSearchCitation(title="", url=url)
|
|
665
|
+
if not isinstance(raw_citation, dict):
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
citation_payload = raw_citation
|
|
669
|
+
for nested_key in ("url_citation", "web_citation", "x_citation"):
|
|
670
|
+
nested = raw_citation.get(nested_key)
|
|
671
|
+
if isinstance(nested, dict):
|
|
672
|
+
citation_payload = nested
|
|
673
|
+
break
|
|
674
|
+
|
|
675
|
+
url = str(
|
|
676
|
+
citation_payload.get("url")
|
|
677
|
+
or citation_payload.get("uri")
|
|
678
|
+
or citation_payload.get("link")
|
|
679
|
+
or ""
|
|
680
|
+
).strip()
|
|
681
|
+
if not url:
|
|
682
|
+
return None
|
|
683
|
+
start_index = citation_payload.get("start_index")
|
|
684
|
+
if start_index is None:
|
|
685
|
+
start_index = citation_payload.get("startIndex")
|
|
686
|
+
end_index = citation_payload.get("end_index")
|
|
687
|
+
if end_index is None:
|
|
688
|
+
end_index = citation_payload.get("endIndex")
|
|
689
|
+
return WebSearchCitation(
|
|
690
|
+
title=str(citation_payload.get("title") or citation_payload.get("name") or "").strip(),
|
|
691
|
+
url=url,
|
|
692
|
+
start_index=_coerce_int(start_index),
|
|
693
|
+
end_index=_coerce_int(end_index),
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _dedupe_citations(citations: list[WebSearchCitation]) -> list[WebSearchCitation]:
|
|
698
|
+
deduped: list[WebSearchCitation] = []
|
|
699
|
+
seen: set[str] = set()
|
|
700
|
+
for citation in citations:
|
|
701
|
+
url = str(citation.url or "").strip()
|
|
702
|
+
if not url or url in seen:
|
|
703
|
+
continue
|
|
704
|
+
seen.add(url)
|
|
705
|
+
deduped.append(citation)
|
|
706
|
+
return deduped
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _extract_sources_and_queries(data: dict[str, Any]) -> tuple[list[WebSearchSource], list[str]]:
|
|
710
|
+
output = data.get("output")
|
|
711
|
+
if not isinstance(output, list):
|
|
712
|
+
return [], []
|
|
713
|
+
|
|
714
|
+
sources: list[WebSearchSource] = []
|
|
715
|
+
queries: list[str] = []
|
|
716
|
+
for item in output:
|
|
717
|
+
if not isinstance(item, dict):
|
|
718
|
+
continue
|
|
719
|
+
if str(item.get("type") or "") != "web_search_call":
|
|
720
|
+
continue
|
|
721
|
+
action = item.get("action")
|
|
722
|
+
if not isinstance(action, dict):
|
|
723
|
+
continue
|
|
724
|
+
|
|
725
|
+
raw_sources = action.get("sources")
|
|
726
|
+
if isinstance(raw_sources, list):
|
|
727
|
+
for raw_source in raw_sources:
|
|
728
|
+
if not isinstance(raw_source, dict):
|
|
729
|
+
continue
|
|
730
|
+
url = str(raw_source.get("url") or "").strip()
|
|
731
|
+
if not url:
|
|
732
|
+
continue
|
|
733
|
+
sources.append(
|
|
734
|
+
WebSearchSource(
|
|
735
|
+
url=url,
|
|
736
|
+
title=str(raw_source.get("title") or "").strip(),
|
|
737
|
+
)
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
raw_queries = action.get("queries")
|
|
741
|
+
if isinstance(raw_queries, list):
|
|
742
|
+
for raw_query in raw_queries:
|
|
743
|
+
query = str(raw_query or "").strip()
|
|
744
|
+
if query:
|
|
745
|
+
queries.append(query)
|
|
746
|
+
raw_query = action.get("query")
|
|
747
|
+
if isinstance(raw_query, str) and raw_query.strip():
|
|
748
|
+
queries.append(raw_query.strip())
|
|
749
|
+
|
|
750
|
+
return sources, queries
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _has_web_search_call_output(data: dict[str, Any]) -> bool:
|
|
754
|
+
output = data.get("output")
|
|
755
|
+
if not isinstance(output, list):
|
|
756
|
+
return False
|
|
757
|
+
return any(
|
|
758
|
+
isinstance(item, dict) and str(item.get("type") or "") == "web_search_call"
|
|
759
|
+
for item in output
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _merge_citation_sources(
|
|
764
|
+
sources: list[WebSearchSource],
|
|
765
|
+
citations: list[WebSearchCitation],
|
|
766
|
+
) -> list[WebSearchSource]:
|
|
767
|
+
merged = list(sources)
|
|
768
|
+
seen = {str(source.url or "").strip() for source in merged if str(source.url or "").strip()}
|
|
769
|
+
for citation in citations:
|
|
770
|
+
url = str(citation.url or "").strip()
|
|
771
|
+
if not url or url in seen:
|
|
772
|
+
continue
|
|
773
|
+
seen.add(url)
|
|
774
|
+
merged.append(WebSearchSource(url=url, title=str(citation.title or "").strip()))
|
|
775
|
+
return merged
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _function_name_from_tool(tool: dict[str, Any]) -> str:
|
|
779
|
+
function = tool.get("function")
|
|
780
|
+
if isinstance(function, dict):
|
|
781
|
+
return str(function.get("name") or "").strip()
|
|
782
|
+
if str(tool.get("type") or "") == "function":
|
|
783
|
+
return str(tool.get("name") or "").strip()
|
|
784
|
+
return ""
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _responses_tool_from_chat_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
|
|
788
|
+
if not isinstance(tool, dict):
|
|
789
|
+
return None
|
|
790
|
+
tool_type = str(tool.get("type") or "").strip()
|
|
791
|
+
if tool_type == "function":
|
|
792
|
+
function = tool.get("function")
|
|
793
|
+
if isinstance(function, dict):
|
|
794
|
+
name = str(function.get("name") or "").strip()
|
|
795
|
+
if not name:
|
|
796
|
+
return None
|
|
797
|
+
mapped: dict[str, Any] = {
|
|
798
|
+
"type": "function",
|
|
799
|
+
"name": name,
|
|
800
|
+
"parameters": copy.deepcopy(function.get("parameters") or {"type": "object"}),
|
|
801
|
+
}
|
|
802
|
+
description = str(function.get("description") or "").strip()
|
|
803
|
+
if description:
|
|
804
|
+
mapped["description"] = description
|
|
805
|
+
strict = function.get("strict", tool.get("strict"))
|
|
806
|
+
if strict is not None:
|
|
807
|
+
mapped["strict"] = bool(strict)
|
|
808
|
+
return mapped
|
|
809
|
+
|
|
810
|
+
name = str(tool.get("name") or "").strip()
|
|
811
|
+
if not name:
|
|
812
|
+
return None
|
|
813
|
+
mapped = copy.deepcopy(tool)
|
|
814
|
+
mapped["type"] = "function"
|
|
815
|
+
return mapped
|
|
816
|
+
if tool_type in {"web_search", "web_search_preview"}:
|
|
817
|
+
return copy.deepcopy(tool)
|
|
818
|
+
raise LLMError(f"OpenAI Responses does not support tool type {tool_type!r}")
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _tools_contain_function(tools: list[dict[str, Any]] | None, name: str) -> bool:
|
|
822
|
+
if not tools:
|
|
823
|
+
return False
|
|
824
|
+
return any(_function_name_from_tool(tool) == name for tool in tools if isinstance(tool, dict))
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _is_alysis_web_search_function(tool: dict[str, Any]) -> bool:
|
|
828
|
+
return _function_name_from_tool(tool) == _ALYSIS_WEB_SEARCH_FUNCTION_NAME
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _is_responses_hosted_web_search_tool(tool: dict[str, Any]) -> bool:
|
|
832
|
+
return str(tool.get("type") or "").strip() in _RESPONSES_HOSTED_WEB_SEARCH_TYPES
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _openai_builtin_web_search_allowed(*, mode: str, adapter: str) -> bool:
|
|
836
|
+
normalized_mode = str(mode or "").strip().lower()
|
|
837
|
+
normalized_adapter = str(adapter or "").strip().lower() or AUTO_WEB_SEARCH_ADAPTER
|
|
838
|
+
if normalized_mode not in _WEB_SEARCH_MODES_ALLOWING_OPENAI_BUILTIN:
|
|
839
|
+
return False
|
|
840
|
+
return normalized_adapter in {AUTO_WEB_SEARCH_ADAPTER, OPENAI_RESPONSES_ADAPTER}
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
@dataclass(frozen=True)
|
|
844
|
+
class _ResponsesToolMapping:
|
|
845
|
+
tools: list[dict[str, Any]]
|
|
846
|
+
added_builtin_web_search: bool
|
|
847
|
+
removed_alysis_web_search: bool
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _responses_tools(
|
|
851
|
+
tools: list[dict[str, Any]] | None,
|
|
852
|
+
*,
|
|
853
|
+
mode: str,
|
|
854
|
+
adapter: str,
|
|
855
|
+
) -> _ResponsesToolMapping:
|
|
856
|
+
normalized_mode = str(mode or "off").strip().lower()
|
|
857
|
+
normalized_adapter = (
|
|
858
|
+
str(adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower() or AUTO_WEB_SEARCH_ADAPTER
|
|
859
|
+
)
|
|
860
|
+
raw_tools = [tool for tool in tools or [] if isinstance(tool, dict)]
|
|
861
|
+
alysis_web_search_present = any(_is_alysis_web_search_function(tool) for tool in raw_tools)
|
|
862
|
+
use_openai_builtin_web_search = (
|
|
863
|
+
alysis_web_search_present
|
|
864
|
+
and _openai_builtin_web_search_allowed(
|
|
865
|
+
mode=normalized_mode,
|
|
866
|
+
adapter=normalized_adapter,
|
|
867
|
+
)
|
|
868
|
+
)
|
|
869
|
+
if (
|
|
870
|
+
normalized_mode == "native"
|
|
871
|
+
and alysis_web_search_present
|
|
872
|
+
and not use_openai_builtin_web_search
|
|
873
|
+
):
|
|
874
|
+
raise LLMError(
|
|
875
|
+
"web_search_mode=native with protocol=openai_responses requires "
|
|
876
|
+
"web_search_adapter='auto' or 'openai_responses' for OpenAI hosted web_search; "
|
|
877
|
+
f"got {normalized_adapter!r}"
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
mapped_tools: list[dict[str, Any]] = []
|
|
881
|
+
removed_alysis_web_search = False
|
|
882
|
+
for tool in raw_tools:
|
|
883
|
+
if _is_alysis_web_search_function(tool):
|
|
884
|
+
if normalized_mode in {"off", "native"} or use_openai_builtin_web_search:
|
|
885
|
+
removed_alysis_web_search = True
|
|
886
|
+
continue
|
|
887
|
+
if _is_responses_hosted_web_search_tool(tool) and normalized_mode in {"off", "external"}:
|
|
888
|
+
continue
|
|
889
|
+
mapped = _responses_tool_from_chat_tool(tool)
|
|
890
|
+
if mapped is not None:
|
|
891
|
+
mapped_tools.append(mapped)
|
|
892
|
+
if use_openai_builtin_web_search and not any(
|
|
893
|
+
_is_responses_hosted_web_search_tool(tool) for tool in mapped_tools
|
|
894
|
+
):
|
|
895
|
+
mapped_tools.append({"type": "web_search", "external_web_access": True})
|
|
896
|
+
return _ResponsesToolMapping(
|
|
897
|
+
tools=mapped_tools,
|
|
898
|
+
added_builtin_web_search=use_openai_builtin_web_search,
|
|
899
|
+
removed_alysis_web_search=removed_alysis_web_search,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _tool_choice_for_mapped_tools(
|
|
904
|
+
tool_choice: Any,
|
|
905
|
+
*,
|
|
906
|
+
removed_alysis_web_search: bool,
|
|
907
|
+
) -> Any:
|
|
908
|
+
if not removed_alysis_web_search:
|
|
909
|
+
return _responses_tool_choice(tool_choice)
|
|
910
|
+
if isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "function":
|
|
911
|
+
if "name" in tool_choice:
|
|
912
|
+
name = str(tool_choice.get("name") or "").strip()
|
|
913
|
+
else:
|
|
914
|
+
function = tool_choice.get("function")
|
|
915
|
+
name = str(function.get("name") or "").strip() if isinstance(function, dict) else ""
|
|
916
|
+
if name == _ALYSIS_WEB_SEARCH_FUNCTION_NAME:
|
|
917
|
+
raise LLMError(
|
|
918
|
+
"OpenAI Responses removed the Alysis Code web_search function for the selected "
|
|
919
|
+
"web_search_mode; do not force tool_choice to function web_search"
|
|
920
|
+
)
|
|
921
|
+
return _responses_tool_choice(tool_choice)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _responses_tool_choice(tool_choice: Any) -> Any:
|
|
925
|
+
if tool_choice is None:
|
|
926
|
+
return None
|
|
927
|
+
if isinstance(tool_choice, str):
|
|
928
|
+
normalized = tool_choice.strip()
|
|
929
|
+
if normalized in _RESPONSES_TOOL_CHOICE_STRINGS:
|
|
930
|
+
return normalized
|
|
931
|
+
raise LLMError(f"OpenAI Responses does not support tool_choice={tool_choice!r}")
|
|
932
|
+
if not isinstance(tool_choice, dict):
|
|
933
|
+
raise LLMError("OpenAI Responses tool_choice must be a string or object")
|
|
934
|
+
|
|
935
|
+
choice_type = str(tool_choice.get("type") or "").strip()
|
|
936
|
+
if choice_type == "function":
|
|
937
|
+
if "name" in tool_choice:
|
|
938
|
+
name = str(tool_choice.get("name") or "").strip()
|
|
939
|
+
else:
|
|
940
|
+
function = tool_choice.get("function")
|
|
941
|
+
name = str(function.get("name") or "").strip() if isinstance(function, dict) else ""
|
|
942
|
+
if not name:
|
|
943
|
+
raise LLMError("OpenAI Responses forced function tool_choice is missing name")
|
|
944
|
+
return {"type": "function", "name": name}
|
|
945
|
+
if choice_type == "allowed_tools":
|
|
946
|
+
return copy.deepcopy(tool_choice)
|
|
947
|
+
if choice_type in {"web_search", "web_search_preview"}:
|
|
948
|
+
return copy.deepcopy(tool_choice)
|
|
949
|
+
raise LLMError(f"OpenAI Responses does not support tool_choice type {choice_type!r}")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _responses_text_config(response_format: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
953
|
+
if not response_format:
|
|
954
|
+
return None
|
|
955
|
+
if "format" in response_format and isinstance(response_format.get("format"), dict):
|
|
956
|
+
return copy.deepcopy(response_format)
|
|
957
|
+
|
|
958
|
+
response_type = str(response_format.get("type") or "").strip()
|
|
959
|
+
if response_type == "json_schema":
|
|
960
|
+
raw_json_schema = response_format.get("json_schema")
|
|
961
|
+
json_schema = raw_json_schema if isinstance(raw_json_schema, dict) else response_format
|
|
962
|
+
name = str(json_schema.get("name") or "").strip()
|
|
963
|
+
schema = json_schema.get("schema")
|
|
964
|
+
if not name or not _RESPONSES_JSON_SCHEMA_NAME_RE.fullmatch(name):
|
|
965
|
+
raise LLMError(
|
|
966
|
+
"OpenAI Responses json_schema response_format requires a valid name "
|
|
967
|
+
"(letters, digits, underscores, or dashes; max 64 chars)"
|
|
968
|
+
)
|
|
969
|
+
if not isinstance(schema, dict):
|
|
970
|
+
raise LLMError("OpenAI Responses json_schema response_format requires schema object")
|
|
971
|
+
fmt: dict[str, Any] = {
|
|
972
|
+
"type": "json_schema",
|
|
973
|
+
"name": name,
|
|
974
|
+
"schema": copy.deepcopy(schema),
|
|
975
|
+
}
|
|
976
|
+
description = str(json_schema.get("description") or "").strip()
|
|
977
|
+
if description:
|
|
978
|
+
fmt["description"] = description
|
|
979
|
+
if "strict" in json_schema:
|
|
980
|
+
fmt["strict"] = bool(json_schema.get("strict"))
|
|
981
|
+
return {"format": fmt}
|
|
982
|
+
if response_type in {"json_object", "text"}:
|
|
983
|
+
return {"format": {"type": response_type}}
|
|
984
|
+
raise LLMError(f"OpenAI Responses does not support response_format type {response_type!r}")
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _responses_reasoning(
|
|
988
|
+
*,
|
|
989
|
+
enable_thinking: bool | None,
|
|
990
|
+
reasoning_effort: str | None,
|
|
991
|
+
request_summary: bool = False,
|
|
992
|
+
) -> dict[str, Any] | None:
|
|
993
|
+
reasoning: dict[str, Any] = {}
|
|
994
|
+
effort = str(reasoning_effort or "").strip().lower()
|
|
995
|
+
if effort:
|
|
996
|
+
if effort not in _RESPONSES_REASONING_EFFORTS:
|
|
997
|
+
raise LLMError(f"OpenAI Responses reasoning_effort is not supported: {effort}")
|
|
998
|
+
reasoning["effort"] = effort
|
|
999
|
+
elif enable_thinking is False:
|
|
1000
|
+
reasoning["effort"] = "none"
|
|
1001
|
+
if request_summary:
|
|
1002
|
+
# Summary visibility is independent from reasoning effort. In
|
|
1003
|
+
# particular, an automatic/default effort must remain omitted while
|
|
1004
|
+
# still asking the provider for the best supported summary.
|
|
1005
|
+
reasoning["summary"] = "auto"
|
|
1006
|
+
return reasoning or None
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _parse_usage(raw: Any) -> LLMUsage | None:
|
|
1010
|
+
usage = parse_compatible_usage(raw, responses_shape=True)
|
|
1011
|
+
if usage is None:
|
|
1012
|
+
return None
|
|
1013
|
+
if (
|
|
1014
|
+
usage.prompt_tokens is None
|
|
1015
|
+
and usage.completion_tokens is None
|
|
1016
|
+
and usage.total_tokens is None
|
|
1017
|
+
and usage.cached_prompt_tokens is None
|
|
1018
|
+
and usage.cache_creation_input_tokens is None
|
|
1019
|
+
and usage.reasoning_tokens is None
|
|
1020
|
+
and usage.provider_cost_usd is None
|
|
1021
|
+
):
|
|
1022
|
+
return None
|
|
1023
|
+
return usage
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _citation_to_dict(citation: WebSearchCitation) -> dict[str, Any]:
|
|
1027
|
+
return {
|
|
1028
|
+
"title": citation.title,
|
|
1029
|
+
"url": citation.url,
|
|
1030
|
+
"start_index": citation.start_index,
|
|
1031
|
+
"end_index": citation.end_index,
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _source_to_dict(source: WebSearchSource) -> dict[str, Any]:
|
|
1036
|
+
return {"title": source.title, "url": source.url}
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _responses_provider_metadata(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
1040
|
+
metadata: dict[str, Any] = {}
|
|
1041
|
+
response_id = str(data.get("id") or "").strip()
|
|
1042
|
+
if response_id:
|
|
1043
|
+
metadata["response_id"] = response_id
|
|
1044
|
+
output = data.get("output")
|
|
1045
|
+
if isinstance(output, list):
|
|
1046
|
+
metadata["output_items"] = copy.deepcopy(output)
|
|
1047
|
+
web_search_calls = [
|
|
1048
|
+
copy.deepcopy(item)
|
|
1049
|
+
for item in output
|
|
1050
|
+
if isinstance(item, dict) and str(item.get("type") or "") == "web_search_call"
|
|
1051
|
+
]
|
|
1052
|
+
if web_search_calls:
|
|
1053
|
+
metadata["web_search_calls"] = web_search_calls
|
|
1054
|
+
citations = [_citation_to_dict(citation) for citation in _extract_citations(data)]
|
|
1055
|
+
if citations:
|
|
1056
|
+
metadata["citations"] = citations
|
|
1057
|
+
sources, queries = _extract_sources_and_queries(data)
|
|
1058
|
+
sources = _merge_citation_sources(sources, _extract_citations(data))
|
|
1059
|
+
if sources:
|
|
1060
|
+
metadata["sources"] = [_source_to_dict(source) for source in sources]
|
|
1061
|
+
if queries:
|
|
1062
|
+
metadata["queries"] = list(queries)
|
|
1063
|
+
stream_metadata = data.get("stream_metadata")
|
|
1064
|
+
if isinstance(stream_metadata, dict):
|
|
1065
|
+
metadata["stream_metadata"] = copy.deepcopy(stream_metadata)
|
|
1066
|
+
return {_OPENAI_RESPONSES_METADATA_KEY: metadata} if metadata else None
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _responses_reasoning_outputs(data: dict[str, Any]) -> tuple[ReasoningOutput, ...]:
|
|
1070
|
+
"""Extract provider-generated summaries, never opaque/raw reasoning state."""
|
|
1071
|
+
|
|
1072
|
+
output = data.get("output")
|
|
1073
|
+
if not isinstance(output, list):
|
|
1074
|
+
return ()
|
|
1075
|
+
seen: set[str] = set()
|
|
1076
|
+
summaries: list[ReasoningOutput] = []
|
|
1077
|
+
for item in output:
|
|
1078
|
+
if not isinstance(item, dict) or str(item.get("type") or "") != "reasoning":
|
|
1079
|
+
continue
|
|
1080
|
+
parts = item.get("summary")
|
|
1081
|
+
if not isinstance(parts, list):
|
|
1082
|
+
continue
|
|
1083
|
+
for part in parts:
|
|
1084
|
+
if not isinstance(part, dict):
|
|
1085
|
+
continue
|
|
1086
|
+
if str(part.get("type") or "") not in {"summary_text", "text"}:
|
|
1087
|
+
continue
|
|
1088
|
+
summary = part.get("text")
|
|
1089
|
+
if not isinstance(summary, str) or not summary.strip() or summary in seen:
|
|
1090
|
+
continue
|
|
1091
|
+
seen.add(summary)
|
|
1092
|
+
summaries.append(
|
|
1093
|
+
ReasoningOutput(
|
|
1094
|
+
text=summary,
|
|
1095
|
+
kind=ReasoningOutputKind.SUMMARY,
|
|
1096
|
+
provider="openai",
|
|
1097
|
+
)
|
|
1098
|
+
)
|
|
1099
|
+
return tuple(summaries)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _has_responses_reasoning_output(data: dict[str, Any]) -> bool:
|
|
1103
|
+
"""Return whether the provider produced a reasoning item of any visibility class."""
|
|
1104
|
+
|
|
1105
|
+
output = data.get("output")
|
|
1106
|
+
return isinstance(output, list) and any(
|
|
1107
|
+
isinstance(item, dict) and str(item.get("type") or "") == "reasoning" for item in output
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _extract_refusal(data: dict[str, Any]) -> str:
|
|
1112
|
+
output = data.get("output")
|
|
1113
|
+
if not isinstance(output, list):
|
|
1114
|
+
return ""
|
|
1115
|
+
refusals: list[str] = []
|
|
1116
|
+
for item in output:
|
|
1117
|
+
if not isinstance(item, dict):
|
|
1118
|
+
continue
|
|
1119
|
+
content = item.get("content")
|
|
1120
|
+
if not isinstance(content, list):
|
|
1121
|
+
continue
|
|
1122
|
+
for part in content:
|
|
1123
|
+
if not isinstance(part, dict):
|
|
1124
|
+
continue
|
|
1125
|
+
refusal = part.get("refusal")
|
|
1126
|
+
if isinstance(refusal, str) and refusal.strip():
|
|
1127
|
+
refusals.append(refusal.strip())
|
|
1128
|
+
if str(part.get("type") or "") == "refusal":
|
|
1129
|
+
text = part.get("text")
|
|
1130
|
+
if isinstance(text, str) and text.strip():
|
|
1131
|
+
refusals.append(text.strip())
|
|
1132
|
+
return "\n".join(refusals)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _parse_response_tool_calls(data: dict[str, Any]) -> list[ToolCall]:
|
|
1136
|
+
output = data.get("output")
|
|
1137
|
+
if not isinstance(output, list):
|
|
1138
|
+
return []
|
|
1139
|
+
tool_calls: list[ToolCall] = []
|
|
1140
|
+
for index, item in enumerate(output):
|
|
1141
|
+
if not isinstance(item, dict):
|
|
1142
|
+
continue
|
|
1143
|
+
if str(item.get("type") or "") != "function_call":
|
|
1144
|
+
continue
|
|
1145
|
+
call_id = str(item.get("call_id") or item.get("id") or f"call_{index}").strip()
|
|
1146
|
+
name = str(item.get("name") or "").strip()
|
|
1147
|
+
if not name:
|
|
1148
|
+
continue
|
|
1149
|
+
provider_metadata: dict[str, Any] = {
|
|
1150
|
+
_OPENAI_RESPONSES_METADATA_KEY: {
|
|
1151
|
+
"item_id": item.get("id"),
|
|
1152
|
+
"output_index": index,
|
|
1153
|
+
"status": item.get("status"),
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
tool_calls.append(
|
|
1157
|
+
ToolCall(
|
|
1158
|
+
id=call_id,
|
|
1159
|
+
name=name,
|
|
1160
|
+
arguments=_parse_arguments(item.get("arguments") or "{}"),
|
|
1161
|
+
provider_metadata=provider_metadata,
|
|
1162
|
+
)
|
|
1163
|
+
)
|
|
1164
|
+
return tool_calls
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _response_from_json(data: dict[str, Any]) -> httpx.Response:
|
|
1168
|
+
return httpx.Response(200, json=data)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _event_output_index(data: dict[str, Any], *, event_type: str) -> int:
|
|
1172
|
+
index = data.get("output_index")
|
|
1173
|
+
if isinstance(index, int) and index >= 0:
|
|
1174
|
+
return index
|
|
1175
|
+
raise LLMError(f"OpenAI Responses stream {event_type} event is missing output_index")
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _event_content_index(data: dict[str, Any]) -> int:
|
|
1179
|
+
index = data.get("content_index")
|
|
1180
|
+
if isinstance(index, int) and index >= 0:
|
|
1181
|
+
return index
|
|
1182
|
+
return 0
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _stream_error_message(data: dict[str, Any]) -> str:
|
|
1186
|
+
message = _extract_error_message(data)
|
|
1187
|
+
if message:
|
|
1188
|
+
return message
|
|
1189
|
+
response = data.get("response")
|
|
1190
|
+
if isinstance(response, dict):
|
|
1191
|
+
message = _extract_error_message(response)
|
|
1192
|
+
if message:
|
|
1193
|
+
return message
|
|
1194
|
+
status = str(response.get("status") or "").strip()
|
|
1195
|
+
incomplete = response.get("incomplete_details")
|
|
1196
|
+
if isinstance(incomplete, dict):
|
|
1197
|
+
reason = str(incomplete.get("reason") or "").strip()
|
|
1198
|
+
if reason:
|
|
1199
|
+
return f"status={status or 'incomplete'} reason={reason}"
|
|
1200
|
+
if status:
|
|
1201
|
+
return f"status={status}"
|
|
1202
|
+
return repr(data)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
class _OpenAIResponsesStreamAccumulator:
|
|
1206
|
+
def __init__(
|
|
1207
|
+
self,
|
|
1208
|
+
*,
|
|
1209
|
+
on_text_delta: Callable[[str], None] | None,
|
|
1210
|
+
on_reasoning_delta: Callable[[str], None] | None,
|
|
1211
|
+
) -> None:
|
|
1212
|
+
self.on_text_delta = on_text_delta
|
|
1213
|
+
self.on_reasoning_delta = on_reasoning_delta
|
|
1214
|
+
self.response: dict[str, Any] = {"object": "response", "output": []}
|
|
1215
|
+
self.output_items: dict[int, dict[str, Any]] = {}
|
|
1216
|
+
self.text_parts: dict[tuple[int, int], dict[str, Any]] = {}
|
|
1217
|
+
self.reasoning_summary_parts: dict[tuple[int, int], dict[str, Any]] = {}
|
|
1218
|
+
self.reasoning_delta_keys: set[tuple[int, int] | None] = set()
|
|
1219
|
+
self.argument_chunks: dict[int, list[str]] = {}
|
|
1220
|
+
self.unknown_events: list[dict[str, Any]] = []
|
|
1221
|
+
self.event_count = 0
|
|
1222
|
+
self.text_delta_seen = False
|
|
1223
|
+
self.seen_final = False
|
|
1224
|
+
self.final_response: dict[str, Any] | None = None
|
|
1225
|
+
|
|
1226
|
+
def handle(self, frame: SSEFrame, data: dict[str, Any]) -> None:
|
|
1227
|
+
event_type = str(data.get("type") or frame.event or "").strip()
|
|
1228
|
+
if not event_type:
|
|
1229
|
+
self._append_unknown(frame=frame, data=data)
|
|
1230
|
+
return
|
|
1231
|
+
self.event_count += 1
|
|
1232
|
+
|
|
1233
|
+
if event_type == "error":
|
|
1234
|
+
raise LLMError(f"OpenAI Responses stream error: {_stream_error_message(data)}")
|
|
1235
|
+
if event_type in {"response.failed", "response.incomplete"}:
|
|
1236
|
+
raise LLMError(f"OpenAI Responses stream {event_type}: {_stream_error_message(data)}")
|
|
1237
|
+
if event_type in {"response.created", "response.in_progress", "response.queued"}:
|
|
1238
|
+
self._merge_response(data.get("response"))
|
|
1239
|
+
return
|
|
1240
|
+
if event_type in {"response.completed", "response.done"}:
|
|
1241
|
+
self._handle_final_response(data)
|
|
1242
|
+
return
|
|
1243
|
+
if event_type in {"response.output_item.added", "response.output_item.done"}:
|
|
1244
|
+
self._handle_output_item(data)
|
|
1245
|
+
return
|
|
1246
|
+
if event_type in {"response.content_part.added", "response.content_part.done"}:
|
|
1247
|
+
self._handle_content_part(data)
|
|
1248
|
+
return
|
|
1249
|
+
if event_type == "response.output_text.delta":
|
|
1250
|
+
self._handle_output_text_delta(data)
|
|
1251
|
+
return
|
|
1252
|
+
if event_type == "response.output_text.done":
|
|
1253
|
+
self._handle_output_text_done(data)
|
|
1254
|
+
return
|
|
1255
|
+
if event_type == "response.output_text.annotation.added":
|
|
1256
|
+
self._handle_output_text_annotation(data)
|
|
1257
|
+
return
|
|
1258
|
+
if event_type in {
|
|
1259
|
+
"response.reasoning_summary_text.delta",
|
|
1260
|
+
# Compatibility event emitted by older/private Responses surfaces.
|
|
1261
|
+
"response.reasoning_summary.delta",
|
|
1262
|
+
}:
|
|
1263
|
+
self._handle_reasoning_summary_delta(data)
|
|
1264
|
+
return
|
|
1265
|
+
if event_type in {
|
|
1266
|
+
"response.reasoning_summary_text.done",
|
|
1267
|
+
"response.reasoning_summary.done",
|
|
1268
|
+
}:
|
|
1269
|
+
self._handle_reasoning_summary_done(data)
|
|
1270
|
+
return
|
|
1271
|
+
if event_type in {
|
|
1272
|
+
"response.reasoning_summary_part.added",
|
|
1273
|
+
"response.reasoning_summary_part.done",
|
|
1274
|
+
}:
|
|
1275
|
+
self._handle_reasoning_summary_part(data, done=event_type.endswith(".done"))
|
|
1276
|
+
return
|
|
1277
|
+
if event_type == "response.function_call_arguments.delta":
|
|
1278
|
+
self._handle_function_arguments_delta(data)
|
|
1279
|
+
return
|
|
1280
|
+
if event_type == "response.function_call_arguments.done":
|
|
1281
|
+
self._handle_function_arguments_done(data)
|
|
1282
|
+
return
|
|
1283
|
+
if event_type.startswith("response.web_search_call."):
|
|
1284
|
+
self._handle_web_search_call_state(event_type, data)
|
|
1285
|
+
return
|
|
1286
|
+
|
|
1287
|
+
self._append_unknown(frame=frame, data=data)
|
|
1288
|
+
|
|
1289
|
+
def finish(self) -> dict[str, Any]:
|
|
1290
|
+
if self.event_count <= 0:
|
|
1291
|
+
raise LLMError("OpenAI Responses stream returned no events")
|
|
1292
|
+
data = copy.deepcopy(self.final_response or self.response)
|
|
1293
|
+
output = data.get("output")
|
|
1294
|
+
if not isinstance(output, list) or not output:
|
|
1295
|
+
output = self._ordered_output_items()
|
|
1296
|
+
data["output"] = output
|
|
1297
|
+
elif self.output_items:
|
|
1298
|
+
data["output"] = self._merge_ordered_items(output)
|
|
1299
|
+
reasoning_only_partial = bool(output) and all(
|
|
1300
|
+
isinstance(item, dict) and str(item.get("type") or "") == "reasoning" for item in output
|
|
1301
|
+
)
|
|
1302
|
+
if not self.seen_final and not reasoning_only_partial:
|
|
1303
|
+
raise LLMError("OpenAI Responses stream ended before response.completed")
|
|
1304
|
+
if "output_text" not in data:
|
|
1305
|
+
text = _extract_answer_text(data)
|
|
1306
|
+
if text:
|
|
1307
|
+
data["output_text"] = text
|
|
1308
|
+
stream_metadata: dict[str, Any] = {"events": self.event_count}
|
|
1309
|
+
if not self.seen_final:
|
|
1310
|
+
stream_metadata["ended_before_response_completed"] = True
|
|
1311
|
+
if self.unknown_events:
|
|
1312
|
+
stream_metadata["unknown_events"] = copy.deepcopy(self.unknown_events)
|
|
1313
|
+
data["stream_metadata"] = stream_metadata
|
|
1314
|
+
return data
|
|
1315
|
+
|
|
1316
|
+
def _merge_response(self, raw_response: Any) -> None:
|
|
1317
|
+
if not isinstance(raw_response, dict):
|
|
1318
|
+
return
|
|
1319
|
+
response = copy.deepcopy(raw_response)
|
|
1320
|
+
output = response.pop("output", None)
|
|
1321
|
+
self.response.update(response)
|
|
1322
|
+
if isinstance(output, list):
|
|
1323
|
+
for index, item in enumerate(output):
|
|
1324
|
+
if isinstance(item, dict):
|
|
1325
|
+
self._set_output_item(index, item)
|
|
1326
|
+
|
|
1327
|
+
def _handle_final_response(self, data: dict[str, Any]) -> None:
|
|
1328
|
+
self._merge_response(data.get("response"))
|
|
1329
|
+
response = data.get("response")
|
|
1330
|
+
self.final_response = (
|
|
1331
|
+
copy.deepcopy(response) if isinstance(response, dict) else copy.deepcopy(self.response)
|
|
1332
|
+
)
|
|
1333
|
+
self.seen_final = True
|
|
1334
|
+
|
|
1335
|
+
def _handle_output_item(self, data: dict[str, Any]) -> None:
|
|
1336
|
+
index = _event_output_index(data, event_type=str(data.get("type") or "output_item"))
|
|
1337
|
+
item = data.get("item")
|
|
1338
|
+
if not isinstance(item, dict):
|
|
1339
|
+
return
|
|
1340
|
+
self._set_output_item(index, item)
|
|
1341
|
+
|
|
1342
|
+
def _set_output_item(self, index: int, item: dict[str, Any]) -> dict[str, Any]:
|
|
1343
|
+
copied = copy.deepcopy(item)
|
|
1344
|
+
existing = self.output_items.get(index)
|
|
1345
|
+
if existing is not None:
|
|
1346
|
+
merged = copy.deepcopy(existing)
|
|
1347
|
+
merged.update(copied)
|
|
1348
|
+
copied = merged
|
|
1349
|
+
self.output_items[index] = copied
|
|
1350
|
+
return copied
|
|
1351
|
+
|
|
1352
|
+
def _ensure_message_item(self, output_index: int, item_id: str | None = None) -> dict[str, Any]:
|
|
1353
|
+
item = self.output_items.get(output_index)
|
|
1354
|
+
if not isinstance(item, dict):
|
|
1355
|
+
item = {
|
|
1356
|
+
"type": "message",
|
|
1357
|
+
"role": "assistant",
|
|
1358
|
+
"content": [],
|
|
1359
|
+
}
|
|
1360
|
+
if item_id:
|
|
1361
|
+
item["id"] = item_id
|
|
1362
|
+
self.output_items[output_index] = item
|
|
1363
|
+
else:
|
|
1364
|
+
item.setdefault("type", "message")
|
|
1365
|
+
item.setdefault("role", "assistant")
|
|
1366
|
+
if item_id and not item.get("id"):
|
|
1367
|
+
item["id"] = item_id
|
|
1368
|
+
content = item.get("content")
|
|
1369
|
+
if not isinstance(content, list):
|
|
1370
|
+
item["content"] = []
|
|
1371
|
+
return item
|
|
1372
|
+
|
|
1373
|
+
def _ensure_text_part(
|
|
1374
|
+
self, output_index: int, content_index: int, item_id: str
|
|
1375
|
+
) -> dict[str, Any]:
|
|
1376
|
+
key = (output_index, content_index)
|
|
1377
|
+
part = self.text_parts.get(key)
|
|
1378
|
+
if part is None:
|
|
1379
|
+
part = {"type": "output_text", "text": ""}
|
|
1380
|
+
self.text_parts[key] = part
|
|
1381
|
+
item = self._ensure_message_item(output_index, item_id)
|
|
1382
|
+
content = item.setdefault("content", [])
|
|
1383
|
+
if isinstance(content, list):
|
|
1384
|
+
while len(content) <= content_index:
|
|
1385
|
+
content.append({"type": "output_text", "text": ""})
|
|
1386
|
+
content[content_index] = part
|
|
1387
|
+
return part
|
|
1388
|
+
|
|
1389
|
+
def _handle_content_part(self, data: dict[str, Any]) -> None:
|
|
1390
|
+
output_index = _event_output_index(data, event_type=str(data.get("type") or "content_part"))
|
|
1391
|
+
content_index = _event_content_index(data)
|
|
1392
|
+
item_id = str(data.get("item_id") or "").strip()
|
|
1393
|
+
raw_part = data.get("part")
|
|
1394
|
+
if not isinstance(raw_part, dict):
|
|
1395
|
+
return
|
|
1396
|
+
item = self._ensure_message_item(output_index, item_id or None)
|
|
1397
|
+
content = item.setdefault("content", [])
|
|
1398
|
+
if not isinstance(content, list):
|
|
1399
|
+
content = []
|
|
1400
|
+
item["content"] = content
|
|
1401
|
+
while len(content) <= content_index:
|
|
1402
|
+
content.append({"type": "output_text", "text": ""})
|
|
1403
|
+
part = copy.deepcopy(raw_part)
|
|
1404
|
+
if str(part.get("type") or "") in {"text", "output_text"}:
|
|
1405
|
+
part["type"] = "output_text"
|
|
1406
|
+
part.setdefault("text", "")
|
|
1407
|
+
self.text_parts[(output_index, content_index)] = part
|
|
1408
|
+
content[content_index] = part
|
|
1409
|
+
|
|
1410
|
+
def _handle_output_text_delta(self, data: dict[str, Any]) -> None:
|
|
1411
|
+
output_index = _event_output_index(data, event_type="response.output_text.delta")
|
|
1412
|
+
content_index = _event_content_index(data)
|
|
1413
|
+
item_id = str(data.get("item_id") or "").strip()
|
|
1414
|
+
delta = data.get("delta")
|
|
1415
|
+
if not isinstance(delta, str) or not delta:
|
|
1416
|
+
return
|
|
1417
|
+
part = self._ensure_text_part(output_index, content_index, item_id)
|
|
1418
|
+
existing = part.get("text")
|
|
1419
|
+
part["text"] = (existing if isinstance(existing, str) else "") + delta
|
|
1420
|
+
self.text_delta_seen = True
|
|
1421
|
+
if self.on_text_delta is not None:
|
|
1422
|
+
self.on_text_delta(delta)
|
|
1423
|
+
|
|
1424
|
+
def _handle_output_text_done(self, data: dict[str, Any]) -> None:
|
|
1425
|
+
output_index = _event_output_index(data, event_type="response.output_text.done")
|
|
1426
|
+
content_index = _event_content_index(data)
|
|
1427
|
+
item_id = str(data.get("item_id") or "").strip()
|
|
1428
|
+
text = data.get("text")
|
|
1429
|
+
if not isinstance(text, str):
|
|
1430
|
+
return
|
|
1431
|
+
part = self._ensure_text_part(output_index, content_index, item_id)
|
|
1432
|
+
part["text"] = text
|
|
1433
|
+
part["type"] = "output_text"
|
|
1434
|
+
|
|
1435
|
+
def _handle_output_text_annotation(self, data: dict[str, Any]) -> None:
|
|
1436
|
+
output_index = _event_output_index(
|
|
1437
|
+
data,
|
|
1438
|
+
event_type="response.output_text.annotation.added",
|
|
1439
|
+
)
|
|
1440
|
+
content_index = _event_content_index(data)
|
|
1441
|
+
item_id = str(data.get("item_id") or "").strip()
|
|
1442
|
+
annotation = data.get("annotation")
|
|
1443
|
+
if not isinstance(annotation, dict):
|
|
1444
|
+
return
|
|
1445
|
+
part = self._ensure_text_part(output_index, content_index, item_id)
|
|
1446
|
+
annotations = part.setdefault("annotations", [])
|
|
1447
|
+
if isinstance(annotations, list):
|
|
1448
|
+
annotations.append(copy.deepcopy(annotation))
|
|
1449
|
+
|
|
1450
|
+
@staticmethod
|
|
1451
|
+
def _reasoning_indices(data: dict[str, Any]) -> tuple[int, int] | None:
|
|
1452
|
+
output_index = data.get("output_index")
|
|
1453
|
+
summary_index = data.get("summary_index")
|
|
1454
|
+
if not isinstance(output_index, int) or output_index < 0:
|
|
1455
|
+
return None
|
|
1456
|
+
if not isinstance(summary_index, int) or summary_index < 0:
|
|
1457
|
+
summary_index = 0
|
|
1458
|
+
return output_index, summary_index
|
|
1459
|
+
|
|
1460
|
+
def _ensure_reasoning_summary_part(
|
|
1461
|
+
self,
|
|
1462
|
+
*,
|
|
1463
|
+
output_index: int,
|
|
1464
|
+
summary_index: int,
|
|
1465
|
+
item_id: str,
|
|
1466
|
+
) -> dict[str, Any] | None:
|
|
1467
|
+
key = (output_index, summary_index)
|
|
1468
|
+
existing_part = self.reasoning_summary_parts.get(key)
|
|
1469
|
+
if existing_part is not None:
|
|
1470
|
+
return existing_part
|
|
1471
|
+
|
|
1472
|
+
item = self.output_items.get(output_index)
|
|
1473
|
+
if item is None:
|
|
1474
|
+
item = {"type": "reasoning", "summary": []}
|
|
1475
|
+
if item_id:
|
|
1476
|
+
item["id"] = item_id
|
|
1477
|
+
self.output_items[output_index] = item
|
|
1478
|
+
elif str(item.get("type") or "") not in {"", "reasoning"}:
|
|
1479
|
+
# Malformed/colliding provider events must not overwrite another
|
|
1480
|
+
# output item merely to reconstruct optional reasoning metadata.
|
|
1481
|
+
return None
|
|
1482
|
+
else:
|
|
1483
|
+
item.setdefault("type", "reasoning")
|
|
1484
|
+
if item_id and not item.get("id"):
|
|
1485
|
+
item["id"] = item_id
|
|
1486
|
+
|
|
1487
|
+
summary = item.get("summary")
|
|
1488
|
+
if not isinstance(summary, list):
|
|
1489
|
+
summary = []
|
|
1490
|
+
item["summary"] = summary
|
|
1491
|
+
while len(summary) <= summary_index:
|
|
1492
|
+
summary.append({"type": "summary_text", "text": ""})
|
|
1493
|
+
part = summary[summary_index]
|
|
1494
|
+
if not isinstance(part, dict):
|
|
1495
|
+
part = {"type": "summary_text", "text": ""}
|
|
1496
|
+
summary[summary_index] = part
|
|
1497
|
+
else:
|
|
1498
|
+
part.setdefault("type", "summary_text")
|
|
1499
|
+
part.setdefault("text", "")
|
|
1500
|
+
self.reasoning_summary_parts[key] = part
|
|
1501
|
+
return part
|
|
1502
|
+
|
|
1503
|
+
def _reasoning_part_for_event(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
|
1504
|
+
indices = self._reasoning_indices(data)
|
|
1505
|
+
if indices is None:
|
|
1506
|
+
return None
|
|
1507
|
+
output_index, summary_index = indices
|
|
1508
|
+
return self._ensure_reasoning_summary_part(
|
|
1509
|
+
output_index=output_index,
|
|
1510
|
+
summary_index=summary_index,
|
|
1511
|
+
item_id=str(data.get("item_id") or "").strip(),
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
def _handle_reasoning_summary_delta(self, data: dict[str, Any]) -> None:
|
|
1515
|
+
delta = data.get("delta")
|
|
1516
|
+
if not isinstance(delta, str) or not delta:
|
|
1517
|
+
return
|
|
1518
|
+
key = self._reasoning_indices(data)
|
|
1519
|
+
part = self._reasoning_part_for_event(data)
|
|
1520
|
+
if part is not None:
|
|
1521
|
+
existing = part.get("text")
|
|
1522
|
+
part["text"] = (existing if isinstance(existing, str) else "") + delta
|
|
1523
|
+
self.reasoning_delta_keys.add(key)
|
|
1524
|
+
if self.on_reasoning_delta is not None:
|
|
1525
|
+
self.on_reasoning_delta(delta)
|
|
1526
|
+
|
|
1527
|
+
def _handle_reasoning_summary_done(self, data: dict[str, Any]) -> None:
|
|
1528
|
+
text = data.get("text")
|
|
1529
|
+
if not isinstance(text, str):
|
|
1530
|
+
return
|
|
1531
|
+
key = self._reasoning_indices(data)
|
|
1532
|
+
part = self._reasoning_part_for_event(data)
|
|
1533
|
+
if part is not None:
|
|
1534
|
+
part["type"] = "summary_text"
|
|
1535
|
+
part["text"] = text
|
|
1536
|
+
# Some compatible providers send only the completed summary event. It is
|
|
1537
|
+
# still genuine provider output, so surface it once, but never inject it
|
|
1538
|
+
# after visible answer text has already started or duplicate prior deltas.
|
|
1539
|
+
if (
|
|
1540
|
+
text
|
|
1541
|
+
and self.on_reasoning_delta is not None
|
|
1542
|
+
and key not in self.reasoning_delta_keys
|
|
1543
|
+
and None not in self.reasoning_delta_keys
|
|
1544
|
+
and not self.text_delta_seen
|
|
1545
|
+
):
|
|
1546
|
+
self.reasoning_delta_keys.add(key)
|
|
1547
|
+
self.on_reasoning_delta(text)
|
|
1548
|
+
|
|
1549
|
+
def _handle_reasoning_summary_part(self, data: dict[str, Any], *, done: bool) -> None:
|
|
1550
|
+
raw_part = data.get("part")
|
|
1551
|
+
if not isinstance(raw_part, dict):
|
|
1552
|
+
return
|
|
1553
|
+
part = self._reasoning_part_for_event(data)
|
|
1554
|
+
if part is None:
|
|
1555
|
+
return
|
|
1556
|
+
part.update(copy.deepcopy(raw_part))
|
|
1557
|
+
part["type"] = "summary_text"
|
|
1558
|
+
text = raw_part.get("text")
|
|
1559
|
+
if done and isinstance(text, str):
|
|
1560
|
+
completed = dict(data)
|
|
1561
|
+
completed["text"] = text
|
|
1562
|
+
self._handle_reasoning_summary_done(completed)
|
|
1563
|
+
|
|
1564
|
+
def _handle_function_arguments_delta(self, data: dict[str, Any]) -> None:
|
|
1565
|
+
output_index = _event_output_index(
|
|
1566
|
+
data,
|
|
1567
|
+
event_type="response.function_call_arguments.delta",
|
|
1568
|
+
)
|
|
1569
|
+
delta = data.get("delta")
|
|
1570
|
+
if isinstance(delta, str):
|
|
1571
|
+
self.argument_chunks.setdefault(output_index, []).append(delta)
|
|
1572
|
+
item = self.output_items.get(output_index)
|
|
1573
|
+
if isinstance(item, dict) and str(item.get("type") or "") == "function_call":
|
|
1574
|
+
existing = item.get("arguments")
|
|
1575
|
+
item["arguments"] = (existing if isinstance(existing, str) else "") + (
|
|
1576
|
+
delta if isinstance(delta, str) else ""
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
def _handle_function_arguments_done(self, data: dict[str, Any]) -> None:
|
|
1580
|
+
output_index = _event_output_index(
|
|
1581
|
+
data,
|
|
1582
|
+
event_type="response.function_call_arguments.done",
|
|
1583
|
+
)
|
|
1584
|
+
item = self.output_items.get(output_index)
|
|
1585
|
+
if not isinstance(item, dict):
|
|
1586
|
+
item = {"type": "function_call"}
|
|
1587
|
+
self.output_items[output_index] = item
|
|
1588
|
+
item["type"] = "function_call"
|
|
1589
|
+
for key in ("item_id", "call_id", "name", "status"):
|
|
1590
|
+
value = data.get(key)
|
|
1591
|
+
if value is not None:
|
|
1592
|
+
item["id" if key == "item_id" else key] = copy.deepcopy(value)
|
|
1593
|
+
arguments = data.get("arguments")
|
|
1594
|
+
if isinstance(arguments, str):
|
|
1595
|
+
item["arguments"] = arguments
|
|
1596
|
+
elif output_index in self.argument_chunks:
|
|
1597
|
+
item["arguments"] = "".join(self.argument_chunks[output_index])
|
|
1598
|
+
|
|
1599
|
+
def _handle_web_search_call_state(self, event_type: str, data: dict[str, Any]) -> None:
|
|
1600
|
+
try:
|
|
1601
|
+
output_index = _event_output_index(data, event_type=event_type)
|
|
1602
|
+
except LLMError:
|
|
1603
|
+
self._append_unknown(frame=SSEFrame(event=event_type, data=json.dumps(data)), data=data)
|
|
1604
|
+
return
|
|
1605
|
+
item = self.output_items.get(output_index)
|
|
1606
|
+
if not isinstance(item, dict):
|
|
1607
|
+
item = {"type": "web_search_call"}
|
|
1608
|
+
self.output_items[output_index] = item
|
|
1609
|
+
item["type"] = "web_search_call"
|
|
1610
|
+
item_id = data.get("item_id")
|
|
1611
|
+
if isinstance(item_id, str) and item_id.strip():
|
|
1612
|
+
item["id"] = item_id
|
|
1613
|
+
status = event_type.rsplit(".", 1)[-1]
|
|
1614
|
+
if status:
|
|
1615
|
+
item["status"] = status
|
|
1616
|
+
for key in ("action", "results"):
|
|
1617
|
+
value = data.get(key)
|
|
1618
|
+
if value is not None:
|
|
1619
|
+
item[key] = copy.deepcopy(value)
|
|
1620
|
+
|
|
1621
|
+
def _ordered_output_items(self) -> list[dict[str, Any]]:
|
|
1622
|
+
return [
|
|
1623
|
+
copy.deepcopy(item)
|
|
1624
|
+
for _index, item in sorted(self.output_items.items(), key=lambda pair: pair[0])
|
|
1625
|
+
if isinstance(item, dict)
|
|
1626
|
+
]
|
|
1627
|
+
|
|
1628
|
+
def _merge_ordered_items(self, output: list[Any]) -> list[dict[str, Any]]:
|
|
1629
|
+
merged: dict[int, dict[str, Any]] = {}
|
|
1630
|
+
for index, item in enumerate(output):
|
|
1631
|
+
if isinstance(item, dict):
|
|
1632
|
+
merged[index] = copy.deepcopy(item)
|
|
1633
|
+
for index, item in self.output_items.items():
|
|
1634
|
+
if not isinstance(item, dict):
|
|
1635
|
+
continue
|
|
1636
|
+
if index in merged:
|
|
1637
|
+
copied = copy.deepcopy(item)
|
|
1638
|
+
copied.update(copy.deepcopy(merged[index]))
|
|
1639
|
+
merged[index] = copied
|
|
1640
|
+
else:
|
|
1641
|
+
merged[index] = copy.deepcopy(item)
|
|
1642
|
+
return [item for _index, item in sorted(merged.items(), key=lambda pair: pair[0])]
|
|
1643
|
+
|
|
1644
|
+
def _append_unknown(self, *, frame: SSEFrame, data: dict[str, Any]) -> None:
|
|
1645
|
+
self.unknown_events.append(
|
|
1646
|
+
{
|
|
1647
|
+
"event": frame.event,
|
|
1648
|
+
"data": copy.deepcopy(data),
|
|
1649
|
+
}
|
|
1650
|
+
)
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
class OpenAIResponsesClient:
|
|
1654
|
+
# Requests may continue server-side via previous_response_id. Replaying the
|
|
1655
|
+
# visible messages is a different cache stream and cannot refresh that prefix.
|
|
1656
|
+
cache_keepalive_transport = "response_continuation"
|
|
1657
|
+
usage_contract = UsageContract(
|
|
1658
|
+
response_usage_confidence=UsageConfidence.AUTHORITATIVE,
|
|
1659
|
+
input_token_count_strategy="openai_responses",
|
|
1660
|
+
)
|
|
1661
|
+
supports_tool_calling = True
|
|
1662
|
+
supports_forced_tool_choice = True
|
|
1663
|
+
usage_counts_authoritative = usage_contract.response_usage_authoritative
|
|
1664
|
+
|
|
1665
|
+
def __init__(
|
|
1666
|
+
self,
|
|
1667
|
+
*,
|
|
1668
|
+
base_url: str,
|
|
1669
|
+
api_key: str,
|
|
1670
|
+
model: str,
|
|
1671
|
+
timeout_s: float = 20.0,
|
|
1672
|
+
temperature: float = 1.0,
|
|
1673
|
+
prompt_cache_key: str | None = None,
|
|
1674
|
+
prompt_cache_retention: str | None = None,
|
|
1675
|
+
enable_thinking: bool | None = None,
|
|
1676
|
+
reasoning_effort: str | None = None,
|
|
1677
|
+
transport: httpx.BaseTransport | None = None,
|
|
1678
|
+
extra_headers: dict[str, str] | None = None,
|
|
1679
|
+
provider_key: str | None = None,
|
|
1680
|
+
web_search_mode: str = "off",
|
|
1681
|
+
web_search_adapter: str = AUTO_WEB_SEARCH_ADAPTER,
|
|
1682
|
+
provider_concurrency_caps: dict[str, int] | None = None,
|
|
1683
|
+
provider_retry_settings: ProviderRetrySettings | None = None,
|
|
1684
|
+
provider_sleep_fn: Callable[[float], None] | None = None,
|
|
1685
|
+
provider_random_fn: Callable[[], float] | None = None,
|
|
1686
|
+
prompt_cache_policy_metadata: Mapping[str, Any] | None = None,
|
|
1687
|
+
provider_auth: ProviderAuthAdapter | None = None,
|
|
1688
|
+
session_id: str | None = None,
|
|
1689
|
+
usage_contract: UsageContract | None = None,
|
|
1690
|
+
route_identity: ProviderRouteIdentity | None = None,
|
|
1691
|
+
) -> None:
|
|
1692
|
+
self.base_url = base_url.rstrip("/")
|
|
1693
|
+
self.api_key = api_key
|
|
1694
|
+
self.model = model
|
|
1695
|
+
self.timeout_s = timeout_s
|
|
1696
|
+
self.temperature = temperature
|
|
1697
|
+
self.prompt_cache_key = str(prompt_cache_key or "").strip() or None
|
|
1698
|
+
self.prompt_cache_retention = str(prompt_cache_retention or "").strip() or None
|
|
1699
|
+
self.enable_thinking = enable_thinking
|
|
1700
|
+
self.reasoning_effort = str(reasoning_effort or "").strip().lower() or None
|
|
1701
|
+
self._transport = transport
|
|
1702
|
+
self.extra_headers = canonicalize_extra_headers(extra_headers)
|
|
1703
|
+
self.provider_key = str(provider_key or "").strip() or None
|
|
1704
|
+
self.route_identity = route_identity or build_provider_route_identity(
|
|
1705
|
+
protocol="openai_responses",
|
|
1706
|
+
base_url=self.base_url,
|
|
1707
|
+
provider_key=self.provider_key,
|
|
1708
|
+
model=self.model,
|
|
1709
|
+
credential_scope=credential_scope_fingerprint(self.api_key),
|
|
1710
|
+
routing_headers=self.extra_headers,
|
|
1711
|
+
session_scope=credential_scope_fingerprint(session_id),
|
|
1712
|
+
)
|
|
1713
|
+
self.web_search_mode = str(web_search_mode or "off").strip().lower()
|
|
1714
|
+
self.web_search_adapter = (
|
|
1715
|
+
str(web_search_adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower()
|
|
1716
|
+
or AUTO_WEB_SEARCH_ADAPTER
|
|
1717
|
+
)
|
|
1718
|
+
self.provider_concurrency_caps = dict(
|
|
1719
|
+
DEFAULT_PROVIDER_CONCURRENCY_CAPS
|
|
1720
|
+
if provider_concurrency_caps is None
|
|
1721
|
+
else provider_concurrency_caps
|
|
1722
|
+
)
|
|
1723
|
+
self.provider_retry_settings = provider_retry_settings or ProviderRetrySettings()
|
|
1724
|
+
self._provider_sleep_fn = provider_sleep_fn
|
|
1725
|
+
self._provider_random_fn = provider_random_fn
|
|
1726
|
+
self.prompt_cache_policy_metadata = (
|
|
1727
|
+
copy.deepcopy(dict(prompt_cache_policy_metadata))
|
|
1728
|
+
if isinstance(prompt_cache_policy_metadata, Mapping)
|
|
1729
|
+
else None
|
|
1730
|
+
)
|
|
1731
|
+
self.provider_auth = provider_auth
|
|
1732
|
+
self.session_id = str(session_id or "").strip() or None
|
|
1733
|
+
self.usage_contract = usage_contract or type(self).usage_contract
|
|
1734
|
+
self.usage_counts_authoritative = self.usage_contract.response_usage_authoritative
|
|
1735
|
+
self._input_token_count_available: bool | None = None
|
|
1736
|
+
self._reasoning_summary_support_by_model: dict[str, bool] = {}
|
|
1737
|
+
self._provider_retry_wall_clock_cap_seconds = _PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS
|
|
1738
|
+
|
|
1739
|
+
def _reasoning_summary_support_key(self) -> str:
|
|
1740
|
+
return _responses_temperature_omit_key(self.base_url, self.model)
|
|
1741
|
+
|
|
1742
|
+
def _should_request_reasoning_summary(self) -> bool:
|
|
1743
|
+
capability = getattr(self, "reasoning_trace_capability", None)
|
|
1744
|
+
if self.enable_thinking is False:
|
|
1745
|
+
return False
|
|
1746
|
+
if not bool(getattr(capability, "requestable", False)):
|
|
1747
|
+
return False
|
|
1748
|
+
if not bool(getattr(capability, "has_safe_summary", False)):
|
|
1749
|
+
return False
|
|
1750
|
+
return (
|
|
1751
|
+
self._reasoning_summary_support_by_model.get(self._reasoning_summary_support_key())
|
|
1752
|
+
is not False
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
def _headers(self, url: str, *, force_refresh: bool = False) -> dict[str, str]:
|
|
1756
|
+
headers = {
|
|
1757
|
+
"Content-Type": "application/json",
|
|
1758
|
+
"User-Agent": "alysis-code/0.1.0",
|
|
1759
|
+
}
|
|
1760
|
+
if self.api_key:
|
|
1761
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
1762
|
+
headers = merge_canonical_headers(headers, self.extra_headers)
|
|
1763
|
+
if self.provider_auth is not None:
|
|
1764
|
+
for key in tuple(headers):
|
|
1765
|
+
if key.casefold() in {
|
|
1766
|
+
"authorization",
|
|
1767
|
+
"chatgpt-account-id",
|
|
1768
|
+
"originator",
|
|
1769
|
+
"session-id",
|
|
1770
|
+
"x-session-affinity",
|
|
1771
|
+
"x-session-id",
|
|
1772
|
+
}:
|
|
1773
|
+
headers.pop(key, None)
|
|
1774
|
+
headers = merge_canonical_headers(
|
|
1775
|
+
headers,
|
|
1776
|
+
self.provider_auth.authorization_headers(
|
|
1777
|
+
url,
|
|
1778
|
+
force_refresh=force_refresh,
|
|
1779
|
+
session_id=self.session_id,
|
|
1780
|
+
),
|
|
1781
|
+
)
|
|
1782
|
+
return _headers_with_default_accept_encoding(headers)
|
|
1783
|
+
|
|
1784
|
+
@staticmethod
|
|
1785
|
+
def _error_from_response(response: httpx.Response) -> ResponsesError:
|
|
1786
|
+
try:
|
|
1787
|
+
data = response.json()
|
|
1788
|
+
except Exception:
|
|
1789
|
+
body = response.text
|
|
1790
|
+
if len(body) > 1000:
|
|
1791
|
+
body = body[:1000] + "...(truncated)"
|
|
1792
|
+
return ResponsesError(
|
|
1793
|
+
sanitize_error_text_for_output(f"Responses error {response.status_code}: {body}")
|
|
1794
|
+
)
|
|
1795
|
+
if isinstance(data, dict):
|
|
1796
|
+
error_message = _extract_error_message(data)
|
|
1797
|
+
if error_message:
|
|
1798
|
+
lower = error_message.lower()
|
|
1799
|
+
if "unsupported" in lower or "not support" in lower:
|
|
1800
|
+
return ResponsesError(
|
|
1801
|
+
sanitize_error_text_for_output(
|
|
1802
|
+
f"Responses web_search unsupported: {error_message}"
|
|
1803
|
+
)
|
|
1804
|
+
)
|
|
1805
|
+
return ResponsesError(
|
|
1806
|
+
sanitize_error_text_for_output(
|
|
1807
|
+
f"Responses error {response.status_code}: {error_message}"
|
|
1808
|
+
)
|
|
1809
|
+
)
|
|
1810
|
+
return ResponsesError(
|
|
1811
|
+
sanitize_error_text_for_output(f"Responses error {response.status_code}: {data!r}")
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
@staticmethod
|
|
1815
|
+
def _llm_error_from_response(response: httpx.Response) -> LLMError:
|
|
1816
|
+
try:
|
|
1817
|
+
data = response.json()
|
|
1818
|
+
except Exception:
|
|
1819
|
+
body = response.text
|
|
1820
|
+
if len(body) > 1000:
|
|
1821
|
+
body = body[:1000] + "...(truncated)"
|
|
1822
|
+
return LLMError(
|
|
1823
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {body}")
|
|
1824
|
+
)
|
|
1825
|
+
error_message = _extract_error_message(data)
|
|
1826
|
+
if error_message:
|
|
1827
|
+
return LLMError(
|
|
1828
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {error_message}")
|
|
1829
|
+
)
|
|
1830
|
+
return LLMError(
|
|
1831
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {data!r}")
|
|
1832
|
+
)
|
|
1833
|
+
|
|
1834
|
+
def count_input_tokens(
|
|
1835
|
+
self,
|
|
1836
|
+
*,
|
|
1837
|
+
messages: list[dict[str, Any]],
|
|
1838
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1839
|
+
tool_choice: Any | None = None,
|
|
1840
|
+
) -> InputTokenCount | None:
|
|
1841
|
+
if self._input_token_count_available is False:
|
|
1842
|
+
return None
|
|
1843
|
+
messages = gate_messages_for_provider_route(messages, self.route_identity)
|
|
1844
|
+
tool_mapping = _responses_tools(
|
|
1845
|
+
tools,
|
|
1846
|
+
mode=self.web_search_mode,
|
|
1847
|
+
adapter=self.web_search_adapter,
|
|
1848
|
+
)
|
|
1849
|
+
payload: dict[str, Any] = {
|
|
1850
|
+
"model": self.model,
|
|
1851
|
+
"input": _responses_input_from_messages(messages),
|
|
1852
|
+
}
|
|
1853
|
+
if tool_mapping.tools:
|
|
1854
|
+
payload["tools"] = tool_mapping.tools
|
|
1855
|
+
payload["tool_choice"] = (
|
|
1856
|
+
"auto"
|
|
1857
|
+
if tool_choice is None
|
|
1858
|
+
else _tool_choice_for_mapped_tools(
|
|
1859
|
+
tool_choice,
|
|
1860
|
+
removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
|
|
1861
|
+
)
|
|
1862
|
+
)
|
|
1863
|
+
if self.provider_auth is not None:
|
|
1864
|
+
adapted = self.provider_auth.adapt_responses_payload(payload)
|
|
1865
|
+
payload = {
|
|
1866
|
+
key: adapted[key]
|
|
1867
|
+
for key in ("model", "input", "instructions", "tools", "tool_choice")
|
|
1868
|
+
if key in adapted
|
|
1869
|
+
}
|
|
1870
|
+
url = f"{self.base_url}/responses/input_tokens"
|
|
1871
|
+
|
|
1872
|
+
def _send_request() -> InputTokenCount | None:
|
|
1873
|
+
auth_refresh_used = False
|
|
1874
|
+
while True:
|
|
1875
|
+
try:
|
|
1876
|
+
with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
|
|
1877
|
+
response = client.post(
|
|
1878
|
+
url,
|
|
1879
|
+
headers=self._headers(url, force_refresh=auth_refresh_used),
|
|
1880
|
+
json=payload,
|
|
1881
|
+
)
|
|
1882
|
+
except httpx.HTTPError as exc:
|
|
1883
|
+
raise LLMError(
|
|
1884
|
+
"OpenAI input token count request failed: "
|
|
1885
|
+
f"{sanitize_error_text_for_output(exc)}"
|
|
1886
|
+
) from exc
|
|
1887
|
+
if (
|
|
1888
|
+
response.status_code == 401
|
|
1889
|
+
and self.provider_auth is not None
|
|
1890
|
+
and not auth_refresh_used
|
|
1891
|
+
):
|
|
1892
|
+
auth_refresh_used = True
|
|
1893
|
+
continue
|
|
1894
|
+
if response.status_code in {404, 405, 501}:
|
|
1895
|
+
self._input_token_count_available = False
|
|
1896
|
+
return None
|
|
1897
|
+
if response.status_code >= 400:
|
|
1898
|
+
raise self._llm_error_from_response(response)
|
|
1899
|
+
try:
|
|
1900
|
+
data = response.json()
|
|
1901
|
+
except Exception as exc: # noqa: BLE001
|
|
1902
|
+
raise LLMError("OpenAI input token count returned non-JSON response") from exc
|
|
1903
|
+
count = _non_negative_int(
|
|
1904
|
+
data.get("input_tokens") if isinstance(data, dict) else None
|
|
1905
|
+
)
|
|
1906
|
+
if count is None:
|
|
1907
|
+
raise LLMError("OpenAI input token count response omitted input_tokens")
|
|
1908
|
+
self._input_token_count_available = True
|
|
1909
|
+
return InputTokenCount(
|
|
1910
|
+
input_tokens=count,
|
|
1911
|
+
raw_provider_usage=copy.deepcopy(data),
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
return run_provider_limited_call(
|
|
1915
|
+
call=_send_request,
|
|
1916
|
+
provider_key=self.provider_key,
|
|
1917
|
+
provider_concurrency_caps=self.provider_concurrency_caps,
|
|
1918
|
+
retry_settings=self.provider_retry_settings,
|
|
1919
|
+
operation="responses_count_input_tokens",
|
|
1920
|
+
sleep_fn=self._provider_sleep_fn,
|
|
1921
|
+
random_fn=self._provider_random_fn,
|
|
1922
|
+
retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
|
|
1923
|
+
)
|
|
1924
|
+
|
|
1925
|
+
def chat(
|
|
1926
|
+
self,
|
|
1927
|
+
*,
|
|
1928
|
+
messages: list[dict[str, Any]],
|
|
1929
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1930
|
+
tool_choice: Any | None = None,
|
|
1931
|
+
response_format: dict[str, Any] | None = None,
|
|
1932
|
+
stream: bool = False,
|
|
1933
|
+
on_text_delta: Callable[[str], None] | None = None,
|
|
1934
|
+
on_reasoning_delta: Callable[[str], None] | None = None,
|
|
1935
|
+
temperature: float | None = None,
|
|
1936
|
+
max_tokens: int | None = None,
|
|
1937
|
+
request_plan: LLMRequestPlan | None = None,
|
|
1938
|
+
) -> LLMResponse:
|
|
1939
|
+
default_cache = RequestCachePlan(
|
|
1940
|
+
strategy=(
|
|
1941
|
+
"openai_prompt_cache"
|
|
1942
|
+
if self.prompt_cache_key or self.prompt_cache_retention
|
|
1943
|
+
else "none"
|
|
1944
|
+
),
|
|
1945
|
+
mode=(
|
|
1946
|
+
"automatic" if self.prompt_cache_key or self.prompt_cache_retention else "manual"
|
|
1947
|
+
),
|
|
1948
|
+
prompt_cache_key=self.prompt_cache_key,
|
|
1949
|
+
prompt_cache_retention=self.prompt_cache_retention,
|
|
1950
|
+
)
|
|
1951
|
+
plan = request_plan or LLMRequestPlan.from_chat_args(
|
|
1952
|
+
messages=messages,
|
|
1953
|
+
tools=tools,
|
|
1954
|
+
tool_choice=tool_choice,
|
|
1955
|
+
response_format=response_format,
|
|
1956
|
+
stream=stream,
|
|
1957
|
+
temperature=temperature,
|
|
1958
|
+
max_tokens=max_tokens,
|
|
1959
|
+
cache=default_cache,
|
|
1960
|
+
)
|
|
1961
|
+
if (
|
|
1962
|
+
request_plan is not None
|
|
1963
|
+
and plan.cache.mode != "off"
|
|
1964
|
+
and plan.cache.strategy == "none"
|
|
1965
|
+
and not plan.cache.prompt_cache_key
|
|
1966
|
+
and not plan.cache.prompt_cache_retention
|
|
1967
|
+
and (self.prompt_cache_key or self.prompt_cache_retention)
|
|
1968
|
+
):
|
|
1969
|
+
plan = plan.with_cache(default_cache)
|
|
1970
|
+
messages = gate_messages_for_provider_route(plan.message_list(), self.route_identity)
|
|
1971
|
+
tools = plan.tool_list()
|
|
1972
|
+
tool_choice = plan.tool_choice
|
|
1973
|
+
response_format = plan.response_format
|
|
1974
|
+
public_stream = plan.stream
|
|
1975
|
+
stream = public_stream or bool(
|
|
1976
|
+
self.provider_auth is not None
|
|
1977
|
+
and getattr(self.provider_auth, "requires_streaming", False)
|
|
1978
|
+
)
|
|
1979
|
+
temperature = plan.temperature
|
|
1980
|
+
max_tokens = plan.max_tokens
|
|
1981
|
+
if max_tokens is not None:
|
|
1982
|
+
max_tokens = _clamp_responses_max_output_tokens(max_tokens)
|
|
1983
|
+
tool_mapping = _responses_tools(
|
|
1984
|
+
tools,
|
|
1985
|
+
mode=self.web_search_mode,
|
|
1986
|
+
adapter=self.web_search_adapter,
|
|
1987
|
+
)
|
|
1988
|
+
mapped_tools = tool_mapping.tools
|
|
1989
|
+
temp_omit_key = _responses_temperature_omit_key(self.base_url, self.model)
|
|
1990
|
+
include_omit_key = _responses_endpoint_model_key(self.base_url, self.model)
|
|
1991
|
+
reasoning_summary_support_key = self._reasoning_summary_support_key()
|
|
1992
|
+
reasoning = _responses_reasoning(
|
|
1993
|
+
enable_thinking=self.enable_thinking,
|
|
1994
|
+
reasoning_effort=self.reasoning_effort,
|
|
1995
|
+
request_summary=self._should_request_reasoning_summary(),
|
|
1996
|
+
)
|
|
1997
|
+
text_config = _responses_text_config(response_format)
|
|
1998
|
+
full_input = _responses_input_from_messages(messages)
|
|
1999
|
+
continuation = _responses_continuation_from_messages(messages)
|
|
2000
|
+
previous_response_id: str | None = None
|
|
2001
|
+
sent_input = full_input
|
|
2002
|
+
input_mode = "full"
|
|
2003
|
+
continuation_anchor_index: int | None = None
|
|
2004
|
+
supports_previous_response_id = self.provider_auth is None or bool(
|
|
2005
|
+
getattr(self.provider_auth, "supports_previous_response_id", True)
|
|
2006
|
+
)
|
|
2007
|
+
if continuation is not None and supports_previous_response_id:
|
|
2008
|
+
# With previous_response_id the API appends the sent input items to the
|
|
2009
|
+
# stored thread; system/developer messages from turn 1 are already
|
|
2010
|
+
# retained server-side, so resending them duplicates the instructions
|
|
2011
|
+
# on every chained turn. Send only the new suffix.
|
|
2012
|
+
continuation_input = _responses_input_from_messages(continuation.suffix_messages)
|
|
2013
|
+
if continuation_input:
|
|
2014
|
+
previous_response_id = continuation.previous_response_id
|
|
2015
|
+
sent_input = continuation_input
|
|
2016
|
+
input_mode = "previous_response_id"
|
|
2017
|
+
continuation_anchor_index = continuation.anchor_index
|
|
2018
|
+
|
|
2019
|
+
cache_policy = merge_cache_policy_metadata(
|
|
2020
|
+
self.prompt_cache_policy_metadata,
|
|
2021
|
+
plan.cache.openai_prompt_cache_policy_metadata(),
|
|
2022
|
+
)
|
|
2023
|
+
|
|
2024
|
+
def _build_payload(
|
|
2025
|
+
input_items: list[dict[str, Any]],
|
|
2026
|
+
*,
|
|
2027
|
+
prior_response_id: str | None,
|
|
2028
|
+
) -> dict[str, Any]:
|
|
2029
|
+
payload: dict[str, Any] = {
|
|
2030
|
+
"model": self.model,
|
|
2031
|
+
"input": input_items,
|
|
2032
|
+
}
|
|
2033
|
+
if prior_response_id:
|
|
2034
|
+
payload["previous_response_id"] = prior_response_id
|
|
2035
|
+
if temp_omit_key not in _RESPONSES_OMIT_TEMPERATURE_MODELS:
|
|
2036
|
+
payload["temperature"] = (
|
|
2037
|
+
self.temperature if temperature is None else float(temperature)
|
|
2038
|
+
)
|
|
2039
|
+
if plan.cache.prompt_cache_key:
|
|
2040
|
+
payload["prompt_cache_key"] = plan.cache.prompt_cache_key
|
|
2041
|
+
if plan.cache.prompt_cache_retention:
|
|
2042
|
+
payload["prompt_cache_retention"] = plan.cache.prompt_cache_retention
|
|
2043
|
+
if reasoning is not None:
|
|
2044
|
+
payload["reasoning"] = copy.deepcopy(reasoning)
|
|
2045
|
+
if mapped_tools:
|
|
2046
|
+
payload["tools"] = mapped_tools
|
|
2047
|
+
payload["tool_choice"] = (
|
|
2048
|
+
"auto"
|
|
2049
|
+
if tool_choice is None
|
|
2050
|
+
else _tool_choice_for_mapped_tools(
|
|
2051
|
+
tool_choice,
|
|
2052
|
+
removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
|
|
2053
|
+
)
|
|
2054
|
+
)
|
|
2055
|
+
if (
|
|
2056
|
+
tool_mapping.added_builtin_web_search
|
|
2057
|
+
and include_omit_key not in _RESPONSES_OMIT_INCLUDE_ENDPOINTS
|
|
2058
|
+
):
|
|
2059
|
+
payload["include"] = ["web_search_call.action.sources"]
|
|
2060
|
+
elif tool_choice is not None:
|
|
2061
|
+
payload["tool_choice"] = _tool_choice_for_mapped_tools(
|
|
2062
|
+
tool_choice,
|
|
2063
|
+
removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
|
|
2064
|
+
)
|
|
2065
|
+
if text_config is not None:
|
|
2066
|
+
payload["text"] = text_config
|
|
2067
|
+
if max_tokens is not None:
|
|
2068
|
+
payload["max_output_tokens"] = int(max_tokens)
|
|
2069
|
+
if stream:
|
|
2070
|
+
payload["stream"] = True
|
|
2071
|
+
if self.provider_auth is not None:
|
|
2072
|
+
payload = self.provider_auth.adapt_responses_payload(payload)
|
|
2073
|
+
if self._reasoning_summary_support_by_model.get(reasoning_summary_support_key) is False:
|
|
2074
|
+
_without_responses_reasoning_summary(payload)
|
|
2075
|
+
return payload
|
|
2076
|
+
|
|
2077
|
+
def _prompt_estimation_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
2078
|
+
estimation_payload: dict[str, Any] = {
|
|
2079
|
+
"input": payload.get("input", []),
|
|
2080
|
+
}
|
|
2081
|
+
for key in ("tools", "text", "include"):
|
|
2082
|
+
if key in payload:
|
|
2083
|
+
estimation_payload[key] = payload[key]
|
|
2084
|
+
return estimation_payload
|
|
2085
|
+
|
|
2086
|
+
payload = _build_payload(sent_input, prior_response_id=previous_response_id)
|
|
2087
|
+
full_estimate_payload = _build_payload(full_input, prior_response_id=None)
|
|
2088
|
+
full_input_estimate_tokens = estimate_provider_payload_tokens(
|
|
2089
|
+
_prompt_estimation_payload(full_estimate_payload)
|
|
2090
|
+
)
|
|
2091
|
+
|
|
2092
|
+
def _request_plan_metadata(current_payload: dict[str, Any]) -> dict[str, Any]:
|
|
2093
|
+
extra: dict[str, Any] = {
|
|
2094
|
+
"full_input_item_count": len(full_input),
|
|
2095
|
+
"sent_input_item_count": len(sent_input),
|
|
2096
|
+
"previous_response_id_used": previous_response_id is not None,
|
|
2097
|
+
# Continuation never resends stable instructions; kept at 0 so the
|
|
2098
|
+
# telemetry schema stays stable for downstream consumers.
|
|
2099
|
+
"resent_stable_instruction_count": 0,
|
|
2100
|
+
}
|
|
2101
|
+
if continuation_anchor_index is not None:
|
|
2102
|
+
extra["continuation_anchor_index"] = continuation_anchor_index
|
|
2103
|
+
metadata = plan.request_plan_metadata(
|
|
2104
|
+
input_mode=input_mode,
|
|
2105
|
+
continuation_strategy=(
|
|
2106
|
+
"previous_response_id" if previous_response_id else "full_replay"
|
|
2107
|
+
),
|
|
2108
|
+
provider_payload=_prompt_estimation_payload(full_estimate_payload),
|
|
2109
|
+
sent_provider_payload=_prompt_estimation_payload(current_payload),
|
|
2110
|
+
cache_policy_metadata=cache_policy,
|
|
2111
|
+
extra=extra,
|
|
2112
|
+
)
|
|
2113
|
+
metadata["request_messages_signature"] = _stable_request_signature(messages)
|
|
2114
|
+
return metadata
|
|
2115
|
+
|
|
2116
|
+
request_plan_metadata = _request_plan_metadata(payload)
|
|
2117
|
+
|
|
2118
|
+
def _token_reconciliation_metadata(
|
|
2119
|
+
current_payload: dict[str, Any],
|
|
2120
|
+
) -> dict[str, Any]:
|
|
2121
|
+
sent_input_estimate_tokens = estimate_provider_payload_tokens(
|
|
2122
|
+
_prompt_estimation_payload(current_payload)
|
|
2123
|
+
)
|
|
2124
|
+
return {
|
|
2125
|
+
"input_estimate_tokens": full_input_estimate_tokens,
|
|
2126
|
+
"sent_input_estimate_tokens": sent_input_estimate_tokens,
|
|
2127
|
+
"estimator": "cl100k_base",
|
|
2128
|
+
"estimate_basis": "provider_prompt_payload",
|
|
2129
|
+
"input_mode": str(request_plan_metadata.get("input_mode") or input_mode),
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
def _request_shape_metadata(current_payload: dict[str, Any]) -> dict[str, Any]:
|
|
2133
|
+
return build_request_shape_report(
|
|
2134
|
+
messages=messages,
|
|
2135
|
+
tools=tools,
|
|
2136
|
+
cache_policy=cache_policy,
|
|
2137
|
+
provider_payload=_prompt_estimation_payload(current_payload),
|
|
2138
|
+
input_mode=str(request_plan_metadata.get("input_mode") or input_mode),
|
|
2139
|
+
)
|
|
2140
|
+
|
|
2141
|
+
provider_key = self.provider_key or best_effort_provider_key(
|
|
2142
|
+
base_url=self.base_url,
|
|
2143
|
+
model=self.model,
|
|
2144
|
+
)
|
|
2145
|
+
telemetry = ProviderCallTelemetryRecorder(
|
|
2146
|
+
provider_key=provider_key,
|
|
2147
|
+
protocol="openai_responses",
|
|
2148
|
+
model=self.model,
|
|
2149
|
+
base_url=self.base_url,
|
|
2150
|
+
stream=stream,
|
|
2151
|
+
tools=tools,
|
|
2152
|
+
web_search_mode=self.web_search_mode,
|
|
2153
|
+
web_search_adapter=self.web_search_adapter,
|
|
2154
|
+
native_web_search=tool_mapping.added_builtin_web_search,
|
|
2155
|
+
cache_policy=cache_policy,
|
|
2156
|
+
request_plan=request_plan_metadata,
|
|
2157
|
+
request_shape=_request_shape_metadata(payload),
|
|
2158
|
+
token_reconciliation=_token_reconciliation_metadata(payload),
|
|
2159
|
+
operation="responses_chat",
|
|
2160
|
+
)
|
|
2161
|
+
telemetry_on_text_delta = telemetry.wrap_text_delta(on_text_delta)
|
|
2162
|
+
telemetry_on_reasoning_delta = telemetry.wrap_reasoning_delta(on_reasoning_delta)
|
|
2163
|
+
public_output_emitted = False
|
|
2164
|
+
|
|
2165
|
+
def _tracked_text_delta(delta: str) -> None:
|
|
2166
|
+
nonlocal public_output_emitted
|
|
2167
|
+
if delta:
|
|
2168
|
+
public_output_emitted = True
|
|
2169
|
+
if telemetry_on_text_delta is not None:
|
|
2170
|
+
telemetry_on_text_delta(delta)
|
|
2171
|
+
|
|
2172
|
+
def _tracked_reasoning_delta(delta: str) -> None:
|
|
2173
|
+
nonlocal public_output_emitted
|
|
2174
|
+
if delta:
|
|
2175
|
+
public_output_emitted = True
|
|
2176
|
+
if telemetry_on_reasoning_delta is not None:
|
|
2177
|
+
telemetry_on_reasoning_delta(delta)
|
|
2178
|
+
|
|
2179
|
+
def _finalize_response(response: LLMResponse) -> LLMResponse:
|
|
2180
|
+
raw_reasoning = payload.get("reasoning")
|
|
2181
|
+
if isinstance(raw_reasoning, dict) and raw_reasoning.get("summary") == "auto":
|
|
2182
|
+
self._reasoning_summary_support_by_model[reasoning_summary_support_key] = True
|
|
2183
|
+
if not public_stream:
|
|
2184
|
+
if on_reasoning_delta is not None and telemetry_on_reasoning_delta is not None:
|
|
2185
|
+
for summary in response.reasoning:
|
|
2186
|
+
if summary.kind == ReasoningOutputKind.SUMMARY:
|
|
2187
|
+
telemetry_on_reasoning_delta(summary.text)
|
|
2188
|
+
if (
|
|
2189
|
+
on_text_delta is not None
|
|
2190
|
+
and telemetry_on_text_delta is not None
|
|
2191
|
+
and response.content
|
|
2192
|
+
):
|
|
2193
|
+
telemetry_on_text_delta(response.content)
|
|
2194
|
+
telemetry.set_request_plan(request_plan_metadata)
|
|
2195
|
+
telemetry.set_request_shape(_request_shape_metadata(payload))
|
|
2196
|
+
telemetry.set_token_reconciliation(_token_reconciliation_metadata(payload))
|
|
2197
|
+
return _response_with_request_plan_metadata(
|
|
2198
|
+
response,
|
|
2199
|
+
request_plan_metadata,
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
def _send_request() -> LLMResponse:
|
|
2203
|
+
nonlocal input_mode, payload, previous_response_id, reasoning
|
|
2204
|
+
nonlocal request_plan_metadata, sent_input
|
|
2205
|
+
url = f"{self.base_url}/responses"
|
|
2206
|
+
previous_response_fallback_used = False
|
|
2207
|
+
reasoning_summary_fallback_used = False
|
|
2208
|
+
auth_refresh_used = False
|
|
2209
|
+
|
|
2210
|
+
def _refresh_request_metadata() -> None:
|
|
2211
|
+
nonlocal request_plan_metadata
|
|
2212
|
+
request_plan_metadata = _request_plan_metadata(payload)
|
|
2213
|
+
telemetry.set_request_plan(request_plan_metadata)
|
|
2214
|
+
telemetry.set_request_shape(_request_shape_metadata(payload))
|
|
2215
|
+
telemetry.set_token_reconciliation(_token_reconciliation_metadata(payload))
|
|
2216
|
+
|
|
2217
|
+
def _retry_without_include(err: Exception) -> bool:
|
|
2218
|
+
nonlocal payload
|
|
2219
|
+
if "include" not in payload:
|
|
2220
|
+
return False
|
|
2221
|
+
if not _responses_include_unsupported(err):
|
|
2222
|
+
return False
|
|
2223
|
+
# Bounded: the retried payload has no ``include`` entries, so
|
|
2224
|
+
# this branch cannot fire twice for the same request.
|
|
2225
|
+
_RESPONSES_OMIT_INCLUDE_ENDPOINTS.add(include_omit_key)
|
|
2226
|
+
payload = dict(payload)
|
|
2227
|
+
payload.pop("include", None)
|
|
2228
|
+
_refresh_request_metadata()
|
|
2229
|
+
return True
|
|
2230
|
+
|
|
2231
|
+
def _retry_without_reasoning_summary(err: Exception) -> bool:
|
|
2232
|
+
nonlocal payload, reasoning, reasoning_summary_fallback_used
|
|
2233
|
+
if reasoning_summary_fallback_used:
|
|
2234
|
+
return False
|
|
2235
|
+
if not _responses_reasoning_summary_unsupported(err):
|
|
2236
|
+
return False
|
|
2237
|
+
if (
|
|
2238
|
+
not isinstance(payload.get("reasoning"), dict)
|
|
2239
|
+
or "summary" not in payload["reasoning"]
|
|
2240
|
+
):
|
|
2241
|
+
return False
|
|
2242
|
+
reasoning_summary_fallback_used = True
|
|
2243
|
+
if (
|
|
2244
|
+
self._reasoning_summary_support_by_model.get(reasoning_summary_support_key)
|
|
2245
|
+
is not True
|
|
2246
|
+
):
|
|
2247
|
+
self._reasoning_summary_support_by_model[reasoning_summary_support_key] = False
|
|
2248
|
+
if isinstance(reasoning, dict):
|
|
2249
|
+
reasoning = copy.deepcopy(reasoning)
|
|
2250
|
+
reasoning.pop("summary", None)
|
|
2251
|
+
reasoning = reasoning or None
|
|
2252
|
+
payload = _build_payload(sent_input, prior_response_id=previous_response_id)
|
|
2253
|
+
_without_responses_reasoning_summary(payload)
|
|
2254
|
+
_refresh_request_metadata()
|
|
2255
|
+
return True
|
|
2256
|
+
|
|
2257
|
+
while True:
|
|
2258
|
+
try:
|
|
2259
|
+
with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
|
|
2260
|
+
if stream:
|
|
2261
|
+
with client.stream(
|
|
2262
|
+
"POST",
|
|
2263
|
+
url,
|
|
2264
|
+
headers=self._headers(url, force_refresh=auth_refresh_used),
|
|
2265
|
+
json=payload,
|
|
2266
|
+
) as response:
|
|
2267
|
+
if response.status_code >= 400:
|
|
2268
|
+
response.read()
|
|
2269
|
+
if (
|
|
2270
|
+
response.status_code == 401
|
|
2271
|
+
and self.provider_auth is not None
|
|
2272
|
+
and not auth_refresh_used
|
|
2273
|
+
):
|
|
2274
|
+
auth_refresh_used = True
|
|
2275
|
+
continue
|
|
2276
|
+
err = self._llm_error_from_response(response)
|
|
2277
|
+
if (
|
|
2278
|
+
previous_response_id
|
|
2279
|
+
and not previous_response_fallback_used
|
|
2280
|
+
and _responses_previous_response_rejected(err)
|
|
2281
|
+
):
|
|
2282
|
+
previous_response_fallback_used = True
|
|
2283
|
+
previous_response_id = None
|
|
2284
|
+
sent_input = full_input
|
|
2285
|
+
input_mode = (
|
|
2286
|
+
"full_retry_after_previous_response_id_rejected"
|
|
2287
|
+
)
|
|
2288
|
+
payload = _build_payload(
|
|
2289
|
+
sent_input,
|
|
2290
|
+
prior_response_id=None,
|
|
2291
|
+
)
|
|
2292
|
+
_refresh_request_metadata()
|
|
2293
|
+
continue
|
|
2294
|
+
if _retry_without_reasoning_summary(err):
|
|
2295
|
+
continue
|
|
2296
|
+
if _retry_without_include(err):
|
|
2297
|
+
continue
|
|
2298
|
+
if (
|
|
2299
|
+
"temperature" in payload
|
|
2300
|
+
and _responses_temperature_unsupported(err)
|
|
2301
|
+
):
|
|
2302
|
+
payload.pop("temperature", None)
|
|
2303
|
+
_RESPONSES_OMIT_TEMPERATURE_MODELS.add(temp_omit_key)
|
|
2304
|
+
continue
|
|
2305
|
+
raise err
|
|
2306
|
+
return _finalize_response(
|
|
2307
|
+
self._parse_stream_response(
|
|
2308
|
+
response,
|
|
2309
|
+
on_text_delta=(
|
|
2310
|
+
_tracked_text_delta
|
|
2311
|
+
if public_stream and on_text_delta is not None
|
|
2312
|
+
else None
|
|
2313
|
+
),
|
|
2314
|
+
on_reasoning_delta=(
|
|
2315
|
+
_tracked_reasoning_delta
|
|
2316
|
+
if public_stream and on_reasoning_delta is not None
|
|
2317
|
+
else None
|
|
2318
|
+
),
|
|
2319
|
+
)
|
|
2320
|
+
)
|
|
2321
|
+
response = client.post(
|
|
2322
|
+
url,
|
|
2323
|
+
headers=self._headers(url, force_refresh=auth_refresh_used),
|
|
2324
|
+
json=payload,
|
|
2325
|
+
)
|
|
2326
|
+
except httpx.DecodingError as e:
|
|
2327
|
+
err = LLMError(
|
|
2328
|
+
"OpenAI Responses decompression failed: "
|
|
2329
|
+
f"{sanitize_error_text_for_output(e)}"
|
|
2330
|
+
)
|
|
2331
|
+
if stream and public_output_emitted:
|
|
2332
|
+
mark_provider_call_non_retryable(err)
|
|
2333
|
+
raise err from e
|
|
2334
|
+
except Exception as e: # noqa: BLE001
|
|
2335
|
+
if isinstance(e, LLMError):
|
|
2336
|
+
if stream and public_output_emitted:
|
|
2337
|
+
mark_provider_call_non_retryable(e)
|
|
2338
|
+
raise
|
|
2339
|
+
err = LLMError(
|
|
2340
|
+
f"OpenAI Responses request failed: {sanitize_error_text_for_output(e)}"
|
|
2341
|
+
)
|
|
2342
|
+
if stream and public_output_emitted:
|
|
2343
|
+
mark_provider_call_non_retryable(err)
|
|
2344
|
+
raise err from e
|
|
2345
|
+
if response.status_code >= 400:
|
|
2346
|
+
if (
|
|
2347
|
+
response.status_code == 401
|
|
2348
|
+
and self.provider_auth is not None
|
|
2349
|
+
and not auth_refresh_used
|
|
2350
|
+
):
|
|
2351
|
+
auth_refresh_used = True
|
|
2352
|
+
continue
|
|
2353
|
+
err = self._llm_error_from_response(response)
|
|
2354
|
+
if (
|
|
2355
|
+
previous_response_id
|
|
2356
|
+
and not previous_response_fallback_used
|
|
2357
|
+
and _responses_previous_response_rejected(err)
|
|
2358
|
+
):
|
|
2359
|
+
previous_response_fallback_used = True
|
|
2360
|
+
previous_response_id = None
|
|
2361
|
+
sent_input = full_input
|
|
2362
|
+
input_mode = "full_retry_after_previous_response_id_rejected"
|
|
2363
|
+
payload = _build_payload(sent_input, prior_response_id=None)
|
|
2364
|
+
_refresh_request_metadata()
|
|
2365
|
+
continue
|
|
2366
|
+
if _retry_without_reasoning_summary(err):
|
|
2367
|
+
continue
|
|
2368
|
+
if _retry_without_include(err):
|
|
2369
|
+
continue
|
|
2370
|
+
# The model rejected ``temperature``: drop it (and remember
|
|
2371
|
+
# that for this model) and retry once. Bounded — the second
|
|
2372
|
+
# attempt has no ``temperature`` so it can't loop here.
|
|
2373
|
+
if "temperature" in payload and _responses_temperature_unsupported(err):
|
|
2374
|
+
payload.pop("temperature", None)
|
|
2375
|
+
_RESPONSES_OMIT_TEMPERATURE_MODELS.add(temp_omit_key)
|
|
2376
|
+
continue
|
|
2377
|
+
raise err
|
|
2378
|
+
return _finalize_response(self._parse_chat_response(response))
|
|
2379
|
+
|
|
2380
|
+
return stamp_response_for_route(
|
|
2381
|
+
telemetry.run(
|
|
2382
|
+
lambda: run_provider_limited_call(
|
|
2383
|
+
call=_send_request,
|
|
2384
|
+
provider_key=provider_key,
|
|
2385
|
+
provider_concurrency_caps=self.provider_concurrency_caps,
|
|
2386
|
+
retry_settings=self.provider_retry_settings,
|
|
2387
|
+
operation="responses_chat",
|
|
2388
|
+
sleep_fn=self._provider_sleep_fn,
|
|
2389
|
+
random_fn=self._provider_random_fn,
|
|
2390
|
+
on_retry=telemetry.on_retry,
|
|
2391
|
+
on_retry_event=getattr(
|
|
2392
|
+
self,
|
|
2393
|
+
"_provider_retry_event_observer",
|
|
2394
|
+
None,
|
|
2395
|
+
),
|
|
2396
|
+
retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
|
|
2397
|
+
retry_wall_clock_cap_seconds=getattr(
|
|
2398
|
+
self,
|
|
2399
|
+
"_provider_retry_wall_clock_cap_seconds",
|
|
2400
|
+
_PROVIDER_RETRY_WALL_CLOCK_CAP_SECONDS,
|
|
2401
|
+
),
|
|
2402
|
+
)
|
|
2403
|
+
),
|
|
2404
|
+
self.route_identity,
|
|
2405
|
+
)
|
|
2406
|
+
|
|
2407
|
+
@staticmethod
|
|
2408
|
+
def _parse_stream_response(
|
|
2409
|
+
response: httpx.Response,
|
|
2410
|
+
*,
|
|
2411
|
+
on_text_delta: Callable[[str], None] | None,
|
|
2412
|
+
on_reasoning_delta: Callable[[str], None] | None,
|
|
2413
|
+
) -> LLMResponse:
|
|
2414
|
+
accumulator = _OpenAIResponsesStreamAccumulator(
|
|
2415
|
+
on_text_delta=on_text_delta,
|
|
2416
|
+
on_reasoning_delta=on_reasoning_delta,
|
|
2417
|
+
)
|
|
2418
|
+
for frame in iter_sse_frames(response.iter_lines()):
|
|
2419
|
+
raw_event = parse_sse_json_frame(frame, stream_name="OpenAI Responses stream")
|
|
2420
|
+
if not isinstance(raw_event, dict):
|
|
2421
|
+
raise LLMError("OpenAI Responses stream emitted non-object JSON event")
|
|
2422
|
+
accumulator.handle(frame, raw_event)
|
|
2423
|
+
data = accumulator.finish()
|
|
2424
|
+
return OpenAIResponsesClient._parse_chat_response(_response_from_json(data))
|
|
2425
|
+
|
|
2426
|
+
@staticmethod
|
|
2427
|
+
def _parse_chat_response(response: httpx.Response) -> LLMResponse:
|
|
2428
|
+
try:
|
|
2429
|
+
data = response.json()
|
|
2430
|
+
except Exception as e: # noqa: BLE001
|
|
2431
|
+
raise LLMError("OpenAI Responses returned non-JSON response") from e
|
|
2432
|
+
if not isinstance(data, dict):
|
|
2433
|
+
raise LLMError("Unexpected OpenAI Responses payload: expected JSON object")
|
|
2434
|
+
|
|
2435
|
+
content = _extract_answer_text(data)
|
|
2436
|
+
tool_calls = _parse_response_tool_calls(data)
|
|
2437
|
+
reasoning = _responses_reasoning_outputs(data)
|
|
2438
|
+
if not content and not tool_calls:
|
|
2439
|
+
refusal = _extract_refusal(data)
|
|
2440
|
+
if refusal:
|
|
2441
|
+
raise LLMError(f"OpenAI Responses refusal: {refusal}")
|
|
2442
|
+
if not _has_responses_reasoning_output(data):
|
|
2443
|
+
status = str(data.get("status") or "").strip()
|
|
2444
|
+
# A completed response with no output is a valid provider result,
|
|
2445
|
+
# even though it gives the agent nothing to act on. Preserve that
|
|
2446
|
+
# structure as an empty LLMResponse so the shared turn-level
|
|
2447
|
+
# recovery and stall policy can decide what happens next. Other
|
|
2448
|
+
# statuses remain provider failures and must stay explicit.
|
|
2449
|
+
if status.casefold() != "completed":
|
|
2450
|
+
suffix = f" (status={status})" if status else ""
|
|
2451
|
+
raise LLMError(
|
|
2452
|
+
f"OpenAI Responses returned no assistant text or tool calls{suffix}"
|
|
2453
|
+
)
|
|
2454
|
+
|
|
2455
|
+
response_model = data.get("model") if isinstance(data.get("model"), str) else None
|
|
2456
|
+
return LLMResponse(
|
|
2457
|
+
content=content,
|
|
2458
|
+
tool_calls=tool_calls,
|
|
2459
|
+
raw=data,
|
|
2460
|
+
response_model=response_model,
|
|
2461
|
+
usage=_parse_usage(data.get("usage")),
|
|
2462
|
+
provider_metadata=_responses_provider_metadata(data),
|
|
2463
|
+
reasoning=reasoning,
|
|
2464
|
+
assistant_phase=_assistant_response_phase(data),
|
|
2465
|
+
)
|
|
2466
|
+
|
|
2467
|
+
def web_search(
|
|
2468
|
+
self,
|
|
2469
|
+
*,
|
|
2470
|
+
query: str,
|
|
2471
|
+
allowed_domains: list[str] | None = None,
|
|
2472
|
+
external_web_access: bool | None = None,
|
|
2473
|
+
include_source_details: bool = True,
|
|
2474
|
+
tool_choice: str | dict[str, Any] | None = "required",
|
|
2475
|
+
) -> WebSearchResponse:
|
|
2476
|
+
url = f"{self.base_url}/responses"
|
|
2477
|
+
|
|
2478
|
+
tool_spec: dict[str, Any] = {"type": "web_search"}
|
|
2479
|
+
if allowed_domains:
|
|
2480
|
+
tool_spec["filters"] = {"allowed_domains": list(allowed_domains)}
|
|
2481
|
+
if external_web_access is not None:
|
|
2482
|
+
tool_spec["external_web_access"] = bool(external_web_access)
|
|
2483
|
+
|
|
2484
|
+
include_omit_key = _responses_endpoint_model_key(self.base_url, self.model)
|
|
2485
|
+
include_omitted = include_omit_key in _RESPONSES_OMIT_INCLUDE_ENDPOINTS
|
|
2486
|
+
|
|
2487
|
+
payload: dict[str, Any] = {
|
|
2488
|
+
"model": self.model,
|
|
2489
|
+
"input": query,
|
|
2490
|
+
"tools": [tool_spec],
|
|
2491
|
+
}
|
|
2492
|
+
if tool_choice is not None:
|
|
2493
|
+
payload["tool_choice"] = tool_choice
|
|
2494
|
+
if include_source_details and not include_omitted:
|
|
2495
|
+
payload["include"] = ["web_search_call.action.sources"]
|
|
2496
|
+
if self.provider_auth is not None:
|
|
2497
|
+
payload = self.provider_auth.adapt_responses_payload(payload)
|
|
2498
|
+
|
|
2499
|
+
provider_key = self.provider_key or best_effort_provider_key(
|
|
2500
|
+
base_url=self.base_url,
|
|
2501
|
+
model=self.model,
|
|
2502
|
+
)
|
|
2503
|
+
|
|
2504
|
+
def _perform_request(request_payload: dict[str, Any]) -> httpx.Response:
|
|
2505
|
+
def _send_request() -> httpx.Response:
|
|
2506
|
+
auth_refresh_used = False
|
|
2507
|
+
try:
|
|
2508
|
+
while True:
|
|
2509
|
+
with httpx.Client(
|
|
2510
|
+
timeout=self.timeout_s, transport=self._transport
|
|
2511
|
+
) as client:
|
|
2512
|
+
response = client.post(
|
|
2513
|
+
url,
|
|
2514
|
+
headers=self._headers(url, force_refresh=auth_refresh_used),
|
|
2515
|
+
json=request_payload,
|
|
2516
|
+
)
|
|
2517
|
+
if (
|
|
2518
|
+
response.status_code == 401
|
|
2519
|
+
and self.provider_auth is not None
|
|
2520
|
+
and not auth_refresh_used
|
|
2521
|
+
):
|
|
2522
|
+
auth_refresh_used = True
|
|
2523
|
+
continue
|
|
2524
|
+
break
|
|
2525
|
+
except httpx.DecodingError as e:
|
|
2526
|
+
raise ResponsesError(
|
|
2527
|
+
"Responses response decompression failed: "
|
|
2528
|
+
f"{sanitize_error_text_for_output(e)}"
|
|
2529
|
+
) from e
|
|
2530
|
+
except Exception as e: # noqa: BLE001
|
|
2531
|
+
raise ResponsesError(
|
|
2532
|
+
f"Responses request failed: {sanitize_error_text_for_output(e)}"
|
|
2533
|
+
) from e
|
|
2534
|
+
if response.status_code >= 400:
|
|
2535
|
+
raise self._error_from_response(response)
|
|
2536
|
+
return response
|
|
2537
|
+
|
|
2538
|
+
return run_provider_limited_call(
|
|
2539
|
+
call=_send_request,
|
|
2540
|
+
provider_key=provider_key,
|
|
2541
|
+
provider_concurrency_caps=self.provider_concurrency_caps,
|
|
2542
|
+
retry_settings=self.provider_retry_settings,
|
|
2543
|
+
operation="responses_web_search",
|
|
2544
|
+
sleep_fn=self._provider_sleep_fn,
|
|
2545
|
+
random_fn=self._provider_random_fn,
|
|
2546
|
+
retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
|
|
2547
|
+
)
|
|
2548
|
+
|
|
2549
|
+
try:
|
|
2550
|
+
response = _perform_request(payload)
|
|
2551
|
+
except ResponsesError as err:
|
|
2552
|
+
if "include" not in payload or not _responses_include_unsupported(err):
|
|
2553
|
+
raise
|
|
2554
|
+
# The gateway rejected the optional ``include`` entries. Source
|
|
2555
|
+
# metadata is an enhancement, not a requirement: retry once without
|
|
2556
|
+
# it and remember the capability so later calls skip the doomed
|
|
2557
|
+
# variant. Bounded — the retried payload has no ``include``.
|
|
2558
|
+
_RESPONSES_OMIT_INCLUDE_ENDPOINTS.add(include_omit_key)
|
|
2559
|
+
include_omitted = True
|
|
2560
|
+
payload = {key: value for key, value in payload.items() if key != "include"}
|
|
2561
|
+
response = _perform_request(payload)
|
|
2562
|
+
|
|
2563
|
+
try:
|
|
2564
|
+
data = response.json()
|
|
2565
|
+
except Exception as e: # noqa: BLE001
|
|
2566
|
+
raise ResponsesError("Responses API returned non-JSON response") from e
|
|
2567
|
+
|
|
2568
|
+
if not isinstance(data, dict):
|
|
2569
|
+
raise ResponsesError("Unexpected Responses API payload: expected JSON object")
|
|
2570
|
+
|
|
2571
|
+
if response.status_code >= 400:
|
|
2572
|
+
error_message = _extract_error_message(data)
|
|
2573
|
+
if error_message:
|
|
2574
|
+
lower = error_message.lower()
|
|
2575
|
+
if "unsupported" in lower or "not support" in lower:
|
|
2576
|
+
raise ResponsesError(f"Responses web_search unsupported: {error_message}")
|
|
2577
|
+
raise ResponsesError(f"Responses error {response.status_code}: {error_message}")
|
|
2578
|
+
raise ResponsesError(f"Responses error {response.status_code}: {data!r}")
|
|
2579
|
+
|
|
2580
|
+
answer = _extract_answer_text(data)
|
|
2581
|
+
citations = _extract_citations(data)
|
|
2582
|
+
sources, queries = _extract_sources_and_queries(data)
|
|
2583
|
+
sources = _merge_citation_sources(sources, citations)
|
|
2584
|
+
if not sources:
|
|
2585
|
+
# Absent source metadata is tolerated only when it was not
|
|
2586
|
+
# requested (the gateway rejected the optional include) AND the
|
|
2587
|
+
# response still shows a real search happened. A response with no
|
|
2588
|
+
# web_search_call at all never searched — treat it as a failure so
|
|
2589
|
+
# callers (e.g. auto-mode fallback) can engage a working backend
|
|
2590
|
+
# instead of accepting an unsourced answer.
|
|
2591
|
+
if not (include_omitted and _has_web_search_call_output(data)):
|
|
2592
|
+
raise ResponsesError("Responses web_search did not return sources")
|
|
2593
|
+
|
|
2594
|
+
response_id = str(data.get("id") or "").strip() or None
|
|
2595
|
+
response_model = str(data.get("model") or "").strip() or None
|
|
2596
|
+
return WebSearchResponse(
|
|
2597
|
+
answer=answer,
|
|
2598
|
+
citations=citations,
|
|
2599
|
+
sources=sources,
|
|
2600
|
+
queries=queries,
|
|
2601
|
+
raw=data,
|
|
2602
|
+
response_id=response_id,
|
|
2603
|
+
model=response_model,
|
|
2604
|
+
)
|