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,2652 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..error_text import sanitize_error_text_for_output
|
|
16
|
+
from ..provider_telemetry import ProviderCallTelemetryRecorder
|
|
17
|
+
from ..reasoning_contracts import WIRE_THINKING_LEVEL, reasoning_contract_for
|
|
18
|
+
from ..request_estimation import estimate_provider_payload_tokens
|
|
19
|
+
from ..token_budget import estimate_tokens
|
|
20
|
+
from ..web_search_adapters import AUTO_WEB_SEARCH_ADAPTER, GEMINI_GROUNDING_ADAPTER
|
|
21
|
+
from .cache_policy import merge_cache_policy_metadata
|
|
22
|
+
from .metadata import (
|
|
23
|
+
GEMINI_GENERATE_CONTENT_PROVIDER_METADATA_KEY,
|
|
24
|
+
PROVIDER_METADATA_KEY,
|
|
25
|
+
TOOL_CALL_PROVIDER_METADATA_KEY,
|
|
26
|
+
ProviderRouteIdentity,
|
|
27
|
+
build_provider_route_identity,
|
|
28
|
+
canonicalize_extra_headers,
|
|
29
|
+
credential_scope_fingerprint,
|
|
30
|
+
gate_messages_for_provider_route,
|
|
31
|
+
merge_canonical_headers,
|
|
32
|
+
stamp_response_for_route,
|
|
33
|
+
)
|
|
34
|
+
from .provider_limits import (
|
|
35
|
+
DEFAULT_PROVIDER_CONCURRENCY_CAPS,
|
|
36
|
+
ProviderRetrySettings,
|
|
37
|
+
best_effort_provider_key,
|
|
38
|
+
mark_provider_call_non_retryable,
|
|
39
|
+
run_provider_limited_call,
|
|
40
|
+
)
|
|
41
|
+
from .request_plan import LLMRequestPlan, RequestCachePlan
|
|
42
|
+
from .request_shape import build_request_shape_report
|
|
43
|
+
from .streaming import SSEFrame, iter_sse_frames, parse_sse_json_frame
|
|
44
|
+
from .temperature_compat import documented_temperature_omit_reason
|
|
45
|
+
from .types import (
|
|
46
|
+
InputTokenCount,
|
|
47
|
+
LLMError,
|
|
48
|
+
LLMResponse,
|
|
49
|
+
LLMUsage,
|
|
50
|
+
ReasoningOutput,
|
|
51
|
+
ReasoningOutputKind,
|
|
52
|
+
ToolCall,
|
|
53
|
+
UsageConfidence,
|
|
54
|
+
UsageContract,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
_LOGGER = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
_DEFAULT_ACCEPT_ENCODING = "identity"
|
|
60
|
+
_GEMINI_METADATA_KEY = GEMINI_GENERATE_CONTENT_PROVIDER_METADATA_KEY
|
|
61
|
+
_ALYSIS_WEB_SEARCH_FUNCTION_NAME = "web_search"
|
|
62
|
+
_WEB_SEARCH_MODES_ALLOWING_GEMINI_GROUNDING = frozenset({"auto", "native"})
|
|
63
|
+
_INCLUDE_SERVER_SIDE_TOOL_INVOCATIONS = "includeServerSideToolInvocations"
|
|
64
|
+
_TOOL_CALL_PROVIDER_METADATA_KEY = TOOL_CALL_PROVIDER_METADATA_KEY
|
|
65
|
+
_DUMMY_IMPORTED_FUNCTION_CALL_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
|
66
|
+
_GEMINI_EXPLICIT_CACHE_MIN_TOKENS = 1024
|
|
67
|
+
_GEMINI_EXPLICIT_CACHE_MAX_ENTRIES = 8
|
|
68
|
+
_GEMINI_EXPLICIT_CACHE_REFRESH_FRACTION = 0.10
|
|
69
|
+
_GEMINI_EXPLICIT_CACHE_REFRESH_MAX_SECONDS = 60.0
|
|
70
|
+
_GEMINI_EXPLICIT_CACHE_REFRESH_MIN_SECONDS = 1.0
|
|
71
|
+
_GEMINI_EXPLICIT_CACHE_TRANSIENT_CREATE_FAILURE_LIMIT = 3
|
|
72
|
+
_GEMINI_EXPLICIT_CACHE_EVICT_ALL_TIMEOUT_S = 5.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _non_negative_int(value: Any) -> int | None:
|
|
76
|
+
try:
|
|
77
|
+
parsed = int(value)
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return None
|
|
80
|
+
return parsed if parsed >= 0 else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _normalize_cached_content_min_tokens(value: int | None) -> int:
|
|
84
|
+
try:
|
|
85
|
+
number = int(value)
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
number = _GEMINI_EXPLICIT_CACHE_MIN_TOKENS
|
|
88
|
+
return max(0, number)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_cached_content_max_entries(value: int | None) -> int:
|
|
92
|
+
try:
|
|
93
|
+
number = int(value)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
number = _GEMINI_EXPLICIT_CACHE_MAX_ENTRIES
|
|
96
|
+
return max(1, number)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_cached_content_ttl_seconds(value: Any) -> float | None:
|
|
100
|
+
text = str(value or "").strip().lower()
|
|
101
|
+
if not text:
|
|
102
|
+
return None
|
|
103
|
+
multiplier = 1.0
|
|
104
|
+
if text[-1:] in {"s", "m", "h"}:
|
|
105
|
+
unit = text[-1]
|
|
106
|
+
text = text[:-1].strip()
|
|
107
|
+
multiplier = {"s": 1.0, "m": 60.0, "h": 3600.0}[unit]
|
|
108
|
+
try:
|
|
109
|
+
seconds = float(text) * multiplier
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
return None
|
|
112
|
+
if seconds <= 0:
|
|
113
|
+
return None
|
|
114
|
+
return seconds
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cached_content_refresh_margin_seconds(ttl_seconds: float | None) -> float | None:
|
|
118
|
+
if ttl_seconds is None:
|
|
119
|
+
return None
|
|
120
|
+
margin = min(
|
|
121
|
+
_GEMINI_EXPLICIT_CACHE_REFRESH_MAX_SECONDS,
|
|
122
|
+
max(
|
|
123
|
+
_GEMINI_EXPLICIT_CACHE_REFRESH_MIN_SECONDS,
|
|
124
|
+
ttl_seconds * _GEMINI_EXPLICIT_CACHE_REFRESH_FRACTION,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
if margin >= ttl_seconds:
|
|
128
|
+
margin = ttl_seconds / 2.0
|
|
129
|
+
return max(0.0, margin)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cached_content_resource_url(base_url: str, name: str) -> str:
|
|
133
|
+
resource = str(name or "").strip()
|
|
134
|
+
if resource.startswith(("http://", "https://")):
|
|
135
|
+
return resource
|
|
136
|
+
if "/" not in resource:
|
|
137
|
+
resource = f"cachedContents/{quote(resource, safe='')}"
|
|
138
|
+
return f"{base_url.rstrip('/')}/{resource.lstrip('/')}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _rounded_non_negative_seconds(value: float | None) -> int | None:
|
|
142
|
+
if value is None:
|
|
143
|
+
return None
|
|
144
|
+
return max(0, int(round(value)))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cached_content_create_usage_tokens(raw: Any) -> int | None:
|
|
148
|
+
if not isinstance(raw, dict):
|
|
149
|
+
return None
|
|
150
|
+
try:
|
|
151
|
+
tokens = int(raw.get("totalTokenCount"))
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
return None
|
|
154
|
+
return tokens if tokens >= 0 else None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _trimmed_create_error_detail(detail: str, *, limit: int = 300) -> str:
|
|
158
|
+
text = " ".join(str(detail or "").split())
|
|
159
|
+
if len(text) > limit:
|
|
160
|
+
return text[:limit] + "...(truncated)"
|
|
161
|
+
return text
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _cached_content_create_failure_is_transient(status_code: int | None) -> bool:
|
|
165
|
+
# Discriminate by HTTP semantics at runtime (never per-provider tables):
|
|
166
|
+
# transport failures, timeouts, throttling, and server errors may succeed
|
|
167
|
+
# on a later attempt, while other 4xx rejections are deterministic for
|
|
168
|
+
# this client configuration.
|
|
169
|
+
if status_code is None:
|
|
170
|
+
return True
|
|
171
|
+
return status_code in {408, 429} or status_code >= 500
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class _GeminiCachedContentPlan:
|
|
176
|
+
signature: str
|
|
177
|
+
create_payload: dict[str, Any]
|
|
178
|
+
suffix_contents: list[dict[str, Any]]
|
|
179
|
+
estimated_tokens: int
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class _GeminiCachedContentEntry:
|
|
184
|
+
name: str
|
|
185
|
+
signature: str
|
|
186
|
+
created_at: float
|
|
187
|
+
last_used_at: float
|
|
188
|
+
ttl_seconds: float | None = None
|
|
189
|
+
refresh_after: float | None = None
|
|
190
|
+
expires_at: float | None = None
|
|
191
|
+
estimated_tokens: int = 0
|
|
192
|
+
# Creation spend already billed by the provider but not yet attached to a
|
|
193
|
+
# successful response's usage; cleared once reported so retries that reuse
|
|
194
|
+
# the entry report it exactly once.
|
|
195
|
+
pending_creation_tokens: int | None = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _headers_with_default_accept_encoding(headers: dict[str, str]) -> dict[str, str]:
|
|
199
|
+
request_headers = dict(headers)
|
|
200
|
+
if not any(key.lower() == "accept-encoding" for key in request_headers):
|
|
201
|
+
request_headers["accept-encoding"] = _DEFAULT_ACCEPT_ENCODING
|
|
202
|
+
return request_headers
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _gemini_native_base_url(base_url: str) -> str:
|
|
206
|
+
normalized = str(base_url or "").strip().rstrip("/")
|
|
207
|
+
if normalized.endswith("/openai"):
|
|
208
|
+
return normalized.removesuffix("/openai")
|
|
209
|
+
return normalized
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _gemini_model_resource_name(model: str) -> str:
|
|
213
|
+
normalized = str(model or "").strip()
|
|
214
|
+
if normalized.startswith("models/"):
|
|
215
|
+
return normalized
|
|
216
|
+
return f"models/{normalized}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _gemini_cached_content_rejection_reason(response: httpx.Response) -> str | None:
|
|
220
|
+
if response.status_code not in {400, 403, 404, 410}:
|
|
221
|
+
return None
|
|
222
|
+
try:
|
|
223
|
+
data = response.json()
|
|
224
|
+
except Exception:
|
|
225
|
+
data = response.text
|
|
226
|
+
rendered = json.dumps(data, ensure_ascii=False, sort_keys=True).casefold()
|
|
227
|
+
if not any(
|
|
228
|
+
marker in rendered for marker in ("cachedcontent", "cached content", "cached_content")
|
|
229
|
+
):
|
|
230
|
+
return None
|
|
231
|
+
if any(
|
|
232
|
+
marker in rendered
|
|
233
|
+
for marker in (
|
|
234
|
+
"not found",
|
|
235
|
+
"not_found",
|
|
236
|
+
"not exist",
|
|
237
|
+
"does not exist",
|
|
238
|
+
"expired",
|
|
239
|
+
"deleted",
|
|
240
|
+
"invalid",
|
|
241
|
+
"permission",
|
|
242
|
+
"denied",
|
|
243
|
+
"gone",
|
|
244
|
+
)
|
|
245
|
+
):
|
|
246
|
+
return "stale_cached_content"
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _stable_digest(value: Any) -> str:
|
|
251
|
+
payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
252
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _response_with_cache_metadata(
|
|
256
|
+
response: LLMResponse,
|
|
257
|
+
cache_metadata: dict[str, Any] | None,
|
|
258
|
+
request_plan_metadata: dict[str, Any] | None = None,
|
|
259
|
+
cache_creation_input_tokens: int | None = None,
|
|
260
|
+
) -> LLMResponse:
|
|
261
|
+
if not cache_metadata and not request_plan_metadata and cache_creation_input_tokens is None:
|
|
262
|
+
return response
|
|
263
|
+
provider_metadata = copy.deepcopy(response.provider_metadata) or {}
|
|
264
|
+
gemini_metadata = provider_metadata.setdefault(_GEMINI_METADATA_KEY, {})
|
|
265
|
+
if isinstance(gemini_metadata, dict):
|
|
266
|
+
if cache_metadata:
|
|
267
|
+
gemini_metadata["cache_policy"] = copy.deepcopy(cache_metadata)
|
|
268
|
+
if request_plan_metadata:
|
|
269
|
+
gemini_metadata["request_plan"] = copy.deepcopy(request_plan_metadata)
|
|
270
|
+
usage = response.usage
|
|
271
|
+
if cache_creation_input_tokens is not None:
|
|
272
|
+
if usage is None:
|
|
273
|
+
usage = LLMUsage(
|
|
274
|
+
prompt_tokens=None,
|
|
275
|
+
completion_tokens=None,
|
|
276
|
+
total_tokens=None,
|
|
277
|
+
cache_creation_input_tokens=cache_creation_input_tokens,
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
usage = replace(
|
|
281
|
+
usage,
|
|
282
|
+
cache_creation_input_tokens=(
|
|
283
|
+
(usage.cache_creation_input_tokens or 0) + cache_creation_input_tokens
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
return LLMResponse(
|
|
287
|
+
content=response.content,
|
|
288
|
+
tool_calls=response.tool_calls,
|
|
289
|
+
raw=response.raw,
|
|
290
|
+
response_model=response.response_model,
|
|
291
|
+
usage=usage,
|
|
292
|
+
provider_metadata=provider_metadata,
|
|
293
|
+
reasoning=response.reasoning,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _content_to_text(raw: Any) -> str:
|
|
298
|
+
if raw is None:
|
|
299
|
+
return ""
|
|
300
|
+
if isinstance(raw, str):
|
|
301
|
+
return raw
|
|
302
|
+
if isinstance(raw, list):
|
|
303
|
+
parts: list[str] = []
|
|
304
|
+
for item in raw:
|
|
305
|
+
if isinstance(item, str):
|
|
306
|
+
parts.append(item)
|
|
307
|
+
elif isinstance(item, dict):
|
|
308
|
+
text = item.get("text") or item.get("content")
|
|
309
|
+
if isinstance(text, str):
|
|
310
|
+
parts.append(text)
|
|
311
|
+
return "".join(parts)
|
|
312
|
+
if isinstance(raw, dict):
|
|
313
|
+
text = raw.get("text") or raw.get("content")
|
|
314
|
+
return text if isinstance(text, str) else json.dumps(raw, ensure_ascii=False)
|
|
315
|
+
return str(raw)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _json_arguments(args: Any) -> dict[str, Any]:
|
|
319
|
+
if isinstance(args, dict):
|
|
320
|
+
return dict(args)
|
|
321
|
+
if args is None:
|
|
322
|
+
return {}
|
|
323
|
+
if isinstance(args, str):
|
|
324
|
+
try:
|
|
325
|
+
parsed = json.loads(args)
|
|
326
|
+
except json.JSONDecodeError:
|
|
327
|
+
return {"_raw_arguments": args}
|
|
328
|
+
if isinstance(parsed, dict):
|
|
329
|
+
return parsed
|
|
330
|
+
return {"_raw_arguments": args}
|
|
331
|
+
return {"_raw_arguments": json.dumps(args, ensure_ascii=False)}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _json_response_payload(value: Any) -> dict[str, Any]:
|
|
335
|
+
if isinstance(value, dict):
|
|
336
|
+
return dict(value)
|
|
337
|
+
if value is None:
|
|
338
|
+
return {}
|
|
339
|
+
if isinstance(value, str):
|
|
340
|
+
try:
|
|
341
|
+
parsed = json.loads(value)
|
|
342
|
+
except json.JSONDecodeError:
|
|
343
|
+
return {"result": value}
|
|
344
|
+
if isinstance(parsed, dict):
|
|
345
|
+
return parsed
|
|
346
|
+
return {"result": parsed}
|
|
347
|
+
return {"result": value}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _gemini_parts_from_content(raw: Any) -> list[dict[str, Any]]:
|
|
351
|
+
if raw is None:
|
|
352
|
+
return []
|
|
353
|
+
if isinstance(raw, str):
|
|
354
|
+
return [{"text": raw}] if raw else []
|
|
355
|
+
if not isinstance(raw, list):
|
|
356
|
+
text = _content_to_text(raw)
|
|
357
|
+
return [{"text": text}] if text else []
|
|
358
|
+
|
|
359
|
+
parts: list[dict[str, Any]] = []
|
|
360
|
+
for item in raw:
|
|
361
|
+
if isinstance(item, str):
|
|
362
|
+
if item:
|
|
363
|
+
parts.append({"text": item})
|
|
364
|
+
continue
|
|
365
|
+
if not isinstance(item, dict):
|
|
366
|
+
continue
|
|
367
|
+
part_type = str(item.get("type") or "").strip()
|
|
368
|
+
text = item.get("text") or item.get("content")
|
|
369
|
+
if part_type in {"text", "input_text", "output_text"} and isinstance(text, str):
|
|
370
|
+
parts.append({"text": text})
|
|
371
|
+
continue
|
|
372
|
+
if part_type == "image_url":
|
|
373
|
+
image_url = item.get("image_url")
|
|
374
|
+
url = ""
|
|
375
|
+
if isinstance(image_url, dict):
|
|
376
|
+
url = str(image_url.get("url") or "").strip()
|
|
377
|
+
elif isinstance(image_url, str):
|
|
378
|
+
url = image_url.strip()
|
|
379
|
+
if url:
|
|
380
|
+
parts.append({"fileData": {"fileUri": url}})
|
|
381
|
+
continue
|
|
382
|
+
if any(
|
|
383
|
+
key in item for key in ("functionCall", "functionResponse", "inlineData", "fileData")
|
|
384
|
+
):
|
|
385
|
+
parts.append(copy.deepcopy(item))
|
|
386
|
+
return parts
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _metadata_content(message: dict[str, Any]) -> dict[str, Any] | None:
|
|
390
|
+
metadata = message.get(PROVIDER_METADATA_KEY)
|
|
391
|
+
if not isinstance(metadata, dict):
|
|
392
|
+
return None
|
|
393
|
+
gemini_metadata = metadata.get(_GEMINI_METADATA_KEY)
|
|
394
|
+
if not isinstance(gemini_metadata, dict):
|
|
395
|
+
return None
|
|
396
|
+
content = gemini_metadata.get("content")
|
|
397
|
+
if isinstance(content, dict):
|
|
398
|
+
return _normalize_gemini_content_wire_keys(content)
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _normalize_gemini_content_wire_keys(content: dict[str, Any]) -> dict[str, Any]:
|
|
403
|
+
copied = copy.deepcopy(content)
|
|
404
|
+
parts = copied.get("parts")
|
|
405
|
+
if not isinstance(parts, list):
|
|
406
|
+
return copied
|
|
407
|
+
for part in parts:
|
|
408
|
+
if not isinstance(part, dict):
|
|
409
|
+
continue
|
|
410
|
+
if "thought_signature" in part:
|
|
411
|
+
if "thoughtSignature" not in part:
|
|
412
|
+
part["thoughtSignature"] = copy.deepcopy(part["thought_signature"])
|
|
413
|
+
part.pop("thought_signature", None)
|
|
414
|
+
return copied
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _function_name_from_tool(tool: dict[str, Any]) -> str:
|
|
418
|
+
function = tool.get("function")
|
|
419
|
+
if isinstance(function, dict):
|
|
420
|
+
return str(function.get("name") or "").strip()
|
|
421
|
+
if str(tool.get("type") or "") == "function":
|
|
422
|
+
return str(tool.get("name") or "").strip()
|
|
423
|
+
return ""
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _is_alysis_web_search_function(tool: dict[str, Any]) -> bool:
|
|
427
|
+
return _function_name_from_tool(tool) == _ALYSIS_WEB_SEARCH_FUNCTION_NAME
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _is_gemini_grounding_tool(tool: dict[str, Any]) -> bool:
|
|
431
|
+
return isinstance(tool.get("google_search"), dict) or isinstance(tool.get("googleSearch"), dict)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _gemini_grounding_allowed(*, mode: str, adapter: str) -> bool:
|
|
435
|
+
normalized_mode = str(mode or "").strip().lower()
|
|
436
|
+
normalized_adapter = str(adapter or "").strip().lower() or AUTO_WEB_SEARCH_ADAPTER
|
|
437
|
+
if normalized_mode not in _WEB_SEARCH_MODES_ALLOWING_GEMINI_GROUNDING:
|
|
438
|
+
return False
|
|
439
|
+
return normalized_adapter in {AUTO_WEB_SEARCH_ADAPTER, GEMINI_GROUNDING_ADAPTER}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@dataclass(frozen=True)
|
|
443
|
+
class _GeminiToolMapping:
|
|
444
|
+
tools: list[dict[str, Any]]
|
|
445
|
+
added_google_search: bool
|
|
446
|
+
removed_alysis_web_search: bool
|
|
447
|
+
include_server_side_tool_invocations: bool
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _gemini_function_declaration_from_chat_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
|
|
451
|
+
if not isinstance(tool, dict):
|
|
452
|
+
return None
|
|
453
|
+
tool_type = str(tool.get("type") or "").strip()
|
|
454
|
+
if tool_type != "function":
|
|
455
|
+
if _is_gemini_grounding_tool(tool):
|
|
456
|
+
return None
|
|
457
|
+
raise LLMError(f"Gemini GenerateContent does not support tool type {tool_type!r}")
|
|
458
|
+
function = tool.get("function")
|
|
459
|
+
source = function if isinstance(function, dict) else tool
|
|
460
|
+
name = str(source.get("name") or "").strip()
|
|
461
|
+
if not name:
|
|
462
|
+
return None
|
|
463
|
+
declaration: dict[str, Any] = {
|
|
464
|
+
"name": name,
|
|
465
|
+
"parameters": copy.deepcopy(source.get("parameters") or {"type": "object"}),
|
|
466
|
+
}
|
|
467
|
+
description = str(source.get("description") or "").strip()
|
|
468
|
+
if description:
|
|
469
|
+
declaration["description"] = description
|
|
470
|
+
return declaration
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _gemini_tools(
|
|
474
|
+
tools: list[dict[str, Any]] | None,
|
|
475
|
+
*,
|
|
476
|
+
mode: str,
|
|
477
|
+
adapter: str,
|
|
478
|
+
) -> _GeminiToolMapping:
|
|
479
|
+
normalized_mode = str(mode or "off").strip().lower()
|
|
480
|
+
normalized_adapter = (
|
|
481
|
+
str(adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower() or AUTO_WEB_SEARCH_ADAPTER
|
|
482
|
+
)
|
|
483
|
+
raw_tools = [tool for tool in tools or [] if isinstance(tool, dict)]
|
|
484
|
+
alysis_web_search_present = any(_is_alysis_web_search_function(tool) for tool in raw_tools)
|
|
485
|
+
use_google_search = alysis_web_search_present and _gemini_grounding_allowed(
|
|
486
|
+
mode=normalized_mode, adapter=normalized_adapter
|
|
487
|
+
)
|
|
488
|
+
if normalized_mode == "native" and alysis_web_search_present and not use_google_search:
|
|
489
|
+
raise LLMError(
|
|
490
|
+
"web_search_mode=native with protocol=gemini_generate_content requires "
|
|
491
|
+
"web_search_adapter='auto' or 'gemini_grounding' for Gemini Google Search grounding; "
|
|
492
|
+
f"got {normalized_adapter!r}"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
function_declarations: list[dict[str, Any]] = []
|
|
496
|
+
mapped_tools: list[dict[str, Any]] = []
|
|
497
|
+
removed_alysis_web_search = False
|
|
498
|
+
for tool in raw_tools:
|
|
499
|
+
if _is_alysis_web_search_function(tool):
|
|
500
|
+
if normalized_mode in {"off", "native"} or use_google_search:
|
|
501
|
+
removed_alysis_web_search = True
|
|
502
|
+
continue
|
|
503
|
+
if _is_gemini_grounding_tool(tool):
|
|
504
|
+
if normalized_mode in {"off", "external"}:
|
|
505
|
+
continue
|
|
506
|
+
mapped_tools.append(copy.deepcopy(tool))
|
|
507
|
+
continue
|
|
508
|
+
declaration = _gemini_function_declaration_from_chat_tool(tool)
|
|
509
|
+
if declaration is not None:
|
|
510
|
+
function_declarations.append(declaration)
|
|
511
|
+
if function_declarations:
|
|
512
|
+
mapped_tools.insert(0, {"functionDeclarations": function_declarations})
|
|
513
|
+
if use_google_search and not any(_is_gemini_grounding_tool(tool) for tool in mapped_tools):
|
|
514
|
+
mapped_tools.append({"google_search": {}})
|
|
515
|
+
include_server_side_tool_invocations = bool(function_declarations) and any(
|
|
516
|
+
_is_gemini_grounding_tool(tool) for tool in mapped_tools
|
|
517
|
+
)
|
|
518
|
+
return _GeminiToolMapping(
|
|
519
|
+
tools=mapped_tools,
|
|
520
|
+
added_google_search=use_google_search,
|
|
521
|
+
removed_alysis_web_search=removed_alysis_web_search,
|
|
522
|
+
include_server_side_tool_invocations=include_server_side_tool_invocations,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _gemini_tool_choice(
|
|
527
|
+
tool_choice: Any,
|
|
528
|
+
*,
|
|
529
|
+
removed_alysis_web_search: bool,
|
|
530
|
+
added_google_search: bool,
|
|
531
|
+
) -> dict[str, Any] | None:
|
|
532
|
+
if tool_choice is None:
|
|
533
|
+
return None
|
|
534
|
+
if isinstance(tool_choice, str):
|
|
535
|
+
normalized = tool_choice.strip()
|
|
536
|
+
if normalized == "auto":
|
|
537
|
+
return {"functionCallingConfig": {"mode": "AUTO"}}
|
|
538
|
+
if normalized == "none":
|
|
539
|
+
return {"functionCallingConfig": {"mode": "NONE"}}
|
|
540
|
+
if normalized == "required":
|
|
541
|
+
return {"functionCallingConfig": {"mode": "ANY"}}
|
|
542
|
+
raise LLMError(f"Gemini GenerateContent does not support tool_choice={tool_choice!r}")
|
|
543
|
+
if not isinstance(tool_choice, dict):
|
|
544
|
+
raise LLMError("Gemini GenerateContent tool_choice must be a string or object")
|
|
545
|
+
|
|
546
|
+
choice_type = str(tool_choice.get("type") or "").strip()
|
|
547
|
+
if choice_type == "function":
|
|
548
|
+
if "name" in tool_choice:
|
|
549
|
+
name = str(tool_choice.get("name") or "").strip()
|
|
550
|
+
else:
|
|
551
|
+
function = tool_choice.get("function")
|
|
552
|
+
name = str(function.get("name") or "").strip() if isinstance(function, dict) else ""
|
|
553
|
+
if not name:
|
|
554
|
+
raise LLMError("Gemini GenerateContent forced function tool_choice is missing name")
|
|
555
|
+
if (
|
|
556
|
+
name == _ALYSIS_WEB_SEARCH_FUNCTION_NAME
|
|
557
|
+
and removed_alysis_web_search
|
|
558
|
+
and not added_google_search
|
|
559
|
+
):
|
|
560
|
+
raise LLMError(
|
|
561
|
+
"Gemini GenerateContent removed the Alysis Code web_search function for the "
|
|
562
|
+
"selected web_search_mode; do not force tool_choice to function web_search"
|
|
563
|
+
)
|
|
564
|
+
if name == _ALYSIS_WEB_SEARCH_FUNCTION_NAME and added_google_search:
|
|
565
|
+
raise LLMError(
|
|
566
|
+
"Gemini GenerateContent cannot force Google Search grounding via tool_choice"
|
|
567
|
+
)
|
|
568
|
+
return {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": [name]}}
|
|
569
|
+
if choice_type in {"auto", "any", "none"}:
|
|
570
|
+
mode = "ANY" if choice_type == "any" else choice_type.upper()
|
|
571
|
+
return {"functionCallingConfig": {"mode": mode}}
|
|
572
|
+
raise LLMError(f"Gemini GenerateContent does not support tool_choice type {choice_type!r}")
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _system_instruction_from_parts(parts: list[str]) -> dict[str, Any] | None:
|
|
576
|
+
text = "\n\n".join(part for part in parts if part).strip()
|
|
577
|
+
if not text:
|
|
578
|
+
return None
|
|
579
|
+
return {"parts": [{"text": text}]}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _tool_call_provider_metadata_indexes(
|
|
583
|
+
message: dict[str, Any],
|
|
584
|
+
) -> tuple[dict[str, dict[str, Any]], dict[int, dict[str, Any]]]:
|
|
585
|
+
metadata = message.get(PROVIDER_METADATA_KEY)
|
|
586
|
+
if not isinstance(metadata, dict):
|
|
587
|
+
return {}, {}
|
|
588
|
+
entries = metadata.get(_TOOL_CALL_PROVIDER_METADATA_KEY)
|
|
589
|
+
if not isinstance(entries, list):
|
|
590
|
+
return {}, {}
|
|
591
|
+
by_id: dict[str, dict[str, Any]] = {}
|
|
592
|
+
by_index: dict[int, dict[str, Any]] = {}
|
|
593
|
+
for entry in entries:
|
|
594
|
+
if not isinstance(entry, dict):
|
|
595
|
+
continue
|
|
596
|
+
entry_metadata = entry.get("metadata")
|
|
597
|
+
if not isinstance(entry_metadata, dict):
|
|
598
|
+
continue
|
|
599
|
+
gemini_metadata = entry_metadata.get(_GEMINI_METADATA_KEY)
|
|
600
|
+
if not isinstance(gemini_metadata, dict):
|
|
601
|
+
continue
|
|
602
|
+
copied = copy.deepcopy(gemini_metadata)
|
|
603
|
+
tool_call_id = entry.get("id")
|
|
604
|
+
if isinstance(tool_call_id, str) and tool_call_id.strip():
|
|
605
|
+
by_id[tool_call_id.strip()] = copied
|
|
606
|
+
index = entry.get("index")
|
|
607
|
+
if isinstance(index, int):
|
|
608
|
+
by_index[index] = copied
|
|
609
|
+
return by_id, by_index
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _apply_gemini_thought_signature(
|
|
613
|
+
part: dict[str, Any],
|
|
614
|
+
metadata: dict[str, Any] | None,
|
|
615
|
+
) -> None:
|
|
616
|
+
if isinstance(metadata, dict):
|
|
617
|
+
if "thoughtSignature" in metadata:
|
|
618
|
+
part["thoughtSignature"] = copy.deepcopy(metadata["thoughtSignature"])
|
|
619
|
+
return
|
|
620
|
+
if "thought_signature" in metadata:
|
|
621
|
+
part["thoughtSignature"] = copy.deepcopy(metadata["thought_signature"])
|
|
622
|
+
return
|
|
623
|
+
part["thoughtSignature"] = _DUMMY_IMPORTED_FUNCTION_CALL_THOUGHT_SIGNATURE
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _tool_call_parts_from_message(message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
627
|
+
raw_tool_calls = message.get("tool_calls")
|
|
628
|
+
if not isinstance(raw_tool_calls, list):
|
|
629
|
+
return []
|
|
630
|
+
parts: list[dict[str, Any]] = []
|
|
631
|
+
metadata_by_id, metadata_by_index = _tool_call_provider_metadata_indexes(message)
|
|
632
|
+
for raw_tool_call in raw_tool_calls:
|
|
633
|
+
if not isinstance(raw_tool_call, dict):
|
|
634
|
+
continue
|
|
635
|
+
tool_call_index = len(parts)
|
|
636
|
+
call_id = str(raw_tool_call.get("id") or raw_tool_call.get("call_id") or "").strip()
|
|
637
|
+
function = raw_tool_call.get("function")
|
|
638
|
+
if isinstance(function, dict):
|
|
639
|
+
name = str(function.get("name") or "").strip()
|
|
640
|
+
args = _json_arguments(function.get("arguments"))
|
|
641
|
+
else:
|
|
642
|
+
name = str(raw_tool_call.get("name") or "").strip()
|
|
643
|
+
args = _json_arguments(raw_tool_call.get("arguments"))
|
|
644
|
+
if not name:
|
|
645
|
+
continue
|
|
646
|
+
function_call: dict[str, Any] = {"name": name, "args": args}
|
|
647
|
+
if call_id:
|
|
648
|
+
function_call["id"] = call_id
|
|
649
|
+
part = {"functionCall": function_call}
|
|
650
|
+
metadata = metadata_by_id.get(call_id) if call_id else None
|
|
651
|
+
if metadata is None:
|
|
652
|
+
metadata = metadata_by_index.get(tool_call_index)
|
|
653
|
+
_apply_gemini_thought_signature(part, metadata)
|
|
654
|
+
parts.append(part)
|
|
655
|
+
return parts
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _collect_function_call_names(content: dict[str, Any], call_names: dict[str, str]) -> None:
|
|
659
|
+
parts = content.get("parts")
|
|
660
|
+
if not isinstance(parts, list):
|
|
661
|
+
return
|
|
662
|
+
for part in parts:
|
|
663
|
+
if not isinstance(part, dict):
|
|
664
|
+
continue
|
|
665
|
+
function_call = part.get("functionCall")
|
|
666
|
+
if not isinstance(function_call, dict):
|
|
667
|
+
continue
|
|
668
|
+
call_id = str(function_call.get("id") or "").strip()
|
|
669
|
+
name = str(function_call.get("name") or "").strip()
|
|
670
|
+
if call_id and name:
|
|
671
|
+
call_names[call_id] = name
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _is_function_response_user_content(content: dict[str, Any]) -> bool:
|
|
675
|
+
if str(content.get("role") or "") != "user":
|
|
676
|
+
return False
|
|
677
|
+
parts = content.get("parts")
|
|
678
|
+
return (
|
|
679
|
+
isinstance(parts, list)
|
|
680
|
+
and bool(parts)
|
|
681
|
+
and all(
|
|
682
|
+
isinstance(part, dict) and isinstance(part.get("functionResponse"), dict)
|
|
683
|
+
for part in parts
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _gemini_contents_from_messages(
|
|
689
|
+
messages: list[dict[str, Any]],
|
|
690
|
+
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
|
691
|
+
system_parts: list[str] = []
|
|
692
|
+
contents: list[dict[str, Any]] = []
|
|
693
|
+
call_names: dict[str, str] = {}
|
|
694
|
+
for message in messages:
|
|
695
|
+
if not isinstance(message, dict):
|
|
696
|
+
continue
|
|
697
|
+
role = str(message.get("role") or "").strip()
|
|
698
|
+
if role in {"system", "developer"}:
|
|
699
|
+
text = _content_to_text(message.get("content")).strip()
|
|
700
|
+
if text:
|
|
701
|
+
system_parts.append(text)
|
|
702
|
+
continue
|
|
703
|
+
if role == "user":
|
|
704
|
+
parts = _gemini_parts_from_content(message.get("content"))
|
|
705
|
+
if parts:
|
|
706
|
+
contents.append({"role": "user", "parts": parts})
|
|
707
|
+
continue
|
|
708
|
+
if role == "assistant":
|
|
709
|
+
metadata_content = _metadata_content(message)
|
|
710
|
+
if metadata_content is not None:
|
|
711
|
+
contents.append(metadata_content)
|
|
712
|
+
_collect_function_call_names(metadata_content, call_names)
|
|
713
|
+
continue
|
|
714
|
+
parts = _gemini_parts_from_content(message.get("content"))
|
|
715
|
+
parts.extend(_tool_call_parts_from_message(message))
|
|
716
|
+
if parts:
|
|
717
|
+
content = {"role": "model", "parts": parts}
|
|
718
|
+
contents.append(content)
|
|
719
|
+
_collect_function_call_names(content, call_names)
|
|
720
|
+
continue
|
|
721
|
+
if role == "tool":
|
|
722
|
+
call_id = str(message.get("tool_call_id") or message.get("call_id") or "").strip()
|
|
723
|
+
if not call_id:
|
|
724
|
+
raise LLMError("Gemini GenerateContent function response is missing tool_call_id")
|
|
725
|
+
name = str(message.get("name") or call_names.get(call_id) or "").strip()
|
|
726
|
+
if not name:
|
|
727
|
+
raise LLMError(
|
|
728
|
+
"Gemini GenerateContent function response is missing function name for "
|
|
729
|
+
f"tool_call_id={call_id!r}"
|
|
730
|
+
)
|
|
731
|
+
response_payload = _json_response_payload(message.get("content"))
|
|
732
|
+
function_response_part = {
|
|
733
|
+
"functionResponse": {
|
|
734
|
+
"id": call_id,
|
|
735
|
+
"name": name,
|
|
736
|
+
"response": response_payload,
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if contents and _is_function_response_user_content(contents[-1]):
|
|
740
|
+
parts = contents[-1].setdefault("parts", [])
|
|
741
|
+
if isinstance(parts, list):
|
|
742
|
+
parts.append(function_response_part)
|
|
743
|
+
else:
|
|
744
|
+
contents.append({"role": "user", "parts": [function_response_part]})
|
|
745
|
+
else:
|
|
746
|
+
contents.append({"role": "user", "parts": [function_response_part]})
|
|
747
|
+
continue
|
|
748
|
+
raise LLMError(f"Gemini GenerateContent cannot send message role {role!r}")
|
|
749
|
+
return _system_instruction_from_parts(system_parts), contents
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _explicit_cached_content_plan(
|
|
753
|
+
*,
|
|
754
|
+
model: str,
|
|
755
|
+
system_instruction: dict[str, Any] | None,
|
|
756
|
+
contents: list[dict[str, Any]],
|
|
757
|
+
ttl: str | None,
|
|
758
|
+
min_tokens: int,
|
|
759
|
+
) -> _GeminiCachedContentPlan | None:
|
|
760
|
+
if len(contents) < 2:
|
|
761
|
+
return None
|
|
762
|
+
cache_contents = copy.deepcopy(contents[:-1])
|
|
763
|
+
suffix_contents = copy.deepcopy(contents[-1:])
|
|
764
|
+
create_payload: dict[str, Any] = {
|
|
765
|
+
"model": _gemini_model_resource_name(model),
|
|
766
|
+
"contents": cache_contents,
|
|
767
|
+
}
|
|
768
|
+
if system_instruction is not None:
|
|
769
|
+
create_payload["systemInstruction"] = copy.deepcopy(system_instruction)
|
|
770
|
+
if ttl:
|
|
771
|
+
create_payload["ttl"] = ttl
|
|
772
|
+
estimated_tokens = estimate_tokens(
|
|
773
|
+
json.dumps(create_payload, ensure_ascii=False, sort_keys=True)
|
|
774
|
+
)
|
|
775
|
+
if estimated_tokens < max(0, int(min_tokens)):
|
|
776
|
+
return None
|
|
777
|
+
return _GeminiCachedContentPlan(
|
|
778
|
+
signature=_stable_digest(create_payload),
|
|
779
|
+
create_payload=create_payload,
|
|
780
|
+
suffix_contents=suffix_contents,
|
|
781
|
+
estimated_tokens=estimated_tokens,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _parse_usage(raw: Any) -> LLMUsage | None:
|
|
786
|
+
if not isinstance(raw, dict):
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
def _as_non_negative_int(value: Any) -> int | None:
|
|
790
|
+
try:
|
|
791
|
+
parsed = int(value) if value is not None else None
|
|
792
|
+
except (TypeError, ValueError):
|
|
793
|
+
return None
|
|
794
|
+
if parsed is None or parsed >= 0:
|
|
795
|
+
return parsed
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
prompt_tokens = _as_non_negative_int(raw.get("promptTokenCount"))
|
|
799
|
+
completion_tokens = _as_non_negative_int(raw.get("candidatesTokenCount"))
|
|
800
|
+
tool_use_prompt_tokens = _as_non_negative_int(raw.get("toolUsePromptTokenCount"))
|
|
801
|
+
thoughts_tokens = _as_non_negative_int(raw.get("thoughtsTokenCount"))
|
|
802
|
+
total_tokens = _as_non_negative_int(raw.get("totalTokenCount"))
|
|
803
|
+
cached_tokens = _as_non_negative_int(raw.get("cachedContentTokenCount"))
|
|
804
|
+
# Gemini reports tool-use prompts separately from the ordinary prompt and
|
|
805
|
+
# candidates separately from thinking. Fold each provider-owned component
|
|
806
|
+
# into the corresponding billing side so prompt+completion reconciles with
|
|
807
|
+
# totalTokenCount while the raw breakdown remains available for diagnostics.
|
|
808
|
+
if tool_use_prompt_tokens:
|
|
809
|
+
prompt_tokens = (prompt_tokens or 0) + tool_use_prompt_tokens
|
|
810
|
+
if thoughts_tokens:
|
|
811
|
+
completion_tokens = (completion_tokens or 0) + thoughts_tokens
|
|
812
|
+
if total_tokens is None and prompt_tokens is not None and completion_tokens is not None:
|
|
813
|
+
total_tokens = prompt_tokens + completion_tokens
|
|
814
|
+
input_tokens_uncached = None
|
|
815
|
+
if prompt_tokens is not None and cached_tokens is not None:
|
|
816
|
+
input_tokens_uncached = max(0, prompt_tokens - cached_tokens)
|
|
817
|
+
usage = LLMUsage(
|
|
818
|
+
prompt_tokens=prompt_tokens,
|
|
819
|
+
completion_tokens=completion_tokens,
|
|
820
|
+
total_tokens=total_tokens,
|
|
821
|
+
cached_prompt_tokens=cached_tokens,
|
|
822
|
+
input_tokens_uncached=input_tokens_uncached,
|
|
823
|
+
cache_read_input_tokens=cached_tokens,
|
|
824
|
+
reasoning_tokens=thoughts_tokens,
|
|
825
|
+
raw_provider_usage=copy.deepcopy(raw),
|
|
826
|
+
)
|
|
827
|
+
if (
|
|
828
|
+
usage.prompt_tokens is None
|
|
829
|
+
and usage.completion_tokens is None
|
|
830
|
+
and usage.total_tokens is None
|
|
831
|
+
and usage.cached_prompt_tokens is None
|
|
832
|
+
and usage.cache_read_input_tokens is None
|
|
833
|
+
):
|
|
834
|
+
return None
|
|
835
|
+
return usage
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _extract_error_message(data: Any) -> str | None:
|
|
839
|
+
if not isinstance(data, dict):
|
|
840
|
+
return None
|
|
841
|
+
error_obj = data.get("error")
|
|
842
|
+
if isinstance(error_obj, dict):
|
|
843
|
+
message = str(error_obj.get("message") or "").strip()
|
|
844
|
+
if message:
|
|
845
|
+
status = str(error_obj.get("status") or "").strip()
|
|
846
|
+
return f"{status}: {message}" if status else message
|
|
847
|
+
return None
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _gemini_thought_summary_request_rejected(response: httpx.Response) -> bool:
|
|
851
|
+
if response.status_code not in {400, 422}:
|
|
852
|
+
return False
|
|
853
|
+
detail = response.text.casefold()
|
|
854
|
+
names_summary_field = any(
|
|
855
|
+
marker in detail for marker in ("includethoughts", "include_thoughts", "thought summaries")
|
|
856
|
+
)
|
|
857
|
+
rejects_field = any(
|
|
858
|
+
marker in detail
|
|
859
|
+
for marker in (
|
|
860
|
+
"unsupported",
|
|
861
|
+
"not supported",
|
|
862
|
+
"unknown field",
|
|
863
|
+
"unknown name",
|
|
864
|
+
"unrecognized",
|
|
865
|
+
"invalid field",
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
return names_summary_field and rejects_field
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _remove_gemini_thought_summary_request(payload: dict[str, Any]) -> bool:
|
|
872
|
+
generation_config = payload.get("generationConfig")
|
|
873
|
+
if not isinstance(generation_config, dict):
|
|
874
|
+
return False
|
|
875
|
+
thinking_config = generation_config.get("thinkingConfig")
|
|
876
|
+
if not isinstance(thinking_config, dict) or "includeThoughts" not in thinking_config:
|
|
877
|
+
return False
|
|
878
|
+
thinking_config.pop("includeThoughts", None)
|
|
879
|
+
if not thinking_config:
|
|
880
|
+
generation_config.pop("thinkingConfig", None)
|
|
881
|
+
return True
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _gemini_payload_requests_thought_summaries(payload: dict[str, Any]) -> bool:
|
|
885
|
+
generation_config = payload.get("generationConfig")
|
|
886
|
+
if not isinstance(generation_config, dict):
|
|
887
|
+
return False
|
|
888
|
+
thinking_config = generation_config.get("thinkingConfig")
|
|
889
|
+
return isinstance(thinking_config, dict) and thinking_config.get("includeThoughts") is True
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _candidate(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
893
|
+
candidates = data.get("candidates")
|
|
894
|
+
if not isinstance(candidates, list):
|
|
895
|
+
return None
|
|
896
|
+
return next((item for item in candidates if isinstance(item, dict)), None)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _candidate_content(candidate: dict[str, Any]) -> dict[str, Any] | None:
|
|
900
|
+
content = candidate.get("content")
|
|
901
|
+
return content if isinstance(content, dict) else None
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _extract_text(parts: list[Any]) -> str:
|
|
905
|
+
text_parts: list[str] = []
|
|
906
|
+
for part in parts:
|
|
907
|
+
if (
|
|
908
|
+
isinstance(part, dict)
|
|
909
|
+
and part.get("thought") is not True
|
|
910
|
+
and isinstance(part.get("text"), str)
|
|
911
|
+
):
|
|
912
|
+
text_parts.append(part["text"])
|
|
913
|
+
return "".join(text_parts)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _emit_reasoning_parts(
|
|
917
|
+
parts: list[Any],
|
|
918
|
+
callback: Callable[[str], None] | None,
|
|
919
|
+
) -> None:
|
|
920
|
+
if callback is None:
|
|
921
|
+
return
|
|
922
|
+
for part in parts:
|
|
923
|
+
if not isinstance(part, dict) or part.get("thought") is not True:
|
|
924
|
+
continue
|
|
925
|
+
text = part.get("text")
|
|
926
|
+
if isinstance(text, str) and text:
|
|
927
|
+
callback(text)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _reasoning_outputs_from_parts(parts: list[Any]) -> tuple[ReasoningOutput, ...]:
|
|
931
|
+
"""Normalize Gemini thought summaries while excluding opaque signatures."""
|
|
932
|
+
|
|
933
|
+
outputs: list[ReasoningOutput] = []
|
|
934
|
+
for part in parts:
|
|
935
|
+
if not isinstance(part, dict) or part.get("thought") is not True:
|
|
936
|
+
continue
|
|
937
|
+
text = part.get("text")
|
|
938
|
+
if isinstance(text, str) and text:
|
|
939
|
+
outputs.append(
|
|
940
|
+
ReasoningOutput(
|
|
941
|
+
text=text,
|
|
942
|
+
kind=ReasoningOutputKind.SUMMARY,
|
|
943
|
+
provider="gemini",
|
|
944
|
+
)
|
|
945
|
+
)
|
|
946
|
+
return tuple(outputs)
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _gemini_function_call_id(function_call: dict[str, Any], index: int) -> str:
|
|
950
|
+
return str(function_call.get("id") or f"call_{index}").strip()
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _candidate_content_with_normalized_function_call_ids(
|
|
954
|
+
content: dict[str, Any],
|
|
955
|
+
) -> dict[str, Any]:
|
|
956
|
+
copied = copy.deepcopy(content)
|
|
957
|
+
parts = copied.get("parts")
|
|
958
|
+
if not isinstance(parts, list):
|
|
959
|
+
return copied
|
|
960
|
+
for index, part in enumerate(parts):
|
|
961
|
+
if not isinstance(part, dict):
|
|
962
|
+
continue
|
|
963
|
+
function_call = part.get("functionCall")
|
|
964
|
+
if not isinstance(function_call, dict):
|
|
965
|
+
continue
|
|
966
|
+
name = str(function_call.get("name") or "").strip()
|
|
967
|
+
if not name:
|
|
968
|
+
continue
|
|
969
|
+
call_id = _gemini_function_call_id(function_call, index)
|
|
970
|
+
if call_id:
|
|
971
|
+
function_call["id"] = call_id
|
|
972
|
+
return copied
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _parse_tool_calls(parts: list[Any]) -> list[ToolCall]:
|
|
976
|
+
tool_calls: list[ToolCall] = []
|
|
977
|
+
for index, part in enumerate(parts):
|
|
978
|
+
if not isinstance(part, dict):
|
|
979
|
+
continue
|
|
980
|
+
function_call = part.get("functionCall")
|
|
981
|
+
if not isinstance(function_call, dict):
|
|
982
|
+
continue
|
|
983
|
+
name = str(function_call.get("name") or "").strip()
|
|
984
|
+
if not name:
|
|
985
|
+
continue
|
|
986
|
+
call_id = _gemini_function_call_id(function_call, index)
|
|
987
|
+
raw_args = function_call.get("args")
|
|
988
|
+
args = dict(raw_args) if isinstance(raw_args, dict) else _json_arguments(raw_args)
|
|
989
|
+
metadata: dict[str, Any] = {"part_index": index}
|
|
990
|
+
for key in ("thoughtSignature", "thought_signature"):
|
|
991
|
+
if key in part:
|
|
992
|
+
metadata[key] = part.get(key)
|
|
993
|
+
tool_calls.append(
|
|
994
|
+
ToolCall(
|
|
995
|
+
id=call_id,
|
|
996
|
+
name=name,
|
|
997
|
+
arguments=args,
|
|
998
|
+
provider_metadata={_GEMINI_METADATA_KEY: metadata},
|
|
999
|
+
)
|
|
1000
|
+
)
|
|
1001
|
+
return tool_calls
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _source_from_grounding_chunk(chunk: Any) -> dict[str, Any] | None:
|
|
1005
|
+
if not isinstance(chunk, dict):
|
|
1006
|
+
return None
|
|
1007
|
+
payload = chunk.get("web") if isinstance(chunk.get("web"), dict) else chunk
|
|
1008
|
+
url = str(payload.get("uri") or payload.get("url") or "").strip()
|
|
1009
|
+
if not url:
|
|
1010
|
+
return None
|
|
1011
|
+
return {"url": url, "title": str(payload.get("title") or "").strip()}
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _grounding_metadata_payload(candidate: dict[str, Any]) -> dict[str, Any]:
|
|
1015
|
+
grounding = candidate.get("groundingMetadata")
|
|
1016
|
+
if not isinstance(grounding, dict):
|
|
1017
|
+
return {}
|
|
1018
|
+
raw_chunks = grounding.get("groundingChunks")
|
|
1019
|
+
chunks = raw_chunks if isinstance(raw_chunks, list) else []
|
|
1020
|
+
sources: list[dict[str, Any]] = []
|
|
1021
|
+
for chunk in chunks:
|
|
1022
|
+
source = _source_from_grounding_chunk(chunk)
|
|
1023
|
+
if source is not None and source not in sources:
|
|
1024
|
+
sources.append(source)
|
|
1025
|
+
citations: list[dict[str, Any]] = []
|
|
1026
|
+
supports = grounding.get("groundingSupports")
|
|
1027
|
+
if isinstance(supports, list):
|
|
1028
|
+
for support in supports:
|
|
1029
|
+
if not isinstance(support, dict):
|
|
1030
|
+
continue
|
|
1031
|
+
segment = support.get("segment") if isinstance(support.get("segment"), dict) else {}
|
|
1032
|
+
for raw_index in support.get("groundingChunkIndices", []):
|
|
1033
|
+
try:
|
|
1034
|
+
source = _source_from_grounding_chunk(chunks[int(raw_index)])
|
|
1035
|
+
except (TypeError, ValueError, IndexError):
|
|
1036
|
+
continue
|
|
1037
|
+
if source is None:
|
|
1038
|
+
continue
|
|
1039
|
+
citations.append(
|
|
1040
|
+
{
|
|
1041
|
+
"title": source["title"],
|
|
1042
|
+
"url": source["url"],
|
|
1043
|
+
"start_index": segment.get("startIndex"),
|
|
1044
|
+
"end_index": segment.get("endIndex"),
|
|
1045
|
+
"text": segment.get("text"),
|
|
1046
|
+
}
|
|
1047
|
+
)
|
|
1048
|
+
raw_queries = grounding.get("webSearchQueries")
|
|
1049
|
+
queries = (
|
|
1050
|
+
[str(query).strip() for query in raw_queries if str(query).strip()]
|
|
1051
|
+
if isinstance(raw_queries, list)
|
|
1052
|
+
else []
|
|
1053
|
+
)
|
|
1054
|
+
payload: dict[str, Any] = {
|
|
1055
|
+
"groundingMetadata": copy.deepcopy(grounding),
|
|
1056
|
+
}
|
|
1057
|
+
if sources:
|
|
1058
|
+
payload["sources"] = sources
|
|
1059
|
+
if citations:
|
|
1060
|
+
payload["citations"] = citations
|
|
1061
|
+
if queries:
|
|
1062
|
+
payload["queries"] = queries
|
|
1063
|
+
return payload
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _gemini_provider_metadata(
|
|
1067
|
+
data: dict[str, Any], candidate: dict[str, Any]
|
|
1068
|
+
) -> dict[str, Any] | None:
|
|
1069
|
+
metadata: dict[str, Any] = {}
|
|
1070
|
+
response_id = str(data.get("responseId") or "").strip()
|
|
1071
|
+
if response_id:
|
|
1072
|
+
metadata["response_id"] = response_id
|
|
1073
|
+
model_version = str(data.get("modelVersion") or "").strip()
|
|
1074
|
+
if model_version:
|
|
1075
|
+
metadata["model_version"] = model_version
|
|
1076
|
+
finish_reason = str(candidate.get("finishReason") or "").strip()
|
|
1077
|
+
if finish_reason:
|
|
1078
|
+
metadata["finish_reason"] = finish_reason
|
|
1079
|
+
for key in ("finishMessage", "safetyRatings", "citationMetadata"):
|
|
1080
|
+
value = candidate.get(key)
|
|
1081
|
+
if value is not None:
|
|
1082
|
+
metadata[_camel_to_snake(key)] = copy.deepcopy(value)
|
|
1083
|
+
content = _candidate_content(candidate)
|
|
1084
|
+
if isinstance(content, dict):
|
|
1085
|
+
metadata["content"] = _candidate_content_with_normalized_function_call_ids(content)
|
|
1086
|
+
metadata.update(_grounding_metadata_payload(candidate))
|
|
1087
|
+
usage = data.get("usageMetadata")
|
|
1088
|
+
if isinstance(usage, dict):
|
|
1089
|
+
metadata["usage"] = copy.deepcopy(usage)
|
|
1090
|
+
stream_metadata = data.get("streamMetadata")
|
|
1091
|
+
if isinstance(stream_metadata, dict):
|
|
1092
|
+
metadata["stream_metadata"] = copy.deepcopy(stream_metadata)
|
|
1093
|
+
return {_GEMINI_METADATA_KEY: metadata} if metadata else None
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _camel_to_snake(value: str) -> str:
|
|
1097
|
+
result = []
|
|
1098
|
+
for index, char in enumerate(value):
|
|
1099
|
+
if char.isupper() and index > 0:
|
|
1100
|
+
result.append("_")
|
|
1101
|
+
result.append(char.lower())
|
|
1102
|
+
return "".join(result)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _response_from_json(data: dict[str, Any]) -> httpx.Response:
|
|
1106
|
+
return httpx.Response(200, json=data)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _append_unique(target: list[Any], values: list[Any]) -> None:
|
|
1110
|
+
for value in values:
|
|
1111
|
+
copied = copy.deepcopy(value)
|
|
1112
|
+
if copied not in target:
|
|
1113
|
+
target.append(copied)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _merge_stream_grounding_metadata(
|
|
1117
|
+
current: dict[str, Any],
|
|
1118
|
+
incoming: dict[str, Any],
|
|
1119
|
+
) -> dict[str, Any]:
|
|
1120
|
+
merged = copy.deepcopy(current)
|
|
1121
|
+
for key, value in incoming.items():
|
|
1122
|
+
if isinstance(value, list):
|
|
1123
|
+
existing = merged.setdefault(key, [])
|
|
1124
|
+
if isinstance(existing, list):
|
|
1125
|
+
_append_unique(existing, value)
|
|
1126
|
+
else:
|
|
1127
|
+
merged[key] = copy.deepcopy(value)
|
|
1128
|
+
continue
|
|
1129
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
1130
|
+
merged[key] = _merge_stream_grounding_metadata(merged[key], value)
|
|
1131
|
+
continue
|
|
1132
|
+
merged[key] = copy.deepcopy(value)
|
|
1133
|
+
return merged
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
class _GeminiStreamAccumulator:
|
|
1137
|
+
def __init__(
|
|
1138
|
+
self,
|
|
1139
|
+
*,
|
|
1140
|
+
on_text_delta: Callable[[str], None] | None,
|
|
1141
|
+
on_reasoning_delta: Callable[[str], None] | None,
|
|
1142
|
+
) -> None:
|
|
1143
|
+
self.on_text_delta = on_text_delta
|
|
1144
|
+
self.on_reasoning_delta = on_reasoning_delta
|
|
1145
|
+
self.response_id: str | None = None
|
|
1146
|
+
self.model_version: str | None = None
|
|
1147
|
+
self.usage_metadata: dict[str, Any] = {}
|
|
1148
|
+
self.parts: list[dict[str, Any]] = []
|
|
1149
|
+
self.role = "model"
|
|
1150
|
+
self.finish_reason: str | None = None
|
|
1151
|
+
self.finish_message: str | None = None
|
|
1152
|
+
self.safety_ratings: list[Any] | None = None
|
|
1153
|
+
self.citation_metadata: dict[str, Any] | None = None
|
|
1154
|
+
self.grounding_metadata: dict[str, Any] = {}
|
|
1155
|
+
self.stream_metadata: dict[str, Any] = {"chunks": 0}
|
|
1156
|
+
self.seen_candidate = False
|
|
1157
|
+
|
|
1158
|
+
def handle(self, frame: SSEFrame, data: dict[str, Any]) -> None:
|
|
1159
|
+
_ = frame
|
|
1160
|
+
error_message = _extract_error_message(data)
|
|
1161
|
+
if error_message:
|
|
1162
|
+
raise LLMError(f"Gemini GenerateContent stream error: {error_message}")
|
|
1163
|
+
self.stream_metadata["chunks"] = int(self.stream_metadata["chunks"]) + 1
|
|
1164
|
+
if not data:
|
|
1165
|
+
self._record_unknown_chunk(data)
|
|
1166
|
+
return
|
|
1167
|
+
|
|
1168
|
+
response_id = data.get("responseId")
|
|
1169
|
+
if isinstance(response_id, str) and response_id.strip():
|
|
1170
|
+
self.response_id = response_id
|
|
1171
|
+
model_version = data.get("modelVersion")
|
|
1172
|
+
if isinstance(model_version, str) and model_version.strip():
|
|
1173
|
+
self.model_version = model_version
|
|
1174
|
+
usage = data.get("usageMetadata")
|
|
1175
|
+
if isinstance(usage, dict):
|
|
1176
|
+
self.usage_metadata.update(copy.deepcopy(usage))
|
|
1177
|
+
|
|
1178
|
+
candidates = data.get("candidates")
|
|
1179
|
+
if not isinstance(candidates, list) or not candidates:
|
|
1180
|
+
self._record_unknown_chunk(data)
|
|
1181
|
+
return
|
|
1182
|
+
candidate = next((item for item in candidates if isinstance(item, dict)), None)
|
|
1183
|
+
if candidate is None:
|
|
1184
|
+
self._record_unknown_chunk(data)
|
|
1185
|
+
return
|
|
1186
|
+
self.seen_candidate = True
|
|
1187
|
+
self._handle_candidate(candidate)
|
|
1188
|
+
|
|
1189
|
+
def _handle_candidate(self, candidate: dict[str, Any]) -> None:
|
|
1190
|
+
finish_reason = candidate.get("finishReason")
|
|
1191
|
+
if isinstance(finish_reason, str) and finish_reason.strip():
|
|
1192
|
+
self.finish_reason = finish_reason
|
|
1193
|
+
finish_message = candidate.get("finishMessage")
|
|
1194
|
+
if isinstance(finish_message, str) and finish_message.strip():
|
|
1195
|
+
self.finish_message = finish_message
|
|
1196
|
+
safety_ratings = candidate.get("safetyRatings")
|
|
1197
|
+
if isinstance(safety_ratings, list):
|
|
1198
|
+
self.safety_ratings = copy.deepcopy(safety_ratings)
|
|
1199
|
+
citation_metadata = candidate.get("citationMetadata")
|
|
1200
|
+
if isinstance(citation_metadata, dict):
|
|
1201
|
+
self.citation_metadata = copy.deepcopy(citation_metadata)
|
|
1202
|
+
grounding_metadata = candidate.get("groundingMetadata")
|
|
1203
|
+
if isinstance(grounding_metadata, dict):
|
|
1204
|
+
self.grounding_metadata = _merge_stream_grounding_metadata(
|
|
1205
|
+
self.grounding_metadata,
|
|
1206
|
+
grounding_metadata,
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
content = candidate.get("content")
|
|
1210
|
+
if not isinstance(content, dict):
|
|
1211
|
+
return
|
|
1212
|
+
role = content.get("role")
|
|
1213
|
+
if isinstance(role, str) and role.strip():
|
|
1214
|
+
self.role = role
|
|
1215
|
+
parts = content.get("parts")
|
|
1216
|
+
if not isinstance(parts, list):
|
|
1217
|
+
return
|
|
1218
|
+
for part in parts:
|
|
1219
|
+
if not isinstance(part, dict):
|
|
1220
|
+
continue
|
|
1221
|
+
copied = copy.deepcopy(part)
|
|
1222
|
+
self.parts.append(copied)
|
|
1223
|
+
text = copied.get("text")
|
|
1224
|
+
if not isinstance(text, str) or not text:
|
|
1225
|
+
continue
|
|
1226
|
+
if copied.get("thought") is True:
|
|
1227
|
+
if self.on_reasoning_delta is not None:
|
|
1228
|
+
self.on_reasoning_delta(text)
|
|
1229
|
+
elif self.on_text_delta is not None:
|
|
1230
|
+
self.on_text_delta(text)
|
|
1231
|
+
|
|
1232
|
+
def _record_unknown_chunk(self, data: dict[str, Any]) -> None:
|
|
1233
|
+
unknown = self.stream_metadata.setdefault("unknown_chunks", [])
|
|
1234
|
+
if isinstance(unknown, list):
|
|
1235
|
+
unknown.append(copy.deepcopy(data))
|
|
1236
|
+
|
|
1237
|
+
def finish(self) -> dict[str, Any]:
|
|
1238
|
+
if int(self.stream_metadata["chunks"]) <= 0:
|
|
1239
|
+
raise LLMError("Gemini GenerateContent stream returned no chunks")
|
|
1240
|
+
if not self.seen_candidate:
|
|
1241
|
+
raise LLMError("Gemini GenerateContent stream returned no candidate chunks")
|
|
1242
|
+
|
|
1243
|
+
candidate: dict[str, Any] = {
|
|
1244
|
+
"content": {
|
|
1245
|
+
"role": self.role,
|
|
1246
|
+
"parts": copy.deepcopy(self.parts),
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if self.finish_reason:
|
|
1250
|
+
candidate["finishReason"] = self.finish_reason
|
|
1251
|
+
if self.finish_message:
|
|
1252
|
+
candidate["finishMessage"] = self.finish_message
|
|
1253
|
+
if self.safety_ratings is not None:
|
|
1254
|
+
candidate["safetyRatings"] = copy.deepcopy(self.safety_ratings)
|
|
1255
|
+
if self.citation_metadata is not None:
|
|
1256
|
+
candidate["citationMetadata"] = copy.deepcopy(self.citation_metadata)
|
|
1257
|
+
if self.grounding_metadata:
|
|
1258
|
+
candidate["groundingMetadata"] = copy.deepcopy(self.grounding_metadata)
|
|
1259
|
+
|
|
1260
|
+
data: dict[str, Any] = {
|
|
1261
|
+
"candidates": [candidate],
|
|
1262
|
+
"streamMetadata": copy.deepcopy(self.stream_metadata),
|
|
1263
|
+
}
|
|
1264
|
+
if self.response_id:
|
|
1265
|
+
data["responseId"] = self.response_id
|
|
1266
|
+
if self.model_version:
|
|
1267
|
+
data["modelVersion"] = self.model_version
|
|
1268
|
+
if self.usage_metadata:
|
|
1269
|
+
data["usageMetadata"] = copy.deepcopy(self.usage_metadata)
|
|
1270
|
+
return data
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
class GeminiGenerateContentClient:
|
|
1274
|
+
usage_contract = UsageContract(
|
|
1275
|
+
response_usage_confidence=UsageConfidence.AUTHORITATIVE,
|
|
1276
|
+
input_token_count_strategy="gemini_count_tokens",
|
|
1277
|
+
)
|
|
1278
|
+
usage_counts_authoritative = usage_contract.response_usage_authoritative
|
|
1279
|
+
supports_tool_calling = True
|
|
1280
|
+
supports_forced_tool_choice = True
|
|
1281
|
+
|
|
1282
|
+
def __init__(
|
|
1283
|
+
self,
|
|
1284
|
+
*,
|
|
1285
|
+
base_url: str,
|
|
1286
|
+
api_key: str,
|
|
1287
|
+
model: str,
|
|
1288
|
+
timeout_s: float = 20.0,
|
|
1289
|
+
temperature: float = 1.0,
|
|
1290
|
+
prompt_cache_key: str | None = None,
|
|
1291
|
+
prompt_cache_retention: str | None = None,
|
|
1292
|
+
enable_thinking: bool | None = None,
|
|
1293
|
+
reasoning_effort: str | None = None,
|
|
1294
|
+
thinking_level: str | None = None,
|
|
1295
|
+
thinking_budget: int | None = None,
|
|
1296
|
+
transport: httpx.BaseTransport | None = None,
|
|
1297
|
+
extra_headers: dict[str, str] | None = None,
|
|
1298
|
+
provider_key: str | None = None,
|
|
1299
|
+
web_search_mode: str = "off",
|
|
1300
|
+
web_search_adapter: str = AUTO_WEB_SEARCH_ADAPTER,
|
|
1301
|
+
explicit_cached_content_enabled: bool = False,
|
|
1302
|
+
cached_content_ttl: str | None = None,
|
|
1303
|
+
cached_content_min_tokens: int | None = _GEMINI_EXPLICIT_CACHE_MIN_TOKENS,
|
|
1304
|
+
cached_content_max_entries: int | None = _GEMINI_EXPLICIT_CACHE_MAX_ENTRIES,
|
|
1305
|
+
cached_content_time_fn: Callable[[], float] | None = None,
|
|
1306
|
+
prompt_cache_policy_metadata: Mapping[str, Any] | None = None,
|
|
1307
|
+
provider_concurrency_caps: dict[str, int] | None = None,
|
|
1308
|
+
provider_retry_settings: ProviderRetrySettings | None = None,
|
|
1309
|
+
provider_sleep_fn: Callable[[float], None] | None = None,
|
|
1310
|
+
provider_random_fn: Callable[[], float] | None = None,
|
|
1311
|
+
usage_contract: UsageContract | None = None,
|
|
1312
|
+
route_identity: ProviderRouteIdentity | None = None,
|
|
1313
|
+
) -> None:
|
|
1314
|
+
self.base_url = _gemini_native_base_url(base_url)
|
|
1315
|
+
self.api_key = api_key
|
|
1316
|
+
self.model = model
|
|
1317
|
+
self.timeout_s = timeout_s
|
|
1318
|
+
self.temperature = temperature
|
|
1319
|
+
self.prompt_cache_key = str(prompt_cache_key or "").strip() or None
|
|
1320
|
+
self.prompt_cache_retention = str(prompt_cache_retention or "").strip() or None
|
|
1321
|
+
self.enable_thinking = enable_thinking
|
|
1322
|
+
self.reasoning_effort = str(reasoning_effort or "").strip().lower() or None
|
|
1323
|
+
self.thinking_level = str(thinking_level or "").strip().lower() or None
|
|
1324
|
+
self.thinking_budget = thinking_budget
|
|
1325
|
+
self._transport = transport
|
|
1326
|
+
self.extra_headers = canonicalize_extra_headers(extra_headers)
|
|
1327
|
+
self.provider_key = str(provider_key or "").strip() or None
|
|
1328
|
+
self.route_identity = route_identity or build_provider_route_identity(
|
|
1329
|
+
protocol="gemini_generate_content",
|
|
1330
|
+
base_url=self.base_url,
|
|
1331
|
+
provider_key=self.provider_key,
|
|
1332
|
+
model=self.model,
|
|
1333
|
+
credential_scope=credential_scope_fingerprint(self.api_key),
|
|
1334
|
+
routing_headers=self.extra_headers,
|
|
1335
|
+
)
|
|
1336
|
+
self.web_search_mode = str(web_search_mode or "off").strip().lower()
|
|
1337
|
+
self.web_search_adapter = (
|
|
1338
|
+
str(web_search_adapter or AUTO_WEB_SEARCH_ADAPTER).strip().lower()
|
|
1339
|
+
or AUTO_WEB_SEARCH_ADAPTER
|
|
1340
|
+
)
|
|
1341
|
+
self.explicit_cached_content_enabled = bool(explicit_cached_content_enabled)
|
|
1342
|
+
self.cached_content_ttl = str(cached_content_ttl or "3600s").strip() or "3600s"
|
|
1343
|
+
self.cached_content_min_tokens = _normalize_cached_content_min_tokens(
|
|
1344
|
+
cached_content_min_tokens
|
|
1345
|
+
)
|
|
1346
|
+
self.cached_content_max_entries = _normalize_cached_content_max_entries(
|
|
1347
|
+
cached_content_max_entries
|
|
1348
|
+
)
|
|
1349
|
+
self.cached_content_ttl_seconds = _parse_cached_content_ttl_seconds(self.cached_content_ttl)
|
|
1350
|
+
self.cached_content_refresh_margin_seconds = _cached_content_refresh_margin_seconds(
|
|
1351
|
+
self.cached_content_ttl_seconds
|
|
1352
|
+
)
|
|
1353
|
+
self._cached_content_time_fn = cached_content_time_fn or time.monotonic
|
|
1354
|
+
self.prompt_cache_policy_metadata = (
|
|
1355
|
+
copy.deepcopy(dict(prompt_cache_policy_metadata))
|
|
1356
|
+
if isinstance(prompt_cache_policy_metadata, Mapping)
|
|
1357
|
+
else None
|
|
1358
|
+
)
|
|
1359
|
+
self._cached_content_by_signature: dict[str, _GeminiCachedContentEntry] = {}
|
|
1360
|
+
self._cached_content_create_disabled_reason: str | None = None
|
|
1361
|
+
self._cached_content_create_transient_failures = 0
|
|
1362
|
+
self._thought_summaries_supported: bool | None = None
|
|
1363
|
+
self.provider_concurrency_caps = dict(
|
|
1364
|
+
DEFAULT_PROVIDER_CONCURRENCY_CAPS
|
|
1365
|
+
if provider_concurrency_caps is None
|
|
1366
|
+
else provider_concurrency_caps
|
|
1367
|
+
)
|
|
1368
|
+
self.provider_retry_settings = provider_retry_settings or ProviderRetrySettings()
|
|
1369
|
+
self._provider_sleep_fn = provider_sleep_fn
|
|
1370
|
+
self._provider_random_fn = provider_random_fn
|
|
1371
|
+
self.usage_contract = usage_contract or type(self).usage_contract
|
|
1372
|
+
self.usage_counts_authoritative = self.usage_contract.response_usage_authoritative
|
|
1373
|
+
self._input_token_count_available: bool | None = None
|
|
1374
|
+
|
|
1375
|
+
def _headers(self) -> dict[str, str]:
|
|
1376
|
+
headers = merge_canonical_headers(
|
|
1377
|
+
{
|
|
1378
|
+
"x-goog-api-key": self.api_key,
|
|
1379
|
+
"Content-Type": "application/json",
|
|
1380
|
+
"User-Agent": "alysis-code/0.1.0",
|
|
1381
|
+
},
|
|
1382
|
+
self.extra_headers,
|
|
1383
|
+
)
|
|
1384
|
+
return _headers_with_default_accept_encoding(headers)
|
|
1385
|
+
|
|
1386
|
+
@staticmethod
|
|
1387
|
+
def _llm_error_from_response(response: httpx.Response) -> LLMError:
|
|
1388
|
+
try:
|
|
1389
|
+
data = response.json()
|
|
1390
|
+
except Exception:
|
|
1391
|
+
body = response.text
|
|
1392
|
+
if len(body) > 1000:
|
|
1393
|
+
body = body[:1000] + "...(truncated)"
|
|
1394
|
+
return LLMError(
|
|
1395
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {body}")
|
|
1396
|
+
)
|
|
1397
|
+
error_message = _extract_error_message(data)
|
|
1398
|
+
if error_message:
|
|
1399
|
+
return LLMError(
|
|
1400
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {error_message}")
|
|
1401
|
+
)
|
|
1402
|
+
return LLMError(
|
|
1403
|
+
sanitize_error_text_for_output(f"LLM error {response.status_code}: {data!r}")
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
def count_input_tokens(
|
|
1407
|
+
self,
|
|
1408
|
+
*,
|
|
1409
|
+
messages: list[dict[str, Any]],
|
|
1410
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1411
|
+
tool_choice: Any | None = None,
|
|
1412
|
+
) -> InputTokenCount | None:
|
|
1413
|
+
if self._input_token_count_available is False:
|
|
1414
|
+
return None
|
|
1415
|
+
messages = gate_messages_for_provider_route(messages, self.route_identity)
|
|
1416
|
+
system_instruction, contents = _gemini_contents_from_messages(messages)
|
|
1417
|
+
tool_mapping = _gemini_tools(
|
|
1418
|
+
tools,
|
|
1419
|
+
mode=self.web_search_mode,
|
|
1420
|
+
adapter=self.web_search_adapter,
|
|
1421
|
+
)
|
|
1422
|
+
generate_request: dict[str, Any] = {
|
|
1423
|
+
"model": f"models/{self.model}",
|
|
1424
|
+
"contents": contents,
|
|
1425
|
+
}
|
|
1426
|
+
if system_instruction is not None:
|
|
1427
|
+
generate_request["systemInstruction"] = system_instruction
|
|
1428
|
+
if tool_mapping.tools:
|
|
1429
|
+
generate_request["tools"] = tool_mapping.tools
|
|
1430
|
+
mapped_tool_choice = _gemini_tool_choice(
|
|
1431
|
+
tool_choice,
|
|
1432
|
+
removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
|
|
1433
|
+
added_google_search=tool_mapping.added_google_search,
|
|
1434
|
+
)
|
|
1435
|
+
tool_config = dict(mapped_tool_choice or {})
|
|
1436
|
+
if tool_mapping.include_server_side_tool_invocations:
|
|
1437
|
+
tool_config[_INCLUDE_SERVER_SIDE_TOOL_INVOCATIONS] = True
|
|
1438
|
+
if tool_config:
|
|
1439
|
+
generate_request["toolConfig"] = tool_config
|
|
1440
|
+
payload = {"generateContentRequest": generate_request}
|
|
1441
|
+
encoded_model = quote(self.model, safe="")
|
|
1442
|
+
url = f"{self.base_url}/models/{encoded_model}:countTokens"
|
|
1443
|
+
|
|
1444
|
+
def _send_request() -> InputTokenCount | None:
|
|
1445
|
+
try:
|
|
1446
|
+
with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
|
|
1447
|
+
response = client.post(url, headers=self._headers(), json=payload)
|
|
1448
|
+
except httpx.HTTPError as exc:
|
|
1449
|
+
raise LLMError(
|
|
1450
|
+
"Gemini input token count request failed: "
|
|
1451
|
+
f"{sanitize_error_text_for_output(exc)}"
|
|
1452
|
+
) from exc
|
|
1453
|
+
if response.status_code in {404, 405, 501}:
|
|
1454
|
+
self._input_token_count_available = False
|
|
1455
|
+
return None
|
|
1456
|
+
if response.status_code >= 400:
|
|
1457
|
+
raise self._llm_error_from_response(response)
|
|
1458
|
+
try:
|
|
1459
|
+
data = response.json()
|
|
1460
|
+
except Exception as exc: # noqa: BLE001
|
|
1461
|
+
raise LLMError("Gemini input token count returned non-JSON response") from exc
|
|
1462
|
+
count = _non_negative_int(data.get("totalTokens") if isinstance(data, dict) else None)
|
|
1463
|
+
if count is None:
|
|
1464
|
+
raise LLMError("Gemini input token count response omitted totalTokens")
|
|
1465
|
+
self._input_token_count_available = True
|
|
1466
|
+
return InputTokenCount(
|
|
1467
|
+
input_tokens=count,
|
|
1468
|
+
raw_provider_usage=copy.deepcopy(data),
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
return run_provider_limited_call(
|
|
1472
|
+
call=_send_request,
|
|
1473
|
+
provider_key=self.provider_key,
|
|
1474
|
+
provider_concurrency_caps=self.provider_concurrency_caps,
|
|
1475
|
+
retry_settings=self.provider_retry_settings,
|
|
1476
|
+
operation="gemini_generate_content_count_input_tokens",
|
|
1477
|
+
sleep_fn=self._provider_sleep_fn,
|
|
1478
|
+
random_fn=self._provider_random_fn,
|
|
1479
|
+
retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
def _resolve_cached_content(
|
|
1483
|
+
self,
|
|
1484
|
+
*,
|
|
1485
|
+
client: httpx.Client,
|
|
1486
|
+
plan: _GeminiCachedContentPlan,
|
|
1487
|
+
) -> tuple[str | None, str, dict[str, Any]]:
|
|
1488
|
+
lifecycle = self._cached_content_lifecycle_metadata()
|
|
1489
|
+
now = self._cached_content_now()
|
|
1490
|
+
entry = self._cached_content_by_signature.get(plan.signature)
|
|
1491
|
+
if entry is not None:
|
|
1492
|
+
refresh_reason = self._cached_content_refresh_reason(entry, now)
|
|
1493
|
+
if refresh_reason:
|
|
1494
|
+
lifecycle["refresh_reason"] = refresh_reason
|
|
1495
|
+
self._evict_cached_content_entry(
|
|
1496
|
+
client=client,
|
|
1497
|
+
signature=plan.signature,
|
|
1498
|
+
reason=refresh_reason,
|
|
1499
|
+
lifecycle=lifecycle,
|
|
1500
|
+
)
|
|
1501
|
+
else:
|
|
1502
|
+
entry = replace(entry, last_used_at=now)
|
|
1503
|
+
self._cached_content_by_signature[plan.signature] = entry
|
|
1504
|
+
lifecycle["reused_entry_count"] = 1
|
|
1505
|
+
if entry.pending_creation_tokens is not None:
|
|
1506
|
+
# Creation spend from an attempt that failed before any
|
|
1507
|
+
# successful use; keep surfacing it until a success
|
|
1508
|
+
# consumes the pending marker.
|
|
1509
|
+
lifecycle["cache_creation_input_tokens"] = entry.pending_creation_tokens
|
|
1510
|
+
lifecycle.update(self._cached_content_entry_timing_metadata(entry, now))
|
|
1511
|
+
lifecycle["entry_count"] = len(self._cached_content_by_signature)
|
|
1512
|
+
return entry.name, "reused", lifecycle
|
|
1513
|
+
if self._cached_content_create_disabled_reason:
|
|
1514
|
+
lifecycle["create_disabled_reason"] = self._cached_content_create_disabled_reason
|
|
1515
|
+
return None, "create_disabled", lifecycle
|
|
1516
|
+
try:
|
|
1517
|
+
response = client.post(
|
|
1518
|
+
f"{self.base_url}/cachedContents",
|
|
1519
|
+
headers=self._headers(),
|
|
1520
|
+
json=plan.create_payload,
|
|
1521
|
+
)
|
|
1522
|
+
except Exception as e: # noqa: BLE001
|
|
1523
|
+
return (
|
|
1524
|
+
None,
|
|
1525
|
+
self._record_cached_content_create_failure(
|
|
1526
|
+
status="create_failed",
|
|
1527
|
+
detail=repr(e),
|
|
1528
|
+
lifecycle=lifecycle,
|
|
1529
|
+
transient=True,
|
|
1530
|
+
),
|
|
1531
|
+
lifecycle,
|
|
1532
|
+
)
|
|
1533
|
+
if response.status_code >= 400:
|
|
1534
|
+
return (
|
|
1535
|
+
None,
|
|
1536
|
+
self._record_cached_content_create_failure(
|
|
1537
|
+
status="create_rejected",
|
|
1538
|
+
detail=f"status {response.status_code}: {response.text}",
|
|
1539
|
+
lifecycle=lifecycle,
|
|
1540
|
+
transient=_cached_content_create_failure_is_transient(response.status_code),
|
|
1541
|
+
),
|
|
1542
|
+
lifecycle,
|
|
1543
|
+
)
|
|
1544
|
+
try:
|
|
1545
|
+
data = response.json()
|
|
1546
|
+
except Exception:
|
|
1547
|
+
return (
|
|
1548
|
+
None,
|
|
1549
|
+
self._record_cached_content_create_failure(
|
|
1550
|
+
status="create_non_json",
|
|
1551
|
+
detail="create response was not JSON",
|
|
1552
|
+
lifecycle=lifecycle,
|
|
1553
|
+
),
|
|
1554
|
+
lifecycle,
|
|
1555
|
+
)
|
|
1556
|
+
if not isinstance(data, dict):
|
|
1557
|
+
return (
|
|
1558
|
+
None,
|
|
1559
|
+
self._record_cached_content_create_failure(
|
|
1560
|
+
status="create_unexpected_payload",
|
|
1561
|
+
detail="create response was not a JSON object",
|
|
1562
|
+
lifecycle=lifecycle,
|
|
1563
|
+
),
|
|
1564
|
+
lifecycle,
|
|
1565
|
+
)
|
|
1566
|
+
# Cache-write ingestion is billed once the create succeeds, so capture it
|
|
1567
|
+
# before validating the rest of the payload.
|
|
1568
|
+
create_usage_tokens = _cached_content_create_usage_tokens(data.get("usageMetadata"))
|
|
1569
|
+
if create_usage_tokens is not None:
|
|
1570
|
+
lifecycle["cache_creation_input_tokens"] = create_usage_tokens
|
|
1571
|
+
name = str(data.get("name") or "").strip()
|
|
1572
|
+
if not name:
|
|
1573
|
+
return (
|
|
1574
|
+
None,
|
|
1575
|
+
self._record_cached_content_create_failure(
|
|
1576
|
+
status="create_missing_name",
|
|
1577
|
+
detail="create response had no cachedContents name",
|
|
1578
|
+
lifecycle=lifecycle,
|
|
1579
|
+
),
|
|
1580
|
+
lifecycle,
|
|
1581
|
+
)
|
|
1582
|
+
self._cached_content_create_transient_failures = 0
|
|
1583
|
+
while len(self._cached_content_by_signature) >= self.cached_content_max_entries:
|
|
1584
|
+
oldest_key = min(
|
|
1585
|
+
self._cached_content_by_signature,
|
|
1586
|
+
key=lambda key: self._cached_content_by_signature[key].last_used_at,
|
|
1587
|
+
)
|
|
1588
|
+
self._evict_cached_content_entry(
|
|
1589
|
+
client=client,
|
|
1590
|
+
signature=oldest_key,
|
|
1591
|
+
reason="max_entries_exceeded",
|
|
1592
|
+
lifecycle=lifecycle,
|
|
1593
|
+
)
|
|
1594
|
+
ttl_seconds = self.cached_content_ttl_seconds
|
|
1595
|
+
refresh_margin_seconds = self.cached_content_refresh_margin_seconds
|
|
1596
|
+
expires_at = now + ttl_seconds if ttl_seconds is not None else None
|
|
1597
|
+
refresh_after = (
|
|
1598
|
+
max(now, expires_at - refresh_margin_seconds)
|
|
1599
|
+
if expires_at is not None and refresh_margin_seconds is not None
|
|
1600
|
+
else None
|
|
1601
|
+
)
|
|
1602
|
+
entry = _GeminiCachedContentEntry(
|
|
1603
|
+
name=name,
|
|
1604
|
+
signature=plan.signature,
|
|
1605
|
+
created_at=now,
|
|
1606
|
+
last_used_at=now,
|
|
1607
|
+
ttl_seconds=ttl_seconds,
|
|
1608
|
+
refresh_after=refresh_after,
|
|
1609
|
+
expires_at=expires_at,
|
|
1610
|
+
estimated_tokens=plan.estimated_tokens,
|
|
1611
|
+
pending_creation_tokens=create_usage_tokens,
|
|
1612
|
+
)
|
|
1613
|
+
self._cached_content_by_signature[plan.signature] = entry
|
|
1614
|
+
lifecycle["created_entry_count"] = 1
|
|
1615
|
+
lifecycle.update(self._cached_content_entry_timing_metadata(entry, now))
|
|
1616
|
+
lifecycle["entry_count"] = len(self._cached_content_by_signature)
|
|
1617
|
+
return name, "created", lifecycle
|
|
1618
|
+
|
|
1619
|
+
def _record_cached_content_create_failure(
|
|
1620
|
+
self,
|
|
1621
|
+
*,
|
|
1622
|
+
status: str,
|
|
1623
|
+
detail: str,
|
|
1624
|
+
lifecycle: dict[str, Any],
|
|
1625
|
+
transient: bool = False,
|
|
1626
|
+
) -> str:
|
|
1627
|
+
error_detail = _trimmed_create_error_detail(f"{status}: {detail}")
|
|
1628
|
+
lifecycle["create_error"] = error_detail
|
|
1629
|
+
if transient:
|
|
1630
|
+
# Transient failures may clear up on their own, so keep retrying
|
|
1631
|
+
# the create until several consecutive attempts have missed.
|
|
1632
|
+
self._cached_content_create_transient_failures += 1
|
|
1633
|
+
lifecycle["create_transient_failure_count"] = (
|
|
1634
|
+
self._cached_content_create_transient_failures
|
|
1635
|
+
)
|
|
1636
|
+
if (
|
|
1637
|
+
self._cached_content_create_transient_failures
|
|
1638
|
+
< _GEMINI_EXPLICIT_CACHE_TRANSIENT_CREATE_FAILURE_LIMIT
|
|
1639
|
+
):
|
|
1640
|
+
return status
|
|
1641
|
+
error_detail = _trimmed_create_error_detail(
|
|
1642
|
+
f"{error_detail} "
|
|
1643
|
+
f"({self._cached_content_create_transient_failures} consecutive "
|
|
1644
|
+
f"transient failures)"
|
|
1645
|
+
)
|
|
1646
|
+
# Negative memoization: a deterministic rejection (or an exhausted
|
|
1647
|
+
# transient budget) keeps failing on the same client config, so stop
|
|
1648
|
+
# paying a blocking round-trip per call.
|
|
1649
|
+
self._cached_content_create_disabled_reason = error_detail
|
|
1650
|
+
return status
|
|
1651
|
+
|
|
1652
|
+
def apply_cache_settings(
|
|
1653
|
+
self,
|
|
1654
|
+
*,
|
|
1655
|
+
enabled: bool | None = None,
|
|
1656
|
+
ttl: str | None = None,
|
|
1657
|
+
min_tokens: int | None = None,
|
|
1658
|
+
) -> None:
|
|
1659
|
+
previous = (
|
|
1660
|
+
self.explicit_cached_content_enabled,
|
|
1661
|
+
self.cached_content_ttl,
|
|
1662
|
+
self.cached_content_min_tokens,
|
|
1663
|
+
)
|
|
1664
|
+
if enabled is not None:
|
|
1665
|
+
self.explicit_cached_content_enabled = bool(enabled)
|
|
1666
|
+
if ttl is not None:
|
|
1667
|
+
self.cached_content_ttl = str(ttl or "3600s").strip() or "3600s"
|
|
1668
|
+
if min_tokens is not None:
|
|
1669
|
+
self.cached_content_min_tokens = _normalize_cached_content_min_tokens(min_tokens)
|
|
1670
|
+
self.cached_content_ttl_seconds = _parse_cached_content_ttl_seconds(self.cached_content_ttl)
|
|
1671
|
+
self.cached_content_refresh_margin_seconds = _cached_content_refresh_margin_seconds(
|
|
1672
|
+
self.cached_content_ttl_seconds
|
|
1673
|
+
)
|
|
1674
|
+
if previous == (
|
|
1675
|
+
self.explicit_cached_content_enabled,
|
|
1676
|
+
self.cached_content_ttl,
|
|
1677
|
+
self.cached_content_min_tokens,
|
|
1678
|
+
):
|
|
1679
|
+
return
|
|
1680
|
+
self._evict_all_cached_content_entries(reason="cache_settings_changed")
|
|
1681
|
+
self._cached_content_create_disabled_reason = None
|
|
1682
|
+
self._cached_content_create_transient_failures = 0
|
|
1683
|
+
|
|
1684
|
+
def _evict_all_cached_content_entries(self, *, reason: str) -> None:
|
|
1685
|
+
if not self._cached_content_by_signature:
|
|
1686
|
+
return
|
|
1687
|
+
signatures = list(self._cached_content_by_signature)
|
|
1688
|
+
try:
|
|
1689
|
+
# Config-apply path: clamp per-delete latency so a hanging endpoint
|
|
1690
|
+
# cannot stall a config save for max_entries * timeout_s.
|
|
1691
|
+
timeout_s = min(self.timeout_s, _GEMINI_EXPLICIT_CACHE_EVICT_ALL_TIMEOUT_S)
|
|
1692
|
+
with httpx.Client(timeout=timeout_s, transport=self._transport) as client:
|
|
1693
|
+
for index, signature in enumerate(signatures):
|
|
1694
|
+
lifecycle: dict[str, Any] = {}
|
|
1695
|
+
self._evict_cached_content_entry(
|
|
1696
|
+
client=client,
|
|
1697
|
+
signature=signature,
|
|
1698
|
+
reason=reason,
|
|
1699
|
+
lifecycle=lifecycle,
|
|
1700
|
+
)
|
|
1701
|
+
delete_status = str(lifecycle.get("delete_status") or "unknown")
|
|
1702
|
+
if delete_status in {"deleted", "already_absent"}:
|
|
1703
|
+
_LOGGER.debug(
|
|
1704
|
+
"gemini_cached_content_evicted",
|
|
1705
|
+
extra={
|
|
1706
|
+
"model": self.model,
|
|
1707
|
+
"eviction_reason": reason,
|
|
1708
|
+
"delete_status": delete_status,
|
|
1709
|
+
},
|
|
1710
|
+
)
|
|
1711
|
+
continue
|
|
1712
|
+
# First failed delete: assume the endpoint is unhealthy and
|
|
1713
|
+
# let the remaining server-side entries expire via their TTL
|
|
1714
|
+
# instead of queueing more blocking round-trips.
|
|
1715
|
+
_LOGGER.warning(
|
|
1716
|
+
"gemini_cached_content_evict_delete_failed",
|
|
1717
|
+
extra={
|
|
1718
|
+
"model": self.model,
|
|
1719
|
+
"eviction_reason": reason,
|
|
1720
|
+
"delete_status": delete_status,
|
|
1721
|
+
"remaining_entry_count": len(signatures) - index - 1,
|
|
1722
|
+
},
|
|
1723
|
+
)
|
|
1724
|
+
break
|
|
1725
|
+
except Exception:
|
|
1726
|
+
_LOGGER.warning(
|
|
1727
|
+
"gemini_cached_content_evict_transport_failed",
|
|
1728
|
+
exc_info=True,
|
|
1729
|
+
extra={
|
|
1730
|
+
"model": self.model,
|
|
1731
|
+
"eviction_reason": reason,
|
|
1732
|
+
"remaining_entry_count": len(self._cached_content_by_signature),
|
|
1733
|
+
},
|
|
1734
|
+
)
|
|
1735
|
+
finally:
|
|
1736
|
+
# Entries not deleted expire server-side via their TTL; local
|
|
1737
|
+
# tracking must never keep the stale references.
|
|
1738
|
+
self._cached_content_by_signature.clear()
|
|
1739
|
+
|
|
1740
|
+
def _clear_cached_content_entry(
|
|
1741
|
+
self,
|
|
1742
|
+
plan: _GeminiCachedContentPlan,
|
|
1743
|
+
*,
|
|
1744
|
+
client: httpx.Client | None = None,
|
|
1745
|
+
reason: str = "cleared",
|
|
1746
|
+
lifecycle: dict[str, Any] | None = None,
|
|
1747
|
+
) -> None:
|
|
1748
|
+
entry = self._cached_content_by_signature.pop(plan.signature, None)
|
|
1749
|
+
if entry is None:
|
|
1750
|
+
return
|
|
1751
|
+
target_lifecycle = lifecycle if lifecycle is not None else {}
|
|
1752
|
+
target_lifecycle["evicted_entry_count"] = (
|
|
1753
|
+
int(target_lifecycle.get("evicted_entry_count") or 0) + 1
|
|
1754
|
+
)
|
|
1755
|
+
self._append_cached_content_eviction_reason(target_lifecycle, reason)
|
|
1756
|
+
if client is not None:
|
|
1757
|
+
self._delete_cached_content_entry(
|
|
1758
|
+
client=client,
|
|
1759
|
+
entry=entry,
|
|
1760
|
+
lifecycle=target_lifecycle,
|
|
1761
|
+
)
|
|
1762
|
+
if lifecycle is not None:
|
|
1763
|
+
lifecycle["entry_count"] = len(self._cached_content_by_signature)
|
|
1764
|
+
|
|
1765
|
+
def _cached_content_now(self) -> float:
|
|
1766
|
+
try:
|
|
1767
|
+
value = float(self._cached_content_time_fn())
|
|
1768
|
+
except Exception:
|
|
1769
|
+
return time.monotonic()
|
|
1770
|
+
return value
|
|
1771
|
+
|
|
1772
|
+
def _cached_content_lifecycle_metadata(self) -> dict[str, Any]:
|
|
1773
|
+
payload: dict[str, Any] = {
|
|
1774
|
+
"entry_count": len(self._cached_content_by_signature),
|
|
1775
|
+
"max_entries": self.cached_content_max_entries,
|
|
1776
|
+
}
|
|
1777
|
+
ttl_seconds = _rounded_non_negative_seconds(self.cached_content_ttl_seconds)
|
|
1778
|
+
if ttl_seconds is not None:
|
|
1779
|
+
payload["ttl_seconds"] = ttl_seconds
|
|
1780
|
+
refresh_margin = _rounded_non_negative_seconds(self.cached_content_refresh_margin_seconds)
|
|
1781
|
+
if refresh_margin is not None:
|
|
1782
|
+
payload["refresh_margin_seconds"] = refresh_margin
|
|
1783
|
+
return payload
|
|
1784
|
+
|
|
1785
|
+
@staticmethod
|
|
1786
|
+
def _append_cached_content_eviction_reason(
|
|
1787
|
+
lifecycle: dict[str, Any],
|
|
1788
|
+
reason: str,
|
|
1789
|
+
) -> None:
|
|
1790
|
+
normalized = str(reason or "evicted").strip() or "evicted"
|
|
1791
|
+
reasons = lifecycle.setdefault("eviction_reasons", [])
|
|
1792
|
+
if isinstance(reasons, list) and normalized not in reasons:
|
|
1793
|
+
reasons.append(normalized)
|
|
1794
|
+
|
|
1795
|
+
def _cached_content_entry_timing_metadata(
|
|
1796
|
+
self,
|
|
1797
|
+
entry: _GeminiCachedContentEntry,
|
|
1798
|
+
now: float,
|
|
1799
|
+
) -> dict[str, int]:
|
|
1800
|
+
metadata: dict[str, int] = {
|
|
1801
|
+
"cache_age_seconds": _rounded_non_negative_seconds(now - entry.created_at) or 0,
|
|
1802
|
+
"cached_content_estimated_tokens": max(0, int(entry.estimated_tokens)),
|
|
1803
|
+
}
|
|
1804
|
+
if entry.expires_at is not None:
|
|
1805
|
+
expires_in = _rounded_non_negative_seconds(entry.expires_at - now)
|
|
1806
|
+
metadata["expires_in_seconds"] = expires_in or 0
|
|
1807
|
+
if entry.refresh_after is not None:
|
|
1808
|
+
refresh_in = _rounded_non_negative_seconds(entry.refresh_after - now)
|
|
1809
|
+
metadata["refresh_in_seconds"] = refresh_in or 0
|
|
1810
|
+
return metadata
|
|
1811
|
+
|
|
1812
|
+
@staticmethod
|
|
1813
|
+
def _cached_content_refresh_reason(
|
|
1814
|
+
entry: _GeminiCachedContentEntry,
|
|
1815
|
+
now: float,
|
|
1816
|
+
) -> str:
|
|
1817
|
+
if entry.expires_at is not None and now >= entry.expires_at:
|
|
1818
|
+
return "expired"
|
|
1819
|
+
if entry.refresh_after is not None and now >= entry.refresh_after:
|
|
1820
|
+
return "ttl_refresh_due"
|
|
1821
|
+
return ""
|
|
1822
|
+
|
|
1823
|
+
def _evict_cached_content_entry(
|
|
1824
|
+
self,
|
|
1825
|
+
*,
|
|
1826
|
+
client: httpx.Client,
|
|
1827
|
+
signature: str,
|
|
1828
|
+
reason: str,
|
|
1829
|
+
lifecycle: dict[str, Any],
|
|
1830
|
+
) -> None:
|
|
1831
|
+
entry = self._cached_content_by_signature.pop(signature, None)
|
|
1832
|
+
if entry is None:
|
|
1833
|
+
return
|
|
1834
|
+
lifecycle["evicted_entry_count"] = int(lifecycle.get("evicted_entry_count") or 0) + 1
|
|
1835
|
+
self._append_cached_content_eviction_reason(lifecycle, reason)
|
|
1836
|
+
self._delete_cached_content_entry(
|
|
1837
|
+
client=client,
|
|
1838
|
+
entry=entry,
|
|
1839
|
+
lifecycle=lifecycle,
|
|
1840
|
+
)
|
|
1841
|
+
lifecycle["entry_count"] = len(self._cached_content_by_signature)
|
|
1842
|
+
|
|
1843
|
+
def _delete_cached_content_entry(
|
|
1844
|
+
self,
|
|
1845
|
+
*,
|
|
1846
|
+
client: httpx.Client,
|
|
1847
|
+
entry: _GeminiCachedContentEntry,
|
|
1848
|
+
lifecycle: dict[str, Any],
|
|
1849
|
+
) -> None:
|
|
1850
|
+
lifecycle["delete_attempt_count"] = int(lifecycle.get("delete_attempt_count") or 0) + 1
|
|
1851
|
+
try:
|
|
1852
|
+
response = client.delete(
|
|
1853
|
+
_cached_content_resource_url(self.base_url, entry.name),
|
|
1854
|
+
headers=self._headers(),
|
|
1855
|
+
)
|
|
1856
|
+
except Exception:
|
|
1857
|
+
lifecycle["delete_failure_count"] = int(lifecycle.get("delete_failure_count") or 0) + 1
|
|
1858
|
+
lifecycle["delete_status"] = "delete_failed"
|
|
1859
|
+
return
|
|
1860
|
+
if response.status_code < 400:
|
|
1861
|
+
lifecycle["delete_success_count"] = int(lifecycle.get("delete_success_count") or 0) + 1
|
|
1862
|
+
lifecycle["delete_status"] = "deleted"
|
|
1863
|
+
return
|
|
1864
|
+
if response.status_code in {404, 410}:
|
|
1865
|
+
lifecycle["delete_success_count"] = int(lifecycle.get("delete_success_count") or 0) + 1
|
|
1866
|
+
lifecycle["delete_status"] = "already_absent"
|
|
1867
|
+
return
|
|
1868
|
+
lifecycle["delete_failure_count"] = int(lifecycle.get("delete_failure_count") or 0) + 1
|
|
1869
|
+
lifecycle["delete_status"] = "delete_rejected"
|
|
1870
|
+
|
|
1871
|
+
def chat(
|
|
1872
|
+
self,
|
|
1873
|
+
*,
|
|
1874
|
+
messages: list[dict[str, Any]],
|
|
1875
|
+
tools: list[dict[str, Any]] | None = None,
|
|
1876
|
+
tool_choice: Any | None = None,
|
|
1877
|
+
response_format: dict[str, Any] | None = None,
|
|
1878
|
+
stream: bool = False,
|
|
1879
|
+
on_text_delta: Callable[[str], None] | None = None,
|
|
1880
|
+
on_reasoning_delta: Callable[[str], None] | None = None,
|
|
1881
|
+
temperature: float | None = None,
|
|
1882
|
+
max_tokens: int | None = None,
|
|
1883
|
+
) -> LLMResponse:
|
|
1884
|
+
if self.prompt_cache_key or self.prompt_cache_retention:
|
|
1885
|
+
raise LLMError("Gemini GenerateContent does not support prompt_cache_key settings")
|
|
1886
|
+
|
|
1887
|
+
messages = gate_messages_for_provider_route(messages, self.route_identity)
|
|
1888
|
+
system_instruction, contents = _gemini_contents_from_messages(messages)
|
|
1889
|
+
tool_mapping = _gemini_tools(
|
|
1890
|
+
tools,
|
|
1891
|
+
mode=self.web_search_mode,
|
|
1892
|
+
adapter=self.web_search_adapter,
|
|
1893
|
+
)
|
|
1894
|
+
temperature_omit_reason = documented_temperature_omit_reason(self.model)
|
|
1895
|
+
generation_config: dict[str, Any] = {}
|
|
1896
|
+
if temperature_omit_reason is None:
|
|
1897
|
+
generation_config["temperature"] = (
|
|
1898
|
+
self.temperature if temperature is None else float(temperature)
|
|
1899
|
+
)
|
|
1900
|
+
if max_tokens is not None:
|
|
1901
|
+
generation_config["maxOutputTokens"] = int(max_tokens)
|
|
1902
|
+
text_config = _gemini_response_format(response_format)
|
|
1903
|
+
generation_config.update(text_config)
|
|
1904
|
+
thinking_config = _gemini_thinking_config(
|
|
1905
|
+
model=self.model,
|
|
1906
|
+
enable_thinking=self.enable_thinking,
|
|
1907
|
+
reasoning_effort=self.reasoning_effort,
|
|
1908
|
+
thinking_level=self.thinking_level,
|
|
1909
|
+
thinking_budget=self.thinking_budget,
|
|
1910
|
+
)
|
|
1911
|
+
if on_reasoning_delta is not None and self._thought_summaries_supported is not False:
|
|
1912
|
+
thinking_config["includeThoughts"] = True
|
|
1913
|
+
if thinking_config:
|
|
1914
|
+
generation_config["thinkingConfig"] = thinking_config
|
|
1915
|
+
|
|
1916
|
+
explicit_cached_content_requested = self.explicit_cached_content_enabled
|
|
1917
|
+
explicit_cached_content_disabled_reason = (
|
|
1918
|
+
"tools_or_tool_config_present"
|
|
1919
|
+
if explicit_cached_content_requested and tool_mapping.tools
|
|
1920
|
+
else ""
|
|
1921
|
+
)
|
|
1922
|
+
explicit_cached_content_active = (
|
|
1923
|
+
explicit_cached_content_requested and not explicit_cached_content_disabled_reason
|
|
1924
|
+
)
|
|
1925
|
+
cache_plan = (
|
|
1926
|
+
_explicit_cached_content_plan(
|
|
1927
|
+
model=self.model,
|
|
1928
|
+
system_instruction=system_instruction,
|
|
1929
|
+
contents=contents,
|
|
1930
|
+
ttl=self.cached_content_ttl,
|
|
1931
|
+
min_tokens=self.cached_content_min_tokens,
|
|
1932
|
+
)
|
|
1933
|
+
if explicit_cached_content_active
|
|
1934
|
+
else None
|
|
1935
|
+
)
|
|
1936
|
+
cache_metadata: dict[str, Any] | None = None
|
|
1937
|
+
if explicit_cached_content_requested:
|
|
1938
|
+
cache_metadata = {
|
|
1939
|
+
"strategy": "gemini_explicit_cached_content",
|
|
1940
|
+
"enabled": True,
|
|
1941
|
+
"ttl": self.cached_content_ttl,
|
|
1942
|
+
"min_tokens": self.cached_content_min_tokens,
|
|
1943
|
+
"eligible": cache_plan is not None,
|
|
1944
|
+
}
|
|
1945
|
+
if explicit_cached_content_disabled_reason:
|
|
1946
|
+
cache_metadata["disabled_fields"] = ["cached_content"]
|
|
1947
|
+
cache_metadata["fallback"] = "full_payload"
|
|
1948
|
+
cache_metadata["warnings"] = [
|
|
1949
|
+
f"gemini_explicit_cached_content_skipped_for_"
|
|
1950
|
+
f"{explicit_cached_content_disabled_reason}"
|
|
1951
|
+
]
|
|
1952
|
+
if cache_plan is not None:
|
|
1953
|
+
cache_metadata["cacheable_prefix_estimated_tokens"] = cache_plan.estimated_tokens
|
|
1954
|
+
cache_metadata["cacheable_prefix_hash"] = cache_plan.signature[:16]
|
|
1955
|
+
cache_metadata = merge_cache_policy_metadata(
|
|
1956
|
+
self.prompt_cache_policy_metadata,
|
|
1957
|
+
cache_metadata,
|
|
1958
|
+
)
|
|
1959
|
+
layout_plan = LLMRequestPlan.from_chat_args(
|
|
1960
|
+
messages=messages,
|
|
1961
|
+
tools=tools,
|
|
1962
|
+
tool_choice=tool_choice,
|
|
1963
|
+
response_format=response_format,
|
|
1964
|
+
stream=stream,
|
|
1965
|
+
temperature=temperature,
|
|
1966
|
+
max_tokens=max_tokens,
|
|
1967
|
+
cache=RequestCachePlan(
|
|
1968
|
+
strategy=(
|
|
1969
|
+
"gemini_explicit_cached_content"
|
|
1970
|
+
if explicit_cached_content_requested
|
|
1971
|
+
else "none"
|
|
1972
|
+
),
|
|
1973
|
+
mode="automatic" if explicit_cached_content_requested else "manual",
|
|
1974
|
+
),
|
|
1975
|
+
)
|
|
1976
|
+
|
|
1977
|
+
def _build_payload(
|
|
1978
|
+
*,
|
|
1979
|
+
request_contents: list[dict[str, Any]],
|
|
1980
|
+
cached_content_name: str | None = None,
|
|
1981
|
+
) -> dict[str, Any]:
|
|
1982
|
+
payload: dict[str, Any] = {
|
|
1983
|
+
"contents": request_contents,
|
|
1984
|
+
"generationConfig": generation_config,
|
|
1985
|
+
}
|
|
1986
|
+
if cached_content_name:
|
|
1987
|
+
payload["cachedContent"] = cached_content_name
|
|
1988
|
+
elif system_instruction is not None:
|
|
1989
|
+
payload["systemInstruction"] = system_instruction
|
|
1990
|
+
if tool_mapping.tools:
|
|
1991
|
+
payload["tools"] = tool_mapping.tools
|
|
1992
|
+
mapped_tool_choice = _gemini_tool_choice(
|
|
1993
|
+
tool_choice,
|
|
1994
|
+
removed_alysis_web_search=tool_mapping.removed_alysis_web_search,
|
|
1995
|
+
added_google_search=tool_mapping.added_google_search,
|
|
1996
|
+
)
|
|
1997
|
+
tool_config = dict(mapped_tool_choice or {})
|
|
1998
|
+
if tool_mapping.include_server_side_tool_invocations:
|
|
1999
|
+
tool_config[_INCLUDE_SERVER_SIDE_TOOL_INVOCATIONS] = True
|
|
2000
|
+
if tool_config:
|
|
2001
|
+
payload["toolConfig"] = tool_config
|
|
2002
|
+
elif tool_choice is not None:
|
|
2003
|
+
raise LLMError(
|
|
2004
|
+
"Gemini GenerateContent tool_choice requires at least one available tool"
|
|
2005
|
+
)
|
|
2006
|
+
return payload
|
|
2007
|
+
|
|
2008
|
+
def _prompt_estimation_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
2009
|
+
estimation_payload: dict[str, Any] = {
|
|
2010
|
+
"contents": payload.get("contents", []),
|
|
2011
|
+
}
|
|
2012
|
+
for key in ("systemInstruction", "tools", "toolConfig", "cachedContent"):
|
|
2013
|
+
if key in payload:
|
|
2014
|
+
estimation_payload[key] = payload[key]
|
|
2015
|
+
return estimation_payload
|
|
2016
|
+
|
|
2017
|
+
payload = _build_payload(request_contents=contents)
|
|
2018
|
+
full_input_estimate_tokens = estimate_provider_payload_tokens(
|
|
2019
|
+
_prompt_estimation_payload(payload)
|
|
2020
|
+
)
|
|
2021
|
+
|
|
2022
|
+
def _token_reconciliation_metadata(
|
|
2023
|
+
current_payload: dict[str, Any],
|
|
2024
|
+
*,
|
|
2025
|
+
input_mode: str,
|
|
2026
|
+
) -> dict[str, Any]:
|
|
2027
|
+
sent_input_estimate_tokens = estimate_provider_payload_tokens(
|
|
2028
|
+
_prompt_estimation_payload(current_payload)
|
|
2029
|
+
)
|
|
2030
|
+
return {
|
|
2031
|
+
"input_estimate_tokens": full_input_estimate_tokens,
|
|
2032
|
+
"sent_input_estimate_tokens": sent_input_estimate_tokens,
|
|
2033
|
+
"estimator": "cl100k_base",
|
|
2034
|
+
"estimate_basis": "provider_prompt_payload",
|
|
2035
|
+
"input_mode": input_mode,
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
def _request_shape_metadata(
|
|
2039
|
+
current_payload: dict[str, Any],
|
|
2040
|
+
*,
|
|
2041
|
+
input_mode: str,
|
|
2042
|
+
) -> dict[str, Any]:
|
|
2043
|
+
return build_request_shape_report(
|
|
2044
|
+
messages=messages,
|
|
2045
|
+
tools=tools,
|
|
2046
|
+
cache_policy=cache_metadata,
|
|
2047
|
+
provider_payload=_prompt_estimation_payload(current_payload),
|
|
2048
|
+
input_mode=input_mode,
|
|
2049
|
+
)
|
|
2050
|
+
|
|
2051
|
+
def _request_plan_metadata(
|
|
2052
|
+
current_payload: dict[str, Any],
|
|
2053
|
+
*,
|
|
2054
|
+
input_mode: str,
|
|
2055
|
+
fallback_used: bool = False,
|
|
2056
|
+
) -> dict[str, Any]:
|
|
2057
|
+
extra: dict[str, Any] = {"fallback_used": fallback_used}
|
|
2058
|
+
if temperature_omit_reason is not None:
|
|
2059
|
+
extra.update(
|
|
2060
|
+
{
|
|
2061
|
+
"temperature_omitted": True,
|
|
2062
|
+
"temperature_omit_reason": temperature_omit_reason,
|
|
2063
|
+
}
|
|
2064
|
+
)
|
|
2065
|
+
return layout_plan.request_plan_metadata(
|
|
2066
|
+
input_mode=input_mode,
|
|
2067
|
+
continuation_strategy="full_replay",
|
|
2068
|
+
provider_payload=_prompt_estimation_payload(payload),
|
|
2069
|
+
sent_provider_payload=_prompt_estimation_payload(current_payload),
|
|
2070
|
+
cache_policy_metadata=cache_metadata,
|
|
2071
|
+
extra=extra,
|
|
2072
|
+
)
|
|
2073
|
+
|
|
2074
|
+
request_plan_metadata = _request_plan_metadata(payload, input_mode="full")
|
|
2075
|
+
|
|
2076
|
+
provider_key = self.provider_key or best_effort_provider_key(
|
|
2077
|
+
base_url=self.base_url,
|
|
2078
|
+
model=self.model,
|
|
2079
|
+
)
|
|
2080
|
+
telemetry = ProviderCallTelemetryRecorder(
|
|
2081
|
+
provider_key=provider_key,
|
|
2082
|
+
protocol="gemini_generate_content",
|
|
2083
|
+
model=self.model,
|
|
2084
|
+
base_url=self.base_url,
|
|
2085
|
+
stream=stream,
|
|
2086
|
+
tools=tools,
|
|
2087
|
+
web_search_mode=self.web_search_mode,
|
|
2088
|
+
web_search_adapter=self.web_search_adapter,
|
|
2089
|
+
native_web_search=tool_mapping.added_google_search,
|
|
2090
|
+
cache_policy=cache_metadata,
|
|
2091
|
+
request_plan=request_plan_metadata,
|
|
2092
|
+
request_shape=_request_shape_metadata(
|
|
2093
|
+
payload,
|
|
2094
|
+
input_mode="full",
|
|
2095
|
+
),
|
|
2096
|
+
token_reconciliation=_token_reconciliation_metadata(
|
|
2097
|
+
payload,
|
|
2098
|
+
input_mode="full",
|
|
2099
|
+
),
|
|
2100
|
+
operation="gemini_generate_content_chat",
|
|
2101
|
+
)
|
|
2102
|
+
telemetry_on_text_delta = telemetry.wrap_text_delta(on_text_delta)
|
|
2103
|
+
telemetry_on_reasoning_delta = telemetry.wrap_reasoning_delta(on_reasoning_delta)
|
|
2104
|
+
public_output_emitted = False
|
|
2105
|
+
|
|
2106
|
+
def _tracked_text_delta(delta: str) -> None:
|
|
2107
|
+
nonlocal public_output_emitted
|
|
2108
|
+
if delta:
|
|
2109
|
+
public_output_emitted = True
|
|
2110
|
+
if telemetry_on_text_delta is not None:
|
|
2111
|
+
telemetry_on_text_delta(delta)
|
|
2112
|
+
|
|
2113
|
+
def _tracked_reasoning_delta(delta: str) -> None:
|
|
2114
|
+
nonlocal public_output_emitted
|
|
2115
|
+
if delta:
|
|
2116
|
+
public_output_emitted = True
|
|
2117
|
+
if telemetry_on_reasoning_delta is not None:
|
|
2118
|
+
telemetry_on_reasoning_delta(delta)
|
|
2119
|
+
|
|
2120
|
+
def _send_request() -> LLMResponse:
|
|
2121
|
+
encoded_model = quote(self.model, safe="")
|
|
2122
|
+
operation = "streamGenerateContent?alt=sse" if stream else "generateContent"
|
|
2123
|
+
url = f"{self.base_url}/models/{encoded_model}:{operation}"
|
|
2124
|
+
try:
|
|
2125
|
+
with httpx.Client(timeout=self.timeout_s, transport=self._transport) as client:
|
|
2126
|
+
request_payload = payload
|
|
2127
|
+
active_request_plan_metadata = request_plan_metadata
|
|
2128
|
+
cached_content_name: str | None = None
|
|
2129
|
+
cache_lifecycle_metadata: dict[str, Any] = {}
|
|
2130
|
+
cache_creation_input_tokens: int | None = None
|
|
2131
|
+
stale_cached_content_retry_used = False
|
|
2132
|
+
thought_summary_fallback_used = False
|
|
2133
|
+
|
|
2134
|
+
def _consume_pending_cache_creation_tokens() -> None:
|
|
2135
|
+
# Called only once a response parsed successfully: the
|
|
2136
|
+
# creation spend is attached to this response's usage,
|
|
2137
|
+
# so retries reusing the entry must not report it again.
|
|
2138
|
+
if cache_plan is None or cache_creation_input_tokens is None:
|
|
2139
|
+
return
|
|
2140
|
+
entry = self._cached_content_by_signature.get(cache_plan.signature)
|
|
2141
|
+
if entry is not None and entry.pending_creation_tokens is not None:
|
|
2142
|
+
self._cached_content_by_signature[cache_plan.signature] = replace(
|
|
2143
|
+
entry,
|
|
2144
|
+
pending_creation_tokens=None,
|
|
2145
|
+
)
|
|
2146
|
+
|
|
2147
|
+
if cache_plan is not None:
|
|
2148
|
+
(
|
|
2149
|
+
cached_content_name,
|
|
2150
|
+
cache_status,
|
|
2151
|
+
cache_lifecycle_metadata,
|
|
2152
|
+
) = self._resolve_cached_content(
|
|
2153
|
+
client=client,
|
|
2154
|
+
plan=cache_plan,
|
|
2155
|
+
)
|
|
2156
|
+
raw_creation_tokens = cache_lifecycle_metadata.get(
|
|
2157
|
+
"cache_creation_input_tokens"
|
|
2158
|
+
)
|
|
2159
|
+
if isinstance(raw_creation_tokens, int):
|
|
2160
|
+
cache_creation_input_tokens = raw_creation_tokens
|
|
2161
|
+
if cache_metadata is not None:
|
|
2162
|
+
cache_metadata.update(cache_lifecycle_metadata)
|
|
2163
|
+
cache_metadata["status"] = cache_status
|
|
2164
|
+
cache_metadata["used"] = cached_content_name is not None
|
|
2165
|
+
if cached_content_name is None:
|
|
2166
|
+
cache_metadata["fallback"] = "full_payload"
|
|
2167
|
+
if cached_content_name is not None:
|
|
2168
|
+
request_payload = _build_payload(
|
|
2169
|
+
request_contents=cache_plan.suffix_contents,
|
|
2170
|
+
cached_content_name=cached_content_name,
|
|
2171
|
+
)
|
|
2172
|
+
active_request_plan_metadata = _request_plan_metadata(
|
|
2173
|
+
request_payload,
|
|
2174
|
+
input_mode="cached_content",
|
|
2175
|
+
)
|
|
2176
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2177
|
+
telemetry.set_request_shape(
|
|
2178
|
+
_request_shape_metadata(
|
|
2179
|
+
request_payload,
|
|
2180
|
+
input_mode="cached_content",
|
|
2181
|
+
)
|
|
2182
|
+
)
|
|
2183
|
+
telemetry.set_token_reconciliation(
|
|
2184
|
+
_token_reconciliation_metadata(
|
|
2185
|
+
request_payload,
|
|
2186
|
+
input_mode="cached_content",
|
|
2187
|
+
)
|
|
2188
|
+
)
|
|
2189
|
+
elif explicit_cached_content_requested and cache_metadata is not None:
|
|
2190
|
+
cache_metadata["status"] = (
|
|
2191
|
+
"disabled_for_request"
|
|
2192
|
+
if explicit_cached_content_disabled_reason
|
|
2193
|
+
else "not_eligible"
|
|
2194
|
+
)
|
|
2195
|
+
cache_metadata["used"] = False
|
|
2196
|
+
if explicit_cached_content_disabled_reason:
|
|
2197
|
+
cache_metadata["fallback"] = "full_payload"
|
|
2198
|
+
telemetry.set_request_shape(
|
|
2199
|
+
_request_shape_metadata(
|
|
2200
|
+
request_payload,
|
|
2201
|
+
input_mode=(
|
|
2202
|
+
"cache_disabled_for_request"
|
|
2203
|
+
if explicit_cached_content_disabled_reason
|
|
2204
|
+
else "not_eligible"
|
|
2205
|
+
),
|
|
2206
|
+
)
|
|
2207
|
+
)
|
|
2208
|
+
active_request_plan_metadata = _request_plan_metadata(
|
|
2209
|
+
request_payload,
|
|
2210
|
+
input_mode=(
|
|
2211
|
+
"cache_disabled_for_request"
|
|
2212
|
+
if explicit_cached_content_disabled_reason
|
|
2213
|
+
else "not_eligible"
|
|
2214
|
+
),
|
|
2215
|
+
fallback_used=True,
|
|
2216
|
+
)
|
|
2217
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2218
|
+
telemetry.set_token_reconciliation(
|
|
2219
|
+
_token_reconciliation_metadata(
|
|
2220
|
+
request_payload,
|
|
2221
|
+
input_mode=(
|
|
2222
|
+
"cache_disabled_for_request"
|
|
2223
|
+
if explicit_cached_content_disabled_reason
|
|
2224
|
+
else "not_eligible"
|
|
2225
|
+
),
|
|
2226
|
+
)
|
|
2227
|
+
)
|
|
2228
|
+
if cache_plan is not None and cached_content_name is None:
|
|
2229
|
+
active_request_plan_metadata = _request_plan_metadata(
|
|
2230
|
+
request_payload,
|
|
2231
|
+
input_mode="full",
|
|
2232
|
+
fallback_used=True,
|
|
2233
|
+
)
|
|
2234
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2235
|
+
telemetry.set_request_shape(
|
|
2236
|
+
_request_shape_metadata(
|
|
2237
|
+
request_payload,
|
|
2238
|
+
input_mode="full",
|
|
2239
|
+
)
|
|
2240
|
+
)
|
|
2241
|
+
telemetry.set_cache_policy(cache_metadata)
|
|
2242
|
+
while True:
|
|
2243
|
+
if stream:
|
|
2244
|
+
with client.stream(
|
|
2245
|
+
"POST",
|
|
2246
|
+
url,
|
|
2247
|
+
headers=self._headers(),
|
|
2248
|
+
json=request_payload,
|
|
2249
|
+
) as response:
|
|
2250
|
+
if response.status_code >= 400:
|
|
2251
|
+
response.read()
|
|
2252
|
+
if (
|
|
2253
|
+
not thought_summary_fallback_used
|
|
2254
|
+
and _gemini_thought_summary_request_rejected(response)
|
|
2255
|
+
and _remove_gemini_thought_summary_request(request_payload)
|
|
2256
|
+
):
|
|
2257
|
+
thought_summary_fallback_used = True
|
|
2258
|
+
self._thought_summaries_supported = False
|
|
2259
|
+
_remove_gemini_thought_summary_request(payload)
|
|
2260
|
+
active_request_plan_metadata = _request_plan_metadata(
|
|
2261
|
+
request_payload,
|
|
2262
|
+
input_mode="retry_without_thought_summaries",
|
|
2263
|
+
fallback_used=True,
|
|
2264
|
+
)
|
|
2265
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2266
|
+
telemetry.set_request_shape(
|
|
2267
|
+
_request_shape_metadata(
|
|
2268
|
+
request_payload,
|
|
2269
|
+
input_mode="retry_without_thought_summaries",
|
|
2270
|
+
)
|
|
2271
|
+
)
|
|
2272
|
+
telemetry.set_token_reconciliation(
|
|
2273
|
+
_token_reconciliation_metadata(
|
|
2274
|
+
request_payload,
|
|
2275
|
+
input_mode="retry_without_thought_summaries",
|
|
2276
|
+
)
|
|
2277
|
+
)
|
|
2278
|
+
continue
|
|
2279
|
+
stale_reason = (
|
|
2280
|
+
_gemini_cached_content_rejection_reason(response)
|
|
2281
|
+
if cached_content_name is not None
|
|
2282
|
+
and not stale_cached_content_retry_used
|
|
2283
|
+
else None
|
|
2284
|
+
)
|
|
2285
|
+
if stale_reason is not None and cache_plan is not None:
|
|
2286
|
+
stale_cached_content_retry_used = True
|
|
2287
|
+
self._clear_cached_content_entry(
|
|
2288
|
+
cache_plan,
|
|
2289
|
+
client=client,
|
|
2290
|
+
reason=stale_reason,
|
|
2291
|
+
lifecycle=cache_lifecycle_metadata,
|
|
2292
|
+
)
|
|
2293
|
+
if cache_metadata is not None:
|
|
2294
|
+
cache_metadata.update(cache_lifecycle_metadata)
|
|
2295
|
+
cache_metadata["status"] = "stale_retry"
|
|
2296
|
+
cache_metadata["used"] = False
|
|
2297
|
+
cache_metadata["fallback"] = "full_payload"
|
|
2298
|
+
telemetry.set_cache_policy(cache_metadata)
|
|
2299
|
+
cached_content_name = None
|
|
2300
|
+
request_payload = copy.deepcopy(payload)
|
|
2301
|
+
if self._thought_summaries_supported is False:
|
|
2302
|
+
_remove_gemini_thought_summary_request(request_payload)
|
|
2303
|
+
active_request_plan_metadata = _request_plan_metadata(
|
|
2304
|
+
request_payload,
|
|
2305
|
+
input_mode=("full_retry_after_cached_content_rejected"),
|
|
2306
|
+
fallback_used=True,
|
|
2307
|
+
)
|
|
2308
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2309
|
+
telemetry.set_request_shape(
|
|
2310
|
+
_request_shape_metadata(
|
|
2311
|
+
request_payload,
|
|
2312
|
+
input_mode=(
|
|
2313
|
+
"full_retry_after_cached_content_rejected"
|
|
2314
|
+
),
|
|
2315
|
+
)
|
|
2316
|
+
)
|
|
2317
|
+
telemetry.set_token_reconciliation(
|
|
2318
|
+
_token_reconciliation_metadata(
|
|
2319
|
+
request_payload,
|
|
2320
|
+
input_mode="full_retry_after_cached_content_rejected",
|
|
2321
|
+
)
|
|
2322
|
+
)
|
|
2323
|
+
continue
|
|
2324
|
+
raise self._llm_error_from_response(response)
|
|
2325
|
+
parsed_response = self._parse_stream_response(
|
|
2326
|
+
response,
|
|
2327
|
+
on_text_delta=(
|
|
2328
|
+
_tracked_text_delta
|
|
2329
|
+
if telemetry_on_text_delta is not None
|
|
2330
|
+
else None
|
|
2331
|
+
),
|
|
2332
|
+
on_reasoning_delta=(
|
|
2333
|
+
_tracked_reasoning_delta
|
|
2334
|
+
if telemetry_on_reasoning_delta is not None
|
|
2335
|
+
and _gemini_payload_requests_thought_summaries(
|
|
2336
|
+
request_payload
|
|
2337
|
+
)
|
|
2338
|
+
else None
|
|
2339
|
+
),
|
|
2340
|
+
reasoning_is_summary=_gemini_payload_requests_thought_summaries(
|
|
2341
|
+
request_payload
|
|
2342
|
+
),
|
|
2343
|
+
)
|
|
2344
|
+
_consume_pending_cache_creation_tokens()
|
|
2345
|
+
return _response_with_cache_metadata(
|
|
2346
|
+
parsed_response,
|
|
2347
|
+
cache_metadata,
|
|
2348
|
+
active_request_plan_metadata,
|
|
2349
|
+
cache_creation_input_tokens=cache_creation_input_tokens,
|
|
2350
|
+
)
|
|
2351
|
+
response = client.post(
|
|
2352
|
+
url,
|
|
2353
|
+
headers=self._headers(),
|
|
2354
|
+
json=request_payload,
|
|
2355
|
+
)
|
|
2356
|
+
if response.status_code < 400:
|
|
2357
|
+
break
|
|
2358
|
+
if (
|
|
2359
|
+
not thought_summary_fallback_used
|
|
2360
|
+
and _gemini_thought_summary_request_rejected(response)
|
|
2361
|
+
and _remove_gemini_thought_summary_request(request_payload)
|
|
2362
|
+
):
|
|
2363
|
+
thought_summary_fallback_used = True
|
|
2364
|
+
self._thought_summaries_supported = False
|
|
2365
|
+
_remove_gemini_thought_summary_request(payload)
|
|
2366
|
+
active_request_plan_metadata = _request_plan_metadata(
|
|
2367
|
+
request_payload,
|
|
2368
|
+
input_mode="retry_without_thought_summaries",
|
|
2369
|
+
fallback_used=True,
|
|
2370
|
+
)
|
|
2371
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2372
|
+
telemetry.set_request_shape(
|
|
2373
|
+
_request_shape_metadata(
|
|
2374
|
+
request_payload,
|
|
2375
|
+
input_mode="retry_without_thought_summaries",
|
|
2376
|
+
)
|
|
2377
|
+
)
|
|
2378
|
+
telemetry.set_token_reconciliation(
|
|
2379
|
+
_token_reconciliation_metadata(
|
|
2380
|
+
request_payload,
|
|
2381
|
+
input_mode="retry_without_thought_summaries",
|
|
2382
|
+
)
|
|
2383
|
+
)
|
|
2384
|
+
continue
|
|
2385
|
+
stale_reason = (
|
|
2386
|
+
_gemini_cached_content_rejection_reason(response)
|
|
2387
|
+
if cached_content_name is not None
|
|
2388
|
+
and not stale_cached_content_retry_used
|
|
2389
|
+
else None
|
|
2390
|
+
)
|
|
2391
|
+
if stale_reason is not None and cache_plan is not None:
|
|
2392
|
+
stale_cached_content_retry_used = True
|
|
2393
|
+
self._clear_cached_content_entry(
|
|
2394
|
+
cache_plan,
|
|
2395
|
+
client=client,
|
|
2396
|
+
reason=stale_reason,
|
|
2397
|
+
lifecycle=cache_lifecycle_metadata,
|
|
2398
|
+
)
|
|
2399
|
+
if cache_metadata is not None:
|
|
2400
|
+
cache_metadata.update(cache_lifecycle_metadata)
|
|
2401
|
+
cache_metadata["status"] = "stale_retry"
|
|
2402
|
+
cache_metadata["used"] = False
|
|
2403
|
+
cache_metadata["fallback"] = "full_payload"
|
|
2404
|
+
telemetry.set_cache_policy(cache_metadata)
|
|
2405
|
+
cached_content_name = None
|
|
2406
|
+
request_payload = copy.deepcopy(payload)
|
|
2407
|
+
if self._thought_summaries_supported is False:
|
|
2408
|
+
_remove_gemini_thought_summary_request(request_payload)
|
|
2409
|
+
active_request_plan_metadata = _request_plan_metadata(
|
|
2410
|
+
request_payload,
|
|
2411
|
+
input_mode="full_retry_after_cached_content_rejected",
|
|
2412
|
+
fallback_used=True,
|
|
2413
|
+
)
|
|
2414
|
+
telemetry.set_request_plan(active_request_plan_metadata)
|
|
2415
|
+
telemetry.set_request_shape(
|
|
2416
|
+
_request_shape_metadata(
|
|
2417
|
+
request_payload,
|
|
2418
|
+
input_mode="full_retry_after_cached_content_rejected",
|
|
2419
|
+
)
|
|
2420
|
+
)
|
|
2421
|
+
telemetry.set_token_reconciliation(
|
|
2422
|
+
_token_reconciliation_metadata(
|
|
2423
|
+
request_payload,
|
|
2424
|
+
input_mode="full_retry_after_cached_content_rejected",
|
|
2425
|
+
)
|
|
2426
|
+
)
|
|
2427
|
+
continue
|
|
2428
|
+
break
|
|
2429
|
+
except httpx.DecodingError as e:
|
|
2430
|
+
err = LLMError(
|
|
2431
|
+
"Gemini GenerateContent decompression failed: "
|
|
2432
|
+
f"{sanitize_error_text_for_output(e)}"
|
|
2433
|
+
)
|
|
2434
|
+
if stream and public_output_emitted:
|
|
2435
|
+
mark_provider_call_non_retryable(err)
|
|
2436
|
+
raise err from e
|
|
2437
|
+
except Exception as e: # noqa: BLE001
|
|
2438
|
+
if isinstance(e, LLMError):
|
|
2439
|
+
if stream and public_output_emitted:
|
|
2440
|
+
mark_provider_call_non_retryable(e)
|
|
2441
|
+
raise
|
|
2442
|
+
err = LLMError(
|
|
2443
|
+
f"Gemini GenerateContent request failed: {sanitize_error_text_for_output(e)}"
|
|
2444
|
+
)
|
|
2445
|
+
if stream and public_output_emitted:
|
|
2446
|
+
mark_provider_call_non_retryable(err)
|
|
2447
|
+
raise err from e
|
|
2448
|
+
if response.status_code >= 400:
|
|
2449
|
+
raise self._llm_error_from_response(response)
|
|
2450
|
+
parsed_response = self._parse_chat_response(
|
|
2451
|
+
response,
|
|
2452
|
+
on_reasoning_delta=(
|
|
2453
|
+
telemetry_on_reasoning_delta
|
|
2454
|
+
if _gemini_payload_requests_thought_summaries(request_payload)
|
|
2455
|
+
else None
|
|
2456
|
+
),
|
|
2457
|
+
reasoning_is_summary=_gemini_payload_requests_thought_summaries(request_payload),
|
|
2458
|
+
)
|
|
2459
|
+
_consume_pending_cache_creation_tokens()
|
|
2460
|
+
return _response_with_cache_metadata(
|
|
2461
|
+
parsed_response,
|
|
2462
|
+
cache_metadata,
|
|
2463
|
+
active_request_plan_metadata,
|
|
2464
|
+
cache_creation_input_tokens=cache_creation_input_tokens,
|
|
2465
|
+
)
|
|
2466
|
+
|
|
2467
|
+
return stamp_response_for_route(
|
|
2468
|
+
telemetry.run(
|
|
2469
|
+
lambda: run_provider_limited_call(
|
|
2470
|
+
call=_send_request,
|
|
2471
|
+
provider_key=provider_key,
|
|
2472
|
+
provider_concurrency_caps=self.provider_concurrency_caps,
|
|
2473
|
+
retry_settings=self.provider_retry_settings,
|
|
2474
|
+
operation="gemini_generate_content_chat",
|
|
2475
|
+
sleep_fn=self._provider_sleep_fn,
|
|
2476
|
+
random_fn=self._provider_random_fn,
|
|
2477
|
+
on_retry=telemetry.on_retry,
|
|
2478
|
+
on_retry_event=getattr(
|
|
2479
|
+
self,
|
|
2480
|
+
"_provider_retry_event_observer",
|
|
2481
|
+
None,
|
|
2482
|
+
),
|
|
2483
|
+
retry_deadline_allows=getattr(self, "_provider_retry_deadline_allows", None),
|
|
2484
|
+
)
|
|
2485
|
+
),
|
|
2486
|
+
self.route_identity,
|
|
2487
|
+
)
|
|
2488
|
+
|
|
2489
|
+
@staticmethod
|
|
2490
|
+
def _parse_stream_response(
|
|
2491
|
+
response: httpx.Response,
|
|
2492
|
+
*,
|
|
2493
|
+
on_text_delta: Callable[[str], None] | None,
|
|
2494
|
+
on_reasoning_delta: Callable[[str], None] | None,
|
|
2495
|
+
reasoning_is_summary: bool = False,
|
|
2496
|
+
) -> LLMResponse:
|
|
2497
|
+
accumulator = _GeminiStreamAccumulator(
|
|
2498
|
+
on_text_delta=on_text_delta,
|
|
2499
|
+
on_reasoning_delta=on_reasoning_delta if reasoning_is_summary else None,
|
|
2500
|
+
)
|
|
2501
|
+
for frame in iter_sse_frames(response.iter_lines()):
|
|
2502
|
+
raw_event = parse_sse_json_frame(frame, stream_name="Gemini GenerateContent stream")
|
|
2503
|
+
if not isinstance(raw_event, dict):
|
|
2504
|
+
raise LLMError("Gemini GenerateContent stream emitted non-object JSON event")
|
|
2505
|
+
accumulator.handle(frame, raw_event)
|
|
2506
|
+
data = accumulator.finish()
|
|
2507
|
+
return GeminiGenerateContentClient._parse_chat_response(
|
|
2508
|
+
_response_from_json(data),
|
|
2509
|
+
reasoning_is_summary=reasoning_is_summary,
|
|
2510
|
+
)
|
|
2511
|
+
|
|
2512
|
+
@staticmethod
|
|
2513
|
+
def _parse_chat_response(
|
|
2514
|
+
response: httpx.Response,
|
|
2515
|
+
*,
|
|
2516
|
+
on_reasoning_delta: Callable[[str], None] | None = None,
|
|
2517
|
+
reasoning_is_summary: bool = False,
|
|
2518
|
+
) -> LLMResponse:
|
|
2519
|
+
try:
|
|
2520
|
+
data = response.json()
|
|
2521
|
+
except Exception as e: # noqa: BLE001
|
|
2522
|
+
raise LLMError("Gemini GenerateContent returned non-JSON response") from e
|
|
2523
|
+
if not isinstance(data, dict):
|
|
2524
|
+
raise LLMError("Unexpected Gemini GenerateContent payload: expected JSON object")
|
|
2525
|
+
candidate = _candidate(data)
|
|
2526
|
+
if candidate is None:
|
|
2527
|
+
raise LLMError("Unexpected Gemini GenerateContent payload: missing candidates")
|
|
2528
|
+
content = _candidate_content(candidate)
|
|
2529
|
+
if content is None:
|
|
2530
|
+
finish_reason = str(candidate.get("finishReason") or "").strip()
|
|
2531
|
+
suffix = f" (finish_reason={finish_reason})" if finish_reason else ""
|
|
2532
|
+
raise LLMError(f"Gemini GenerateContent returned no candidate content{suffix}")
|
|
2533
|
+
parts = content.get("parts")
|
|
2534
|
+
if not isinstance(parts, list):
|
|
2535
|
+
raise LLMError("Unexpected Gemini GenerateContent payload: missing content parts")
|
|
2536
|
+
|
|
2537
|
+
text = _extract_text(parts)
|
|
2538
|
+
tool_calls = _parse_tool_calls(parts)
|
|
2539
|
+
if not text and not tool_calls:
|
|
2540
|
+
finish_reason = str(candidate.get("finishReason") or "").strip()
|
|
2541
|
+
suffix = f" (finish_reason={finish_reason})" if finish_reason else ""
|
|
2542
|
+
raise LLMError(
|
|
2543
|
+
f"Gemini GenerateContent returned no assistant text or tool calls{suffix}"
|
|
2544
|
+
)
|
|
2545
|
+
parsed_response = LLMResponse(
|
|
2546
|
+
content=text,
|
|
2547
|
+
tool_calls=tool_calls,
|
|
2548
|
+
raw=data,
|
|
2549
|
+
response_model=data.get("modelVersion")
|
|
2550
|
+
if isinstance(data.get("modelVersion"), str)
|
|
2551
|
+
else None,
|
|
2552
|
+
usage=_parse_usage(data.get("usageMetadata")),
|
|
2553
|
+
provider_metadata=_gemini_provider_metadata(data, candidate),
|
|
2554
|
+
reasoning=(_reasoning_outputs_from_parts(parts) if reasoning_is_summary else ()),
|
|
2555
|
+
)
|
|
2556
|
+
if reasoning_is_summary:
|
|
2557
|
+
_emit_reasoning_parts(parts, on_reasoning_delta)
|
|
2558
|
+
return parsed_response
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
def _gemini_response_format(response_format: dict[str, Any] | None) -> dict[str, Any]:
|
|
2562
|
+
if not response_format:
|
|
2563
|
+
return {}
|
|
2564
|
+
response_type = str(response_format.get("type") or "").strip()
|
|
2565
|
+
if response_type == "json_object":
|
|
2566
|
+
return {"responseMimeType": "application/json"}
|
|
2567
|
+
if response_type == "json_schema":
|
|
2568
|
+
raw_json_schema = response_format.get("json_schema")
|
|
2569
|
+
json_schema = raw_json_schema if isinstance(raw_json_schema, dict) else response_format
|
|
2570
|
+
schema = json_schema.get("schema")
|
|
2571
|
+
if not isinstance(schema, dict):
|
|
2572
|
+
raise LLMError(
|
|
2573
|
+
"Gemini GenerateContent json_schema response_format requires schema object"
|
|
2574
|
+
)
|
|
2575
|
+
return {
|
|
2576
|
+
"responseMimeType": "application/json",
|
|
2577
|
+
"responseSchema": copy.deepcopy(schema),
|
|
2578
|
+
}
|
|
2579
|
+
if response_type == "text":
|
|
2580
|
+
return {}
|
|
2581
|
+
raise LLMError(
|
|
2582
|
+
f"Gemini GenerateContent does not support response_format type {response_type!r}"
|
|
2583
|
+
)
|
|
2584
|
+
|
|
2585
|
+
|
|
2586
|
+
def _thinking_budget(reasoning_effort: str) -> int:
|
|
2587
|
+
effort = str(reasoning_effort or "").strip().lower()
|
|
2588
|
+
if effort in {"none", "minimal"}:
|
|
2589
|
+
return 0
|
|
2590
|
+
if effort == "low":
|
|
2591
|
+
return 1024
|
|
2592
|
+
if effort == "medium":
|
|
2593
|
+
return 4096
|
|
2594
|
+
if effort == "high":
|
|
2595
|
+
return 8192
|
|
2596
|
+
if effort == "xhigh":
|
|
2597
|
+
return 16384
|
|
2598
|
+
raise LLMError(f"Gemini GenerateContent reasoning_effort is not supported: {effort}")
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
def _is_gemini_3_model(model: str | None) -> bool:
|
|
2602
|
+
return "gemini-3" in str(model or "").strip().lower()
|
|
2603
|
+
|
|
2604
|
+
|
|
2605
|
+
def _thinking_level_from_reasoning_effort(
|
|
2606
|
+
*,
|
|
2607
|
+
model: str,
|
|
2608
|
+
reasoning_effort: str,
|
|
2609
|
+
) -> str | None:
|
|
2610
|
+
effort = str(reasoning_effort or "").strip().lower()
|
|
2611
|
+
contract = reasoning_contract_for("gemini", model)
|
|
2612
|
+
if contract.wire != WIRE_THINKING_LEVEL:
|
|
2613
|
+
return None
|
|
2614
|
+
if contract.allows_value(effort):
|
|
2615
|
+
return effort
|
|
2616
|
+
return contract.default or None
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
def _gemini_thinking_config(
|
|
2620
|
+
*,
|
|
2621
|
+
model: str,
|
|
2622
|
+
enable_thinking: bool | None,
|
|
2623
|
+
reasoning_effort: str | None,
|
|
2624
|
+
thinking_level: str | None,
|
|
2625
|
+
thinking_budget: int | None,
|
|
2626
|
+
) -> dict[str, Any]:
|
|
2627
|
+
if thinking_level is not None and thinking_budget is not None:
|
|
2628
|
+
raise LLMError("Gemini GenerateContent cannot set both thinking_level and thinking_budget")
|
|
2629
|
+
contract = reasoning_contract_for("gemini", model)
|
|
2630
|
+
if thinking_level is not None and contract.wire != WIRE_THINKING_LEVEL:
|
|
2631
|
+
raise LLMError("Gemini GenerateContent thinking_level requires a Gemini 3 model")
|
|
2632
|
+
if thinking_level is not None:
|
|
2633
|
+
normalized_level = str(thinking_level or "").strip().lower()
|
|
2634
|
+
if not contract.allows_value(normalized_level):
|
|
2635
|
+
allowed = ", ".join(contract.values)
|
|
2636
|
+
raise LLMError(f"Gemini GenerateContent thinking_level must be one of: {allowed}")
|
|
2637
|
+
return {"thinkingLevel": normalized_level}
|
|
2638
|
+
if thinking_budget is not None:
|
|
2639
|
+
return {"thinkingBudget": int(thinking_budget)}
|
|
2640
|
+
if reasoning_effort:
|
|
2641
|
+
if _is_gemini_3_model(model):
|
|
2642
|
+
level = _thinking_level_from_reasoning_effort(
|
|
2643
|
+
model=model,
|
|
2644
|
+
reasoning_effort=reasoning_effort,
|
|
2645
|
+
)
|
|
2646
|
+
return {"thinkingLevel": level} if level else {}
|
|
2647
|
+
return {"thinkingBudget": _thinking_budget(reasoning_effort)}
|
|
2648
|
+
if enable_thinking is False:
|
|
2649
|
+
if _is_gemini_3_model(model):
|
|
2650
|
+
return {"thinkingLevel": contract.default} if contract.default else {}
|
|
2651
|
+
return {"thinkingBudget": 0}
|
|
2652
|
+
return {}
|