from __future__ import annotations
import base64
import json
import ssl
from collections.abc import AsyncIterator, Mapping
from typing import TYPE_CHECKING, Any, cast
from .._compat import aclose_stream
from ..messages import (
AgentMessage,
AssistantMessage,
ImageBytesSource,
ImageContent,
ImageUrlSource,
SystemMessage,
TextContent,
ToolCall,
ToolMessage,
UserMessage,
)
from ..model import (
DEFAULT_RETRY_OPTIONS,
DEFAULT_TIMEOUT_OPTIONS,
ModelEvent,
ModelRequest,
ModelResponse,
ModelUsage,
RetryOptions,
ServerToolCall,
ServerToolResult,
TimeoutOptions,
ToolCallDelta,
validate_response,
validate_retry,
validate_timeout,
)
from ..stop import StopInfo, StopReason, classify_stop
from .base import (
ProviderAuthError,
ProviderError,
ProviderResponseError,
RetryingProvider,
_reasoning_effort_to_budget,
guard_idle,
http_error,
reject_local_tool_search,
retry_model_stream,
validate_extra_body,
validate_extra_headers,
)
from .http_errors import HttpFailure, failure_message, read_failure
from .stop_reasons import withhold_replay_blocks
from .tool_images import expand_tool_images
if TYPE_CHECKING:
import httpx
[文档]
class GoogleProvider(RetryingProvider):
"""Map official Google GenAI SDK streams and preserve signed replay parts.
Args:
model: Google model identifier used for content generation.
api_key: API credential supplied by the application.
transport: Optional HTTPX transport for test isolation or custom routing.
response: Must remain false; this adapter uses Google GenerateContent.
retry: Retry/backoff policy, applied before any model event is emitted.
timeout: How long the provider may stay silent before the request is given up on.
See :class:`~zett_agent.model.TimeoutOptions`. The Google client sends its request
when the first chunk is asked for, so ``first_byte`` covers connecting, the response
headers and the first chunk. A timeout raises
:class:`~zett_agent.providers.base.ProviderTimeoutError` and is not retried.
base_url: API root; None uses Google's public Generative Language endpoint.
Set it to reach a proxy, a gateway, or a local test server.
extra_headers: HTTP headers sent on every request, for gateways that
route or bill on a header.
extra_body: JSON fields merged into every request body, for options the
adapter does not model.
Note:
ModelResponse retains model-scoped replay parts for subsequent calls.
Await aclose() to release the SDK and injected HTTP client.
Examples:
Configure the SDK adapter without exposing the key in source::
model = GoogleProvider(
model=os.environ["GOOGLE_MODEL"],
api_key=os.environ["GOOGLE_API_KEY"],
)
try:
client = await create_agent(model)
reply = await client.run("Describe the attached image")
finally:
await model.aclose()
.. note::
Provider-specific signed parts are retained in ``replay_blocks`` and
reused only with the same Google model.
.. seealso::
:class:`~zett_agent.messages.AssistantMessage` describes model-scoped replay data,
and :doc:`/learn/providers` shows the common provider workflow.
"""
def __init__(
self,
model: str,
api_key: str,
transport: httpx.AsyncBaseTransport | None = None,
*,
response: bool = False,
retry: RetryOptions = DEFAULT_RETRY_OPTIONS,
base_url: str | None = None,
extra_headers: Mapping[str, str] | None = None,
extra_body: Mapping[str, Any] | None = None,
timeout: TimeoutOptions = DEFAULT_TIMEOUT_OPTIONS,
) -> None:
import httpx
import truststore
from google import genai
from google.genai import types
validate_response(response)
if response:
raise ValueError("GoogleProvider does not support the Responses API")
if not api_key:
raise ValueError("api_key is required")
validate_retry(retry)
validate_timeout(timeout)
self.retry = retry
self.timeout = timeout
self.response = response
self.model = model
self.extra_headers = validate_extra_headers(extra_headers)
self.extra_body = validate_extra_body(extra_body)
self._http_client = httpx.AsyncClient(
transport=transport,
verify=truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT),
trust_env=True,
)
self._client = genai.Client(
api_key=api_key,
http_options=types.HttpOptions(
base_url=base_url,
headers=self.extra_headers or None,
extra_body=self.extra_body or None,
httpx_async_client=self._http_client,
retry_options=types.HttpRetryOptions(attempts=1),
),
)
[文档]
async def aclose(self) -> None:
"""Release both SDK clients and the injected HTTP connection pool."""
await self._client.aio.aclose()
self._client.close()
await self._http_client.aclose()
@retry_model_stream
async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
from google.genai import types
from google.genai.errors import APIError, UnknownApiResponseError
system: list[str] = []
contents: list[types.Content] = []
previous_role = ""
for message in expand_tool_images(request.messages):
parts: list[types.Part] = []
match message:
case AgentMessage():
parts = [types.Part.from_text(text=message.content)]
case SystemMessage():
system.append(message.content)
continue
case UserMessage():
for block in message.parts:
match block:
case TextContent(text=text):
parts.append(types.Part.from_text(text=text))
case ImageContent(source=ImageBytesSource(data=data, media_type=media_type)):
parts.append(types.Part.from_bytes(data=data, mime_type=media_type))
case ImageContent(source=ImageUrlSource(url=url)):
if url.startswith("data:"):
header, _, encoded = url.partition(",")
parts.append(
types.Part.from_bytes(
data=base64.b64decode(encoded, validate=True),
mime_type=header[5:].split(";")[0],
)
)
else:
parts.append(types.Part.from_uri(file_uri=url))
case AssistantMessage():
if message.provider == "google" and message.model == self.model and message.replay_blocks:
parts = [types.Part.model_validate(dict(block)) for block in message.replay_blocks]
else:
parts = [types.Part.from_text(text=message.content)] if message.content else []
parts.extend(
types.Part(
function_call=types.FunctionCall(
name=call.name,
args=dict(call.arguments),
id=call.id,
)
)
for call in message.tool_calls
)
case ToolMessage():
# Gemini documents ``error`` as the key that marks a failed call.
parts = [
types.Part.from_function_response(
name=message.name,
response={"content": message.content} if message.success else {"error": message.content},
)
]
# The service matches the responses of parallel calls as one
# turn: its count must equal the calls in the model turn, so
# adjacent results join instead of becoming separate turns.
if contents and previous_role == "tool" and (joined := contents[-1].parts) is not None:
joined.extend(parts)
continue
if parts:
contents.append(types.Content(role="model" if message.role == "assistant" else "user", parts=parts))
previous_role = message.role
config = types.GenerateContentConfig(
system_instruction="\n\n".join(system) or None,
tools=self._google_tools(types, request),
thinking_config=types.ThinkingConfig(
thinking_budget=_reasoning_effort_to_budget(request.reasoning_effort),
include_thoughts=True,
),
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
tool_config=types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
mode=types.FunctionCallingConfigMode.ANY,
allowed_function_names=[request.tool_choice],
)
)
if request.tool_choice
else None,
)
text, reasoning = "", ""
calls: list[ToolCall] = []
replay: list[dict[str, Any]] = []
usage = ModelUsage()
finish_reason = None
block_reason: str | None = None
block_message: str | None = None
server_tool_sequence = 0
seen_server_tool_metadata: set[str] = set()
try:
stream = await self._client.aio.models.generate_content_stream(
model=self.model, contents=cast("types.ContentListUnion", contents), config=config
)
except APIError as error:
raise _provider_error(error) from error
try:
# The SDK sends the HTTP request when the first chunk is requested,
# so a rejected request surfaces here rather than above.
# The SDK only sends the request when the first chunk is asked for, so the first
# wait covers connecting, the response headers and the first chunk.
async for chunk in guard_idle(stream, self.timeout, first=self.timeout.first_byte):
feedback = getattr(chunk, "prompt_feedback", None)
if feedback is not None and getattr(feedback, "block_reason", None):
# The prompt was blocked before the model wrote anything. There is no
# candidate, so this is the only place the reason is reported.
block_reason = str(getattr(feedback.block_reason, "value", feedback.block_reason))
block_message = getattr(feedback, "block_reason_message", None) or None
for candidate in (chunk.candidates or [])[:1]:
server_events, server_tool_sequence = _google_server_tool_events(
candidate,
sequence=server_tool_sequence,
seen=seen_server_tool_metadata,
)
for server_event in server_events:
yield server_event
for part in candidate.content.parts if candidate.content and candidate.content.parts else []:
replay.append(part.model_dump(exclude_none=True))
if part.text:
if part.thought:
reasoning += part.text
yield ModelEvent.reasoning(part.text)
else:
text += part.text
yield ModelEvent.text(part.text)
if part.function_call:
call = part.function_call
normalized = ToolCall(call.id or f"call_{len(calls)}", call.name, call.args or {})
calls.append(normalized)
yield ModelEvent.tool_call(
ToolCallDelta(
index=len(calls) - 1,
id_delta=normalized.id,
name_delta=normalized.name,
arguments_delta=json.dumps(dict(normalized.arguments)),
)
)
if candidate.finish_reason:
finish_reason = str(candidate.finish_reason.value)
if chunk.usage_metadata:
info = chunk.usage_metadata
usage = ModelUsage(
input_tokens=info.prompt_token_count or 0,
output_tokens=(info.candidates_token_count or 0) + (info.thoughts_token_count or 0),
reasoning_tokens=info.thoughts_token_count or 0,
cache_read_tokens=info.cached_content_token_count or 0,
# Implicit caching reports the field; a response without
# it says nothing about cache usage.
cache_reported=info.cached_content_token_count is not None,
)
except APIError as error:
raise _provider_error(error) from error
except UnknownApiResponseError as error:
# The SDK could not parse a 200 as JSON: a proxy's page, or a gateway that is not Gemini.
raise _unparsable_response(error) from error
finally:
await aclose_stream(stream)
if finish_reason is None and block_reason is not None:
# Answered as a refusal, not raised: the conversation goes on and the host is
# told which safety system blocked the prompt.
yield ModelEvent.completed(
ModelResponse(
AssistantMessage(content="", provider="google", model=self.model),
finish_reason=block_reason,
usage=usage,
stop=StopInfo(
StopReason.REFUSAL,
raw=block_reason,
category=block_reason,
message=block_message,
detail={"blocked": "prompt"},
),
)
)
return
if finish_reason is None:
raise ProviderResponseError("Google stream ended without a finish reason")
stop = classify_stop(finish_reason, has_tool_calls=bool(calls))
if stop.reason in (StopReason.REFUSAL, StopReason.INTERRUPTED):
# The calls are withheld from the message, so the parts that carry them go too.
replay = [dict(part) for part in withhold_replay_blocks(replay, lambda part: "function_call" in part)]
yield ModelEvent.completed(
ModelResponse(
AssistantMessage(
content=text,
reasoning=reasoning or None,
tool_calls=tuple(calls),
provider="google",
model=self.model,
replay_blocks=tuple(replay),
),
finish_reason=finish_reason,
usage=usage,
stop=stop,
)
)
@staticmethod
def _google_tools(types: Any, request: ModelRequest) -> list[Any] | None:
"""Build local function declarations and native Google server tools."""
reject_local_tool_search("Google generative AI", request.tools)
tools: list[Any] = []
if request.tools:
tools.append(
types.Tool(
function_declarations=[
types.FunctionDeclaration(
name=tool.name,
description=tool.description,
parameters_json_schema=dict(tool.parameters),
)
for tool in request.tools
]
)
)
for tool in request.server_tools:
try:
tools.append(types.Tool.model_validate({tool.type: dict(tool.configuration)}))
except ValueError as error:
raise ProviderResponseError(f"Unsupported Google server tool type: {tool.type}") from error
return tools or None
def _provider_error(error: Any) -> ProviderError:
"""Map an SDK failure to the provider error hierarchy.
The original error stays reachable through ``__cause__``, which is where the retry
policy reads the HTTP status, so a 429 or 5xx is still retried. A body that is a web
page (a proxy in front of the API) is described as one instead of being pasted in.
"""
status = int(getattr(error, "code", 0) or 0)
text = str(error.message) if getattr(error, "message", None) else str(error)
failure = read_failure(content_type=None, text=text)
message = (
failure_message("google", status, failure) if failure.is_html else f"google returned HTTP {status}: {text}"
)
if status in (401, 403):
return ProviderAuthError(f"Provider rejected API credential. {message}")
return http_error(status, failure if failure.is_html else HttpFailure(text, False, None, text), message)
def _unparsable_response(error: BaseException) -> ProviderResponseError:
"""Describe a 200 response the SDK could not read as JSON, naming a web page when it was one."""
text = str(error)
_, _, raw = text.partition("Raw response:")
failure = read_failure(content_type=None, text=(raw or text).strip())
if failure.is_html:
return http_error(200, failure, failure_message("google", 200, failure))
return ProviderResponseError(
f"google answered HTTP 200 with something that is not a Gemini response: {failure.preview}"
)
def _google_server_tool_events(candidate: Any, *, sequence: int, seen: set[str]) -> tuple[list[ModelEvent], int]:
"""Normalize completed Google Search and URL Context metadata.
Generate Content exposes these hosted operations only when result metadata
reaches the candidate, rather than as a live call lifecycle. The adapter
therefore emits adjacent start/completion events at that boundary and
deduplicates metadata repeated by later streaming chunks.
"""
events: list[ModelEvent] = []
grounding = getattr(candidate, "grounding_metadata", None)
if grounding is not None:
payload = grounding.model_dump(exclude_none=True)
# Grounding metadata may be repeated or enriched by later stream chunks;
# it still represents one hosted search operation for this model step.
fingerprint = "google_search"
if fingerprint not in seen:
seen.add(fingerprint)
call = ServerToolCall(
id=f"google-search-{sequence}",
name="google_search",
input={"queries": list(payload.get("web_search_queries", ()))},
)
sequence += 1
events.extend(
(
ModelEvent.server_tool_started(call),
ModelEvent.server_tool_completed(ServerToolResult(call.id, call.name, payload)),
)
)
url_context = getattr(candidate, "url_context_metadata", None)
if url_context is not None:
metadata = url_context.model_dump(exclude_none=True)
for item in metadata.get("url_metadata", ()):
url = str(item.get("retrieved_url", ""))
fingerprint = f"url_context:{url or json.dumps(item, sort_keys=True, default=str)}"
if fingerprint in seen:
continue
seen.add(fingerprint)
raw_status = item.get("url_retrieval_status", "URL_RETRIEVAL_STATUS_UNSPECIFIED")
status = str(getattr(raw_status, "value", raw_status))
call = ServerToolCall(id=f"url-context-{sequence}", name="url_context", input={"url": url})
sequence += 1
result = ServerToolResult(
call.id,
call.name,
item,
None if status == "URL_RETRIEVAL_STATUS_SUCCESS" else status,
)
events.extend(
(
ModelEvent.server_tool_started(call),
ModelEvent.server_tool_completed(result)
if result.error_code is None
else ModelEvent.server_tool_failed(result),
)
)
return events, sequence