pi-ai-python 1.3.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 (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,927 @@
1
+ # pyright: reportAttributeAccessIssue=false, reportArgumentType=false
2
+ # Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
3
+ """Shared utilities for the Google Generative AI and Google Vertex APIs.
4
+
5
+ Like pi's TypeScript adapters (which use ``@google/genai``), both adapters send requests
6
+ through the official ``google-genai`` SDK (``client.aio.models.generate_content_stream``).
7
+ This module holds what they share: message/tool conversion, thinking configuration,
8
+ stop reasons, usage, the retry policy, SDK client construction and the stream producer.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import itertools
15
+ import json
16
+ import re
17
+ from collections import OrderedDict
18
+ from collections.abc import AsyncIterator, Awaitable, Callable, Hashable, Mapping, Sequence
19
+ from enum import Enum
20
+ from typing import Any, Literal, Required, TypeAlias, TypedDict, TypeVar
21
+
22
+ import httpx
23
+ from google.genai import Client
24
+ from google.genai import errors as genai_errors
25
+ from google.genai import types as genai_types
26
+
27
+ from ..abort import AbortError, abort_scope
28
+ from ..event_stream import AssistantMessageEventStream
29
+ from ..models import calculate_cost, clamp_thinking_level
30
+ from ..types import (
31
+ AssistantMessage,
32
+ DoneEvent,
33
+ ErrorEvent,
34
+ Model,
35
+ ProviderResponse,
36
+ StartEvent,
37
+ StopReason,
38
+ TextContent,
39
+ TextDeltaEvent,
40
+ TextEndEvent,
41
+ TextStartEvent,
42
+ ThinkingContent,
43
+ ThinkingDeltaEvent,
44
+ ThinkingEndEvent,
45
+ ThinkingStartEvent,
46
+ Tool,
47
+ ToolCall,
48
+ ToolCallDeltaEvent,
49
+ ToolCallEndEvent,
50
+ ToolCallStartEvent,
51
+ TranscriptContext,
52
+ Usage,
53
+ now_ms,
54
+ )
55
+ from ..utils.callbacks import maybe_await
56
+ from ..utils.error_body import format_provider_error, normalize_provider_error
57
+ from ..utils.http import ProviderHttpError, retry_provider_request
58
+ from ..utils.misc import get_pi_user_agent, provider_headers_to_record, sanitize_surrogates
59
+ from ..utils.text import append_text, append_thinking, get_system_message_text
60
+ from ..utils.transcript import (
61
+ collapse_system_messages,
62
+ get_current_tools,
63
+ get_initial_system_message,
64
+ without_initial_system_message,
65
+ )
66
+ from .constrained_sampling import get_json_schema_tool_parameters, resolve_json_schema_strict_sampling
67
+ from .transform_messages import transform_messages
68
+
69
+ __all__ = [
70
+ "GoogleApiThinkingLevel",
71
+ "ResolvedGoogleThinkingLevel",
72
+ "GoogleThinkingOptions",
73
+ "resolve_google_thinking_level",
74
+ "uses_google_thinking_level",
75
+ "to_google_thinking_level",
76
+ "to_google_sdk_thinking_level",
77
+ "get_disabled_google_thinking_config",
78
+ "is_thinking_part",
79
+ "retain_thought_signature",
80
+ "requires_tool_call_id",
81
+ "convert_messages",
82
+ "convert_tools",
83
+ "supports_google_strict_tool_sampling",
84
+ "map_tool_choice",
85
+ "resolve_google_function_calling_mode",
86
+ "map_stop_reason",
87
+ "map_stop_reason_string",
88
+ "retry_google_request",
89
+ "parse_google_usage",
90
+ "build_google_params",
91
+ "build_google_http_headers",
92
+ "google_api_error",
93
+ "get_google_client",
94
+ "new_google_output",
95
+ "run_google_stream",
96
+ ]
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Thinking levels
100
+ # ---------------------------------------------------------------------------
101
+
102
+ #: Thinking level for Gemini 3 models. Mirrors Google's ThinkingLevel enum values.
103
+ GoogleApiThinkingLevel: TypeAlias = Literal["THINKING_LEVEL_UNSPECIFIED", "MINIMAL", "LOW", "MEDIUM", "HIGH"]
104
+ ResolvedGoogleThinkingLevel: TypeAlias = Literal["minimal", "low", "medium", "high"]
105
+
106
+ _RESOLVED_LEVELS = frozenset(("minimal", "low", "medium", "high"))
107
+
108
+
109
+ class GoogleThinkingOptions(TypedDict, total=False):
110
+ """``thinking`` option of the Google adapters."""
111
+
112
+ enabled: Required[bool]
113
+ #: -1 for dynamic, 0 to disable.
114
+ budget_tokens: int
115
+ level: GoogleApiThinkingLevel
116
+
117
+
118
+ def resolve_google_thinking_level(model: Model, level: str) -> ResolvedGoogleThinkingLevel:
119
+ """Resolve a supported pi level or model-specific Google mapping to a standard Google level."""
120
+ level_map = model.thinking_level_map or {}
121
+ mapped = level_map.get(level)
122
+ resolved = mapped.lower() if isinstance(mapped, str) else level
123
+ if resolved in _RESOLVED_LEVELS:
124
+ return resolved # type: ignore[return-value]
125
+ # String(undefined) / String(null) in the upstream message.
126
+ mapped_text = "undefined" if level not in level_map else ("null" if mapped is None else str(mapped))
127
+ raise ValueError(
128
+ f"Unsupported Google thinking level mapping for {model.provider}/{model.id}: {level} -> {mapped_text}"
129
+ )
130
+
131
+
132
+ _GEMINI_3_PRO_FLASH = re.compile(r"gemini-3(?:\.\d+)?-(?:pro|flash)")
133
+ _GEMINI_4_PLUS = re.compile(r"gemini-(?:[4-9]|[1-9]\d)")
134
+ _GEMMA_4 = re.compile(r"gemma-?4")
135
+
136
+
137
+ def uses_google_thinking_level(model: Model) -> bool:
138
+ """Whether this model uses Gemini's discrete ``thinkingLevel`` control instead of ``thinkingBudget``.
139
+
140
+ Supported levels come from the model's ``thinking_level_map``; this only selects the wire format.
141
+ """
142
+ model_id = model.id.lower()
143
+ return bool(
144
+ # Match Gemini 3 Pro/Flash IDs with or without a minor version, such as
145
+ # gemini-3-flash-preview, gemini-3.1-pro-preview, and gemini-3.8-flash.
146
+ _GEMINI_3_PRO_FLASH.search(model_id)
147
+ # Match Gemini 4 and later, such as gemini-4-argon (#10890).
148
+ or _GEMINI_4_PLUS.search(model_id)
149
+ or model_id == "gemini-flash-latest"
150
+ or model_id == "gemini-flash-lite-latest"
151
+ # Match both hosted Gemma 4 naming forms: gemma-4-* and gemma4-*.
152
+ or _GEMMA_4.search(model_id)
153
+ )
154
+
155
+
156
+ def to_google_thinking_level(level: ResolvedGoogleThinkingLevel) -> GoogleApiThinkingLevel:
157
+ return level.upper() # type: ignore[return-value]
158
+
159
+
160
+ def to_google_sdk_thinking_level(level: GoogleApiThinkingLevel) -> GoogleApiThinkingLevel:
161
+ """The SDK's ``ThinkingLevel`` enum value (identical strings on the wire)."""
162
+ return level
163
+
164
+
165
+ def get_disabled_google_thinking_config(model: Model) -> dict[str, Any]:
166
+ """``thinkingConfig`` that turns thinking off, or the lowest level for models that cannot disable it."""
167
+ if not uses_google_thinking_level(model):
168
+ return {"thinkingBudget": 0}
169
+ fallback = clamp_thinking_level(model, "off")
170
+ if fallback == "off":
171
+ return {"thinkingBudget": 0}
172
+ resolved = resolve_google_thinking_level(model, fallback)
173
+ return {"thinkingLevel": to_google_sdk_thinking_level(to_google_thinking_level(resolved))}
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Thought signatures
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def is_thinking_part(part: genai_types.Part | Mapping[str, Any]) -> bool:
182
+ """Whether a streamed Gemini ``Part`` (an SDK ``Part`` or its dict form) is thinking content.
183
+
184
+ Protocol note (Gemini / Vertex AI thought signatures):
185
+
186
+ * ``thought: true`` is the definitive marker for thinking content (thought summaries).
187
+ * ``thoughtSignature`` is an encrypted representation of the model's internal thought
188
+ process used to preserve reasoning context across multi-turn interactions. It can
189
+ appear on ANY part type (text, functionCall, ...) and does NOT mark the part as thinking.
190
+ * For non-functionCall responses, the signature appears on the last part for context replay.
191
+ * When persisting/replaying model outputs, signature-bearing parts must be preserved as-is;
192
+ do not merge/move signatures across parts.
193
+
194
+ See https://ai.google.dev/gemini-api/docs/thought-signatures
195
+ """
196
+ thought = part.get("thought") if isinstance(part, Mapping) else part.thought
197
+ return thought is True
198
+
199
+
200
+ def retain_thought_signature(existing: str | None, incoming: str | None) -> str | None:
201
+ """Keep the last non-empty signature for the current streamed block.
202
+
203
+ Some backends only send ``thoughtSignature`` on the first delta for a part; later deltas
204
+ may omit it. This does NOT move signatures across distinct response parts.
205
+ """
206
+ if isinstance(incoming, str) and incoming:
207
+ return incoming
208
+ return existing
209
+
210
+
211
+ # Thought signatures must be base64 for Google APIs (TYPE_BYTES).
212
+ _BASE64_SIGNATURE = re.compile(r"[A-Za-z0-9+/]+={0,2}")
213
+
214
+
215
+ def _is_valid_thought_signature(signature: str | None) -> bool:
216
+ if not signature:
217
+ return False
218
+ if len(signature) % 4 != 0:
219
+ return False
220
+ return _BASE64_SIGNATURE.fullmatch(signature) is not None
221
+
222
+
223
+ def _resolve_thought_signature(is_same_provider_and_model: bool, signature: str | None) -> str | None:
224
+ """Only keep signatures from the same provider/model and with valid base64."""
225
+ return signature if is_same_provider_and_model and _is_valid_thought_signature(signature) else None
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Messages
230
+ # ---------------------------------------------------------------------------
231
+
232
+ _GEMINI_MAJOR_VERSION = re.compile(r"^gemini(?:-live)?-(\d+)")
233
+ _INVALID_TOOL_CALL_ID_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
234
+
235
+
236
+ def _get_gemini_major_version(model_id: str) -> int | None:
237
+ match = _GEMINI_MAJOR_VERSION.match(model_id.lower())
238
+ return int(match.group(1)) if match else None
239
+
240
+
241
+ def requires_tool_call_id(model_id: str) -> bool:
242
+ """Models via Google APIs that require explicit tool call IDs in function calls/responses."""
243
+ major = _get_gemini_major_version(model_id)
244
+ return model_id.startswith("claude-") or model_id.startswith("gpt-oss-") or (major is not None and major >= 3)
245
+
246
+
247
+ def _supports_multimodal_function_response(model_id: str) -> bool:
248
+ major = _get_gemini_major_version(model_id)
249
+ if major is not None:
250
+ return major >= 3
251
+ return True
252
+
253
+
254
+ def convert_messages(model: Model, context: TranscriptContext) -> list[dict[str, Any]]:
255
+ """Convert transcript messages to Gemini ``Content[]``."""
256
+ # Gemini has no mid-conversation system messages; the leading prompt is sent as systemInstruction.
257
+ conversation = without_initial_system_message(collapse_system_messages(context).messages)
258
+ contents: list[dict[str, Any]] = []
259
+ include_id = requires_tool_call_id(model.id)
260
+
261
+ def normalize_tool_call_id(tool_call_id: str, _model: Model, _source: AssistantMessage) -> str:
262
+ if not include_id:
263
+ return tool_call_id
264
+ return _INVALID_TOOL_CALL_ID_CHARS.sub("_", tool_call_id)[:64]
265
+
266
+ for msg in transform_messages(conversation, model, normalize_tool_call_id):
267
+ role = msg.role
268
+ if role == "user":
269
+ if isinstance(msg.content, str):
270
+ contents.append({"role": "user", "parts": [{"text": sanitize_surrogates(msg.content)}]})
271
+ continue
272
+ parts: list[dict[str, Any]] = []
273
+ for item in msg.content:
274
+ if item.type == "text":
275
+ parts.append({"text": sanitize_surrogates(item.text)})
276
+ else:
277
+ parts.append({"inlineData": {"mimeType": item.mime_type, "data": item.data}})
278
+ if not parts:
279
+ continue
280
+ contents.append({"role": "user", "parts": parts})
281
+ elif role == "assistant":
282
+ parts = []
283
+ # Check if message is from same provider and model - only then keep thinking blocks
284
+ is_same = msg.provider == model.provider and msg.model == model.id
285
+ for block in msg.content:
286
+ kind = block.type
287
+ if kind == "text":
288
+ signature = _resolve_thought_signature(is_same, block.text_signature)
289
+ # Skip empty text blocks — unless they carry a thought signature. Gemini can attach
290
+ # the signature to a part whose visible text is empty and requires it echoed back;
291
+ # dropping it breaks the reasoning chain and the model intermittently ends mid-task
292
+ # turns with a thought-only STOP (empty completion, no tool call).
293
+ if (not block.text or not block.text.strip()) and not signature:
294
+ continue
295
+ part: dict[str, Any] = {"text": sanitize_surrogates(block.text)}
296
+ if signature:
297
+ part["thoughtSignature"] = signature
298
+ parts.append(part)
299
+ elif kind == "thinking":
300
+ # Only keep as thinking block if same provider AND same model.
301
+ # Otherwise convert to plain text (no tags to avoid model mimicking them).
302
+ if is_same:
303
+ signature = _resolve_thought_signature(is_same, block.thinking_signature)
304
+ # Same rule as text blocks: an empty thinking block is dropped only when it
305
+ # carries no signature (mirrors the anthropic converter's handling).
306
+ if (not block.thinking or not block.thinking.strip()) and not signature:
307
+ continue
308
+ part = {"thought": True, "text": sanitize_surrogates(block.thinking)}
309
+ if signature:
310
+ part["thoughtSignature"] = signature
311
+ parts.append(part)
312
+ else:
313
+ # Cross-provider/model: the signature is unusable, empty blocks stay dropped.
314
+ if not block.thinking or not block.thinking.strip():
315
+ continue
316
+ parts.append({"text": sanitize_surrogates(block.thinking)})
317
+ elif kind == "toolCall":
318
+ signature = _resolve_thought_signature(is_same, block.thought_signature)
319
+ function_call: dict[str, Any] = {
320
+ "name": block.name,
321
+ "args": block.arguments if block.arguments is not None else {},
322
+ }
323
+ if include_id:
324
+ function_call["id"] = block.id
325
+ part = {"functionCall": function_call}
326
+ if signature:
327
+ part["thoughtSignature"] = signature
328
+ parts.append(part)
329
+ if not parts:
330
+ continue
331
+ contents.append({"role": "model", "parts": parts})
332
+ elif role == "toolResult":
333
+ text_result = "\n".join(block.text for block in msg.content if block.type == "text")
334
+ images = [block for block in msg.content if block.type == "image"] if "image" in model.input else []
335
+ has_text = bool(text_result.strip())
336
+ has_images = bool(images)
337
+ # Gemini 3+ models support multimodal function responses with images nested inside
338
+ # functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist /
339
+ # Gemini < 3 still need a separate user image turn.
340
+ multimodal = _supports_multimodal_function_response(model.id)
341
+ # Use "output" key for success, "error" key for errors as per SDK documentation
342
+ response_value = (
343
+ sanitize_surrogates(text_result) if has_text else "(see attached image)" if has_images else ""
344
+ )
345
+ image_parts = [{"inlineData": {"mimeType": image.mime_type, "data": image.data}} for image in images]
346
+ function_response: dict[str, Any] = {
347
+ "name": msg.tool_name,
348
+ "response": {"error": response_value} if msg.is_error else {"output": response_value},
349
+ }
350
+ if has_images and multimodal:
351
+ function_response["parts"] = image_parts
352
+ if include_id:
353
+ function_response["id"] = msg.tool_call_id
354
+ function_response_part = {"functionResponse": function_response}
355
+
356
+ # Cloud Code Assist API requires all function responses to be in a single user turn.
357
+ # Check if the last content is already a user turn with function responses and merge.
358
+ last = contents[-1] if contents else None
359
+ if (
360
+ last is not None
361
+ and last.get("role") == "user"
362
+ and any(part.get("functionResponse") for part in last.get("parts") or ())
363
+ ):
364
+ last["parts"].append(function_response_part)
365
+ else:
366
+ contents.append({"role": "user", "parts": [function_response_part]})
367
+
368
+ # For Gemini < 3, add images in a separate user message
369
+ if has_images and not multimodal:
370
+ contents.append({"role": "user", "parts": [{"text": "Tool result image:"}, *image_parts]})
371
+ return contents
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Tools
376
+ # ---------------------------------------------------------------------------
377
+
378
+ _JSON_SCHEMA_META_DECLARATIONS = frozenset(
379
+ (
380
+ "$schema",
381
+ "$id",
382
+ "$anchor",
383
+ "$dynamicAnchor",
384
+ "$vocabulary",
385
+ "$comment",
386
+ "$defs",
387
+ "definitions", # pre-draft-2019-09 equivalent of $defs
388
+ )
389
+ )
390
+
391
+
392
+ def _sanitize_for_open_api(schema: Any) -> Any:
393
+ """Strip meta-declarations from a schema object (returns a copy)."""
394
+ if not isinstance(schema, dict):
395
+ return schema
396
+ return {
397
+ key: _sanitize_for_open_api(value) for key, value in schema.items() if key not in _JSON_SCHEMA_META_DECLARATIONS
398
+ }
399
+
400
+
401
+ def convert_tools(
402
+ tools: Sequence[Tool], use_parameters: bool = False, supports_strict_mode: bool = True
403
+ ) -> list[dict[str, Any]] | None:
404
+ """Convert tools to Gemini function declarations.
405
+
406
+ By default uses ``parametersJsonSchema``, which supports full JSON Schema (``anyOf``,
407
+ ``oneOf``, ``const``, ...). ``use_parameters=True`` uses the legacy ``parameters`` field
408
+ (OpenAPI 3.03 Schema) instead, which Cloud Code Assist needs for Claude models because the
409
+ API translates ``parameters`` into Anthropic's ``input_schema``.
410
+ """
411
+ if not tools:
412
+ return None
413
+ declarations: list[dict[str, Any]] = []
414
+ for tool in tools:
415
+ strict = resolve_json_schema_strict_sampling(tool, supports_strict_mode)
416
+ parameters = get_json_schema_tool_parameters(tool, strict)
417
+ declaration: dict[str, Any] = {"name": tool.name, "description": tool.description}
418
+ if use_parameters:
419
+ declaration["parameters"] = _sanitize_for_open_api(parameters)
420
+ else:
421
+ declaration["parametersJsonSchema"] = parameters
422
+ declarations.append(declaration)
423
+ return [{"functionDeclarations": declarations}]
424
+
425
+
426
+ def supports_google_strict_tool_sampling(model_id: str) -> bool:
427
+ """Gemini 3+ enforces required function parameters in validated tool-calling modes."""
428
+ major = _get_gemini_major_version(model_id)
429
+ return major is not None and major >= 3
430
+
431
+
432
+ def map_tool_choice(choice: str) -> str:
433
+ """Map a tool choice string to Gemini's ``FunctionCallingConfigMode``."""
434
+ if choice == "none":
435
+ return "NONE"
436
+ if choice == "any":
437
+ return "ANY"
438
+ return "AUTO"
439
+
440
+
441
+ def resolve_google_function_calling_mode(
442
+ tools: Sequence[Tool], tool_choice: str | None, supports_strict_mode: bool
443
+ ) -> str | None:
444
+ use_strict_mode = any(resolve_json_schema_strict_sampling(tool, supports_strict_mode) is True for tool in tools)
445
+ if tool_choice in ("none", "any"):
446
+ return map_tool_choice(tool_choice)
447
+ if use_strict_mode:
448
+ return "VALIDATED"
449
+ return map_tool_choice(tool_choice) if tool_choice else None
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # Stop reasons, usage, retries
454
+ # ---------------------------------------------------------------------------
455
+
456
+ _ERROR_FINISH_REASONS = frozenset(
457
+ (
458
+ "BLOCKLIST",
459
+ "PROHIBITED_CONTENT",
460
+ "SPII",
461
+ "SAFETY",
462
+ "IMAGE_SAFETY",
463
+ "IMAGE_PROHIBITED_CONTENT",
464
+ "IMAGE_RECITATION",
465
+ "IMAGE_OTHER",
466
+ "RECITATION",
467
+ "FINISH_REASON_UNSPECIFIED",
468
+ "OTHER",
469
+ "LANGUAGE",
470
+ "MALFORMED_FUNCTION_CALL",
471
+ "UNEXPECTED_TOOL_CALL",
472
+ "TOO_MANY_TOOL_CALLS",
473
+ "NO_IMAGE",
474
+ )
475
+ )
476
+
477
+
478
+ def map_stop_reason(reason: str) -> StopReason:
479
+ """Map a Gemini ``FinishReason`` to a pi stop reason (unknown reasons raise, like upstream)."""
480
+ if reason == "STOP":
481
+ return "stop"
482
+ if reason == "MAX_TOKENS":
483
+ return "length"
484
+ if reason in _ERROR_FINISH_REASONS:
485
+ return "error"
486
+ raise ValueError(f"Unhandled stop reason: {reason}")
487
+
488
+
489
+ def map_stop_reason_string(reason: str) -> StopReason:
490
+ """Map a string finish reason to a pi stop reason (for raw API responses)."""
491
+ if reason == "STOP":
492
+ return "stop"
493
+ if reason == "MAX_TOKENS":
494
+ return "length"
495
+ return "error"
496
+
497
+
498
+ T = TypeVar("T")
499
+
500
+
501
+ class _UnretriedError(Exception):
502
+ """Carries an error past the provider retry policy unchanged."""
503
+
504
+ def __init__(self, error: BaseException) -> None:
505
+ super().__init__(str(error))
506
+ self.error = error
507
+
508
+
509
+ def google_api_error(error: genai_errors.APIError) -> ProviderHttpError:
510
+ """The SDK's ``APIError`` as a :class:`ProviderHttpError` worded like ``@google/genai``'s ``ApiError``.
511
+
512
+ * Error status: the message is the JSON error body. A non-JSON body becomes
513
+ ``{"error": {"message": <text>, "code": <status>, "status": <reason>}}``.
514
+ * Error event inside a 200 event stream: ``got status: <status>. <JSON event>``.
515
+
516
+ Like the JS SDK error it carries the status code but no headers, so retries use plain backoff.
517
+ """
518
+ response = error.response
519
+ details = error.details
520
+ if _is_stream_error(error):
521
+ status = error.status
522
+ message = f"got status: {status if status is not None else 'undefined'}. {_compact_json(details)}"
523
+ else:
524
+ body = details
525
+ if isinstance(response, httpx.Response) and "application/json" not in (
526
+ response.headers.get("content-type") or ""
527
+ ):
528
+ body = {"error": {"message": response.text, "code": error.code, "status": response.reason_phrase}}
529
+ message = _compact_json(body)
530
+ converted = ProviderHttpError(message, status=error.code)
531
+ converted.__cause__ = error
532
+ return converted
533
+
534
+
535
+ def _is_stream_error(error: genai_errors.APIError) -> bool:
536
+ """Whether the SDK raised for an error event received in a successful (200) event stream."""
537
+ status_code = getattr(error.response, "status_code", None)
538
+ return isinstance(status_code, int) and status_code < 400
539
+
540
+
541
+ async def retry_google_request(request: Callable[[], Awaitable[T]], options: Mapping[str, Any] | None = None) -> T:
542
+ """Run a Google request with the shared provider retry policy.
543
+
544
+ 408/409/429/5xx are retried with backoff, honoring ``max_retries``/``max_retry_delay_ms``/``signal``,
545
+ like the Anthropic and OpenAI adapters. The SDK's ``APIError`` is converted with
546
+ :func:`google_api_error`. Upstream only retries the SDK's HTTP errors, so transport failures and
547
+ error events inside an accepted stream are not retried.
548
+ """
549
+ options = options or {}
550
+
551
+ async def attempt() -> T:
552
+ try:
553
+ return await request()
554
+ except genai_errors.APIError as error:
555
+ converted = google_api_error(error)
556
+ if _is_stream_error(error):
557
+ raise _UnretriedError(converted) from error
558
+ raise converted from error
559
+ except ProviderHttpError as error:
560
+ if error.status is None:
561
+ raise _UnretriedError(error) from error
562
+ raise
563
+
564
+ try:
565
+ return await retry_provider_request(
566
+ attempt,
567
+ max_retries=options.get("max_retries") or 0,
568
+ max_retry_delay_ms=options.get("max_retry_delay_ms"),
569
+ signal=options.get("signal"),
570
+ )
571
+ except _UnretriedError as wrapped:
572
+ raise wrapped.error from None
573
+
574
+
575
+ def parse_google_usage(model: Model, metadata: genai_types.GenerateContentResponseUsageMetadata) -> Usage:
576
+ """Usage from the SDK's ``usage_metadata``, with cost filled in."""
577
+ prompt = metadata.prompt_token_count or 0
578
+ cached = metadata.cached_content_token_count or 0
579
+ candidates = metadata.candidates_token_count or 0
580
+ thoughts = metadata.thoughts_token_count or 0
581
+ usage = Usage(
582
+ input=prompt - cached,
583
+ output=candidates + thoughts,
584
+ cache_read=cached,
585
+ cache_write=0,
586
+ total_tokens=metadata.total_token_count or 0,
587
+ reasoning=thoughts,
588
+ )
589
+ calculate_cost(model, usage)
590
+ return usage
591
+
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # Request parameters (the SDK's GenerateContentParameters)
595
+ # ---------------------------------------------------------------------------
596
+
597
+
598
+ def build_google_params(model: Model, context: TranscriptContext, options: Mapping[str, Any]) -> dict[str, Any]:
599
+ """Build ``{"model", "contents", "config"}`` exactly as pi passes them to ``generate_content_stream``.
600
+
601
+ This is the payload handed to ``on_payload``. Keys use the API's camelCase names, which the
602
+ SDK's parameter models accept as aliases.
603
+ """
604
+ contents = convert_messages(model, context)
605
+ initial = get_initial_system_message(context.messages)
606
+ current_tools = get_current_tools(context.messages)
607
+
608
+ config: dict[str, Any] = {}
609
+ if options.get("temperature") is not None:
610
+ config["temperature"] = options["temperature"]
611
+ if options.get("max_tokens") is not None:
612
+ config["maxOutputTokens"] = options["max_tokens"]
613
+
614
+ supports_strict_mode = supports_google_strict_tool_sampling(model.id)
615
+ function_calling_mode = (
616
+ resolve_google_function_calling_mode(current_tools, options.get("tool_choice"), supports_strict_mode)
617
+ if current_tools
618
+ else None
619
+ )
620
+ system_instruction = get_system_message_text(initial) if initial is not None else ""
621
+ if system_instruction:
622
+ config["systemInstruction"] = sanitize_surrogates(system_instruction)
623
+ if current_tools:
624
+ config["tools"] = convert_tools(current_tools, False, supports_strict_mode)
625
+ if function_calling_mode is not None:
626
+ config["toolConfig"] = {"functionCallingConfig": {"mode": function_calling_mode}}
627
+
628
+ thinking = options.get("thinking")
629
+ if thinking is not None and thinking.get("enabled") and model.reasoning:
630
+ thinking_config: dict[str, Any] = {"includeThoughts": True}
631
+ if thinking.get("level") is not None:
632
+ thinking_config["thinkingLevel"] = to_google_sdk_thinking_level(thinking["level"])
633
+ elif thinking.get("budget_tokens") is not None:
634
+ thinking_config["thinkingBudget"] = thinking["budget_tokens"]
635
+ config["thinkingConfig"] = thinking_config
636
+ elif model.reasoning and thinking is not None and not thinking.get("enabled"):
637
+ config["thinkingConfig"] = get_disabled_google_thinking_config(model)
638
+
639
+ signal = options.get("signal")
640
+ if signal is not None and signal.aborted:
641
+ raise AbortError("Request aborted")
642
+
643
+ return {"model": model.id, "contents": contents, "config": config}
644
+
645
+
646
+ # ---------------------------------------------------------------------------
647
+ # SDK client
648
+ # ---------------------------------------------------------------------------
649
+
650
+
651
+ def build_google_http_headers(model: Model, options: Mapping[str, Any]) -> dict[str, str] | None:
652
+ """``httpOptions.headers``: pi's User-Agent, then model headers, then request headers.
653
+
654
+ Names are lower-cased so they replace the SDK's own entries (``x-goog-api-key``,
655
+ ``user-agent``) instead of duplicating them. The SDK prefixes ``user-agent`` with its
656
+ ``google-genai-sdk/<version> gl-python/<version>`` label and sets ``x-goog-api-client``.
657
+ """
658
+ record = provider_headers_to_record({"User-Agent": get_pi_user_agent()}, model.headers, options.get("headers"))
659
+ return {name.lower(): value for name, value in record.items()} if record else None
660
+
661
+
662
+ _CLIENT_CACHE_ATTR = "_pi_google_genai_clients"
663
+ _CLIENT_CACHE_SIZE = 16
664
+
665
+
666
+ async def get_google_client(
667
+ http_client: httpx.AsyncClient, key: Hashable, factory: Callable[[], Awaitable[Client]]
668
+ ) -> Client:
669
+ """The SDK client for ``key`` on ``http_client``, built with ``factory`` on first use.
670
+
671
+ Building a ``google.genai.Client`` takes tens of milliseconds (it creates SSL contexts and
672
+ a sync httpx client it never uses here), and a Vertex client holds the ADC credentials and
673
+ their access token, so clients are reused. The cache lives on the httpx client (the SDK
674
+ client refers back to it), so both are collected together; a client that does not take
675
+ attributes gets a fresh SDK client per request.
676
+ """
677
+ clients: OrderedDict[Hashable, Client] | None = getattr(http_client, _CLIENT_CACHE_ATTR, None)
678
+ if clients is None:
679
+ clients = OrderedDict()
680
+ try:
681
+ setattr(http_client, _CLIENT_CACHE_ATTR, clients)
682
+ except (AttributeError, TypeError):
683
+ return await factory()
684
+ client = clients.get(key)
685
+ if client is not None:
686
+ clients.move_to_end(key)
687
+ return client
688
+ client = await factory()
689
+ clients[key] = client
690
+ if len(clients) > _CLIENT_CACHE_SIZE:
691
+ clients.popitem(last=False)
692
+ return client
693
+
694
+
695
+ def _stream_request(params: Mapping[str, Any], headers: dict[str, str] | None, options: Mapping[str, Any]) -> Any:
696
+ """``generate_content_stream`` keyword arguments, with per-request ``http_options`` in the config."""
697
+ http_options: dict[str, Any] = {}
698
+ if headers:
699
+ http_options["headers"] = headers
700
+ timeout_ms = options.get("timeout_ms")
701
+ if timeout_ms is not None:
702
+ http_options["timeout"] = int(timeout_ms)
703
+ config = params.get("config")
704
+ if http_options:
705
+ if isinstance(config, genai_types.GenerateContentConfig):
706
+ config = config.model_copy(update={"http_options": genai_types.HttpOptions(**http_options)})
707
+ else:
708
+ config = dict(config or {})
709
+ existing = config.pop("httpOptions", None) or config.pop("http_options", None)
710
+ config["httpOptions"] = {**existing, **http_options} if isinstance(existing, Mapping) else http_options
711
+ return {"model": params.get("model"), "contents": params.get("contents"), "config": config}
712
+
713
+
714
+ # ---------------------------------------------------------------------------
715
+ # Streaming
716
+ # ---------------------------------------------------------------------------
717
+
718
+ # Counter for generating unique tool call IDs
719
+ _tool_call_counter = itertools.count(1)
720
+
721
+ _ChunkStream: TypeAlias = AsyncIterator[genai_types.GenerateContentResponse]
722
+
723
+
724
+ def new_google_output(model: Model) -> AssistantMessage:
725
+ return AssistantMessage(
726
+ [], api=model.api, provider=model.provider, model=model.id, usage=Usage(), stop_reason="pending"
727
+ )
728
+
729
+
730
+ async def _aclose(stream: _ChunkStream) -> None:
731
+ aclose = getattr(stream, "aclose", None)
732
+ if aclose is not None:
733
+ await aclose()
734
+
735
+
736
+ async def _open_stream(client: Client, request: Any) -> tuple[_ChunkStream, genai_types.GenerateContentResponse | None]:
737
+ """Send the request and wait for the first chunk.
738
+
739
+ The SDK's stream sends its request lazily, on the first iteration; waiting for the first
740
+ chunk makes HTTP errors surface here, inside the retry policy, as they do upstream.
741
+ """
742
+ stream = await client.aio.models.generate_content_stream(**request)
743
+ try:
744
+ first = await anext(stream, None)
745
+ except BaseException:
746
+ await _aclose(stream)
747
+ raise
748
+ return stream, first
749
+
750
+
751
+ async def run_google_stream(
752
+ model: Model,
753
+ context: TranscriptContext,
754
+ options: Mapping[str, Any],
755
+ out: AssistantMessageEventStream,
756
+ output: AssistantMessage,
757
+ *,
758
+ create_client: Callable[[], Awaitable[Client]],
759
+ missing_finish_reason_message: str,
760
+ ) -> None:
761
+ """Producer shared by the Gemini and Vertex adapters (identical upstream apart from the client)."""
762
+ signal = options.get("signal")
763
+ try:
764
+ client = await create_client()
765
+ normalized = collapse_system_messages(context)
766
+ params: Any = build_google_params(model, normalized, options)
767
+ on_payload = options.get("on_payload")
768
+ if on_payload is not None:
769
+ replaced = await maybe_await(on_payload(params, model))
770
+ if replaced is not None:
771
+ params = replaced
772
+ request = _stream_request(params, build_google_http_headers(model, options), options)
773
+ async with abort_scope(signal):
774
+ stream, first = await retry_google_request(lambda: _open_stream(client, request), options)
775
+ try:
776
+ on_response = options.get("on_response")
777
+ if on_response is not None:
778
+ # The SDK exposes the response headers on each chunk; a stream that started is a 200.
779
+ http_response = first.sdk_http_response if first is not None else None
780
+ headers = dict(http_response.headers or {}) if http_response is not None else {}
781
+ await maybe_await(on_response(ProviderResponse(200, headers), model))
782
+ out.push(StartEvent(output))
783
+ if first is not None:
784
+ await _consume_stream(model, options, out, output, first, stream)
785
+ except genai_errors.APIError as error:
786
+ raise google_api_error(error) from error
787
+ finally:
788
+ await _aclose(stream)
789
+
790
+ if signal is not None and signal.aborted:
791
+ raise AbortError("Request was aborted")
792
+ if output.stop_reason == "pending":
793
+ raise RuntimeError(missing_finish_reason_message)
794
+ if output.stop_reason in ("aborted", "error"):
795
+ raise RuntimeError(
796
+ f"Provider stopped with: {output.raw_stop_reason}"
797
+ if output.raw_stop_reason
798
+ else "An unknown error occurred"
799
+ )
800
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
801
+ out.end()
802
+ except Exception as error:
803
+ output.stop_reason = "aborted" if signal is not None and signal.aborted else "error"
804
+ output.error_message = format_provider_error(normalize_provider_error(error))
805
+ out.push(ErrorEvent(output.stop_reason, output)) # type: ignore[arg-type]
806
+ out.end()
807
+
808
+
809
+ def _end_block(
810
+ out: AssistantMessageEventStream, output: AssistantMessage, block: TextContent | ThinkingContent
811
+ ) -> None:
812
+ index = len(output.content) - 1
813
+ if isinstance(block, TextContent):
814
+ out.push(TextEndEvent(index, block.text, output))
815
+ else:
816
+ out.push(ThinkingEndEvent(index, block.thinking, output))
817
+
818
+
819
+ def _signature_text(signature: bytes | str | None) -> str | None:
820
+ """A thought signature as the base64 text pi stores (the SDK decodes it to bytes)."""
821
+ if not signature:
822
+ return None
823
+ if isinstance(signature, bytes):
824
+ return base64.b64encode(signature).decode("ascii")
825
+ return signature
826
+
827
+
828
+ async def _consume_stream(
829
+ model: Model,
830
+ options: Mapping[str, Any],
831
+ out: AssistantMessageEventStream,
832
+ output: AssistantMessage,
833
+ first: genai_types.GenerateContentResponse,
834
+ stream: _ChunkStream,
835
+ ) -> None:
836
+ on_event = options.get("on_provider_stream_event")
837
+ blocks = output.content
838
+ current: TextContent | ThinkingContent | None = None
839
+
840
+ chunk: genai_types.GenerateContentResponse | None = first
841
+ while chunk is not None:
842
+ if on_event is not None:
843
+ await maybe_await(on_event(chunk, model))
844
+ # GenerateContentResponse.response_id is an output-only field identifying each response.
845
+ # Keep the first non-empty one from the stream.
846
+ if not output.response_id:
847
+ output.response_id = chunk.response_id or None
848
+ candidates = chunk.candidates
849
+ candidate = candidates[0] if candidates else None
850
+ if candidate is not None:
851
+ content = candidate.content
852
+ parts = content.parts if content is not None else None
853
+ for part in parts or ():
854
+ text = part.text
855
+ if text is not None:
856
+ is_thinking = part.thought is True
857
+ if current is None or is_thinking != isinstance(current, ThinkingContent):
858
+ if current is not None:
859
+ _end_block(out, output, current)
860
+ if is_thinking:
861
+ current = ThinkingContent("")
862
+ blocks.append(current)
863
+ out.push(ThinkingStartEvent(len(blocks) - 1, output))
864
+ else:
865
+ current = TextContent("")
866
+ blocks.append(current)
867
+ out.push(TextStartEvent(len(blocks) - 1, output))
868
+ signature = _signature_text(part.thought_signature)
869
+ if isinstance(current, ThinkingContent):
870
+ append_thinking(current, text)
871
+ current.thinking_signature = retain_thought_signature(current.thinking_signature, signature)
872
+ out.push(ThinkingDeltaEvent(len(blocks) - 1, text, output))
873
+ else:
874
+ append_text(current, text)
875
+ current.text_signature = retain_thought_signature(current.text_signature, signature)
876
+ out.push(TextDeltaEvent(len(blocks) - 1, text, output))
877
+
878
+ function_call = part.function_call
879
+ if function_call is not None:
880
+ if current is not None:
881
+ _end_block(out, output, current)
882
+ current = None
883
+ # Generate unique ID if not provided or if it's a duplicate
884
+ provided_id = function_call.id
885
+ needs_new_id = not provided_id or any(
886
+ isinstance(block, ToolCall) and block.id == provided_id for block in blocks
887
+ )
888
+ name = function_call.name
889
+ tool_call_id = (
890
+ f"{name if name is not None else 'undefined'}_{now_ms()}_{next(_tool_call_counter)}"
891
+ if needs_new_id
892
+ else provided_id
893
+ )
894
+ args = function_call.args
895
+ tool_call = ToolCall(
896
+ tool_call_id, # type: ignore[arg-type]
897
+ name or "",
898
+ args if args is not None else {},
899
+ thought_signature=_signature_text(part.thought_signature),
900
+ )
901
+ blocks.append(tool_call)
902
+ index = len(blocks) - 1
903
+ out.push(ToolCallStartEvent(index, output))
904
+ out.push(ToolCallDeltaEvent(index, _compact_json(tool_call.arguments), output))
905
+ out.push(ToolCallEndEvent(index, tool_call, output))
906
+
907
+ finish_reason = candidate.finish_reason
908
+ if finish_reason:
909
+ raw = finish_reason.value if isinstance(finish_reason, Enum) else str(finish_reason)
910
+ output.raw_stop_reason = raw
911
+ output.stop_reason = map_stop_reason(raw)
912
+ if output.stop_reason == "stop" and any(isinstance(block, ToolCall) for block in blocks):
913
+ output.stop_reason = "toolUse"
914
+
915
+ usage_metadata = chunk.usage_metadata
916
+ if usage_metadata is not None:
917
+ output.usage = parse_google_usage(model, usage_metadata)
918
+
919
+ chunk = await anext(stream, None)
920
+
921
+ if current is not None:
922
+ _end_block(out, output, current)
923
+
924
+
925
+ def _compact_json(value: Any) -> str:
926
+ """``JSON.stringify`` output."""
927
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))