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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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=(",", ":"))
|