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.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. 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 {}