diff --git a/examples/run-examples.py b/examples/run-examples.py index cbdda8e7..add213e8 100755 --- a/examples/run-examples.py +++ b/examples/run-examples.py @@ -34,6 +34,7 @@ class Sample: TEXT_SAMPLES = [ Sample("stream.py"), + Sample("gemini.py"), Sample("stream_all.py"), Sample("tools_schema.py"), Sample("agent_simple.py"), diff --git a/examples/samples/check_connection.py b/examples/samples/check_connection.py index 18befb95..af13e166 100644 --- a/examples/samples/check_connection.py +++ b/examples/samples/check_connection.py @@ -8,6 +8,7 @@ PROVIDERS: list[tuple[str, ai.Provider, str]] = [ ("ai_gateway", ai.ai_gateway, "anthropic/claude-sonnet-4"), ("anthropic", ai.anthropic, "claude-sonnet-4-20250514"), + ("google", ai.google, "gemini-2.5-flash"), ("openai", ai.openai, "gpt-5.4-mini"), ] diff --git a/examples/samples/gemini.py b/examples/samples/gemini.py new file mode 100644 index 00000000..99508ddd --- /dev/null +++ b/examples/samples/gemini.py @@ -0,0 +1,29 @@ +"""Gemini direct - stream from Google's Gemini API.""" + +import asyncio +import sys + +import ai + +if ai.google.client().api_key is None: + print("[SKIP] GOOGLE_API_KEY or GEMINI_API_KEY not set") + sys.exit(0) + +model = ai.google("gemini-3.1-flash-lite") + +messages = [ + ai.system_message("Be concise."), + ai.user_message("Explain why the sky is blue in two sentences."), +] + + +async def main() -> None: + async with ai.stream(model, messages) as s: + async for event in s: + if isinstance(event, ai.events.TextDelta): + print(event.chunk, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/samples/stream_all.py b/examples/samples/stream_all.py index 4e52223a..98dda026 100644 --- a/examples/samples/stream_all.py +++ b/examples/samples/stream_all.py @@ -7,6 +7,7 @@ MODELS: list[tuple[str, ai.Provider, str]] = [ ("ai_gateway", ai.ai_gateway, "anthropic/claude-sonnet-4.6"), ("anthropic", ai.anthropic, "claude-sonnet-4-6"), + ("google", ai.google, "gemini-2.5-flash"), ("openai", ai.openai, "gpt-5.5"), ] diff --git a/pyproject.toml b/pyproject.toml index b5577ed6..26459454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ authors = [ requires-python = ">=3.12" dependencies = [ "anthropic>=0.83.0", + "google-genai>=2.0.1", "httpx>=0.28.1", "mcp>=1.18.0", "openai>=2.14.0", diff --git a/src/ai/__init__.py b/src/ai/__init__.py index 5bba9825..41985c0c 100644 --- a/src/ai/__init__.py +++ b/src/ai/__init__.py @@ -44,6 +44,7 @@ anthropic, check_connection, generate, + google, openai, stream, ) @@ -87,6 +88,7 @@ # Provider factories "openai", "anthropic", + "google", "ai_gateway", # Agents — primary API "Agent", diff --git a/src/ai/models/__init__.py b/src/ai/models/__init__.py index 5db3053d..468392ac 100644 --- a/src/ai/models/__init__.py +++ b/src/ai/models/__init__.py @@ -3,10 +3,11 @@ Usage:: import ai - from ai.models import openai, anthropic, ai_gateway + from ai.models import openai, anthropic, google, ai_gateway model = openai("gpt-5.4") model = anthropic("claude-sonnet-4-6") + model = google("gemini-2.5-flash") model = ai_gateway("anthropic/claude-sonnet-4") # stream — auto-creates client from env vars @@ -44,6 +45,7 @@ from .core.model import Model from .core.params import GenerateParams, ImageParams, VideoParams from .core.proto import CheckConnFn, GenerateFn, Provider, StreamFn +from .google import google from .openai import openai __all__ = [ @@ -66,6 +68,7 @@ # Provider factories "ai_gateway", "anthropic", + "google", "openai", # Adapter registration "register_generate", diff --git a/src/ai/models/core/adapters.py b/src/ai/models/core/adapters.py index 35669c7f..90198d95 100644 --- a/src/ai/models/core/adapters.py +++ b/src/ai/models/core/adapters.py @@ -32,12 +32,14 @@ def _ensure_adapters() -> None: from ..ai_gateway.adapter import generate as ai_gw_generate from ..ai_gateway.adapter import stream as ai_gw_stream from ..anthropic.adapter import stream as anthropic_stream + from ..google.adapter import stream as google_stream from ..openai.adapter import stream as openai_stream _stream_adapters["ai-gateway-v3"] = ai_gw_stream _generate_adapters["ai-gateway-v3"] = ai_gw_generate _stream_adapters["openai"] = openai_stream _stream_adapters["anthropic"] = anthropic_stream + _stream_adapters["google"] = google_stream def register_stream(adapter: str, fn: proto.StreamFn) -> None: diff --git a/src/ai/models/google/__init__.py b/src/ai/models/google/__init__.py new file mode 100644 index 00000000..bcf9ab04 --- /dev/null +++ b/src/ai/models/google/__init__.py @@ -0,0 +1,32 @@ +"""Google Gemini provider. + +Usage:: + + from ai.models import google + + model = google("gemini-2.5-flash") + ids = await google.list() + + # built-in tools + async with ai.stream( + model, msgs, + tools=[google.tools.google_search()], + ) as s: + ... + +The adapter module is loaded lazily to avoid pulling in the ``google-genai`` +SDK at import time. +""" + +from . import tools +from .provider import google + +__all__ = ["google", "tools"] + + +def __getattr__(name: str) -> object: + if name == "stream": + from .adapter import stream + + return stream + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ai/models/google/adapter.py b/src/ai/models/google/adapter.py new file mode 100644 index 00000000..30cfa0c0 --- /dev/null +++ b/src/ai/models/google/adapter.py @@ -0,0 +1,759 @@ +"""Google adapter - Gemini Developer API. + +Message/tool conversion and streaming via the first-party ``google-genai`` SDK. +The SDK client is constructed from :class:`Client` params on each call. +""" + +from __future__ import annotations + +import base64 +import json +from collections.abc import AsyncGenerator, Mapping, Sequence +from typing import Any + +import pydantic + +from ... import types +from ...types import events +from .. import core +from . import tools as google_tools + +PROVIDER_NAME = "google" +_DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + + +def _provider_metadata(**values: Any) -> dict[str, Any]: + return {"provider": PROVIDER_NAME, **values} + + +def _get_field(value: Any, *names: str, default: Any = None) -> Any: + if value is None: + return default + for name in names: + if isinstance(value, Mapping) and name in value: + return value[name] + if hasattr(value, name): + return getattr(value, name) + return default + + +def _dump_jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, bytes): + return base64.b64encode(value).decode("ascii") + if isinstance(value, pydantic.BaseModel): + return value.model_dump(mode="json", exclude_none=True) + if isinstance(value, Mapping): + return {str(k): _dump_jsonable(v) for k, v in value.items() if v is not None} + if isinstance(value, list | tuple): + return [_dump_jsonable(v) for v in value] + enum_value = getattr(value, "value", None) + if enum_value is not None: + return enum_value + if hasattr(value, "__dict__"): + return { + k: _dump_jsonable(v) + for k, v in vars(value).items() + if not k.startswith("_") and v is not None + } + return value + + +def _wire_value(value: Any) -> Any: + enum_value = getattr(value, "value", None) + return enum_value if enum_value is not None else value + + +def _json_dumps(value: Any) -> str: + return json.dumps(_dump_jsonable(value), separators=(",", ":")) + + +def _parse_json_object(value: str) -> dict[str, Any]: + if not value: + return {} + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + return {"value": parsed} + + +def _thought_signature_metadata(part: Any) -> dict[str, Any] | None: + signature = _get_field(part, "thought_signature", "thoughtSignature") + if signature is None: + return None + if isinstance(signature, bytes): + return _provider_metadata( + thoughtSignature=base64.b64encode(signature).decode("ascii"), + thoughtSignatureEncoding="base64", + ) + return _provider_metadata(thoughtSignature=str(signature)) + + +def _thought_signature_from_metadata( + provider_metadata: dict[str, Any] | None, +) -> str | bytes | None: + if not provider_metadata: + return None + signature = provider_metadata.get("thoughtSignature") or provider_metadata.get( + "thought_signature" + ) + if not isinstance(signature, str): + return signature + if provider_metadata.get("thoughtSignatureEncoding") == "base64": + return base64.b64decode(signature) + return signature + + +def _file_part_to_google(part: types.messages.FilePart) -> dict[str, Any]: + media_type = "image/jpeg" if part.media_type == "image/*" else part.media_type + data = part.data + if isinstance(data, str) and data.startswith(("http://", "https://", "gs://")): + file_data: dict[str, Any] = { + "mime_type": media_type, + "file_uri": data, + } + if part.filename: + file_data["display_name"] = part.filename + return {"file_data": file_data} + inline_data: dict[str, Any] = { + "mime_type": media_type, + "data": types.media.data_to_base64(data), + } + if part.filename: + inline_data["display_name"] = part.filename + return {"inline_data": inline_data} + + +def _tool_result_payload(part: types.messages.ToolResultPart) -> dict[str, Any]: + if part.is_error: + return {"error": str(part.result) if part.result is not None else ""} + return {"output": part.result} + + +def _server_tool_metadata( + part: types.messages.BuiltinToolCallPart | types.messages.BuiltinToolReturnPart, +) -> tuple[str | None, str | None]: + provider_metadata = part.provider_metadata or {} + server_tool_id = provider_metadata.get("serverToolCallId") or provider_metadata.get( + "server_tool_call_id" + ) + server_tool_type = provider_metadata.get("serverToolType") or provider_metadata.get( + "server_tool_type" + ) + if server_tool_type is None and part.tool_name.startswith("server:"): + server_tool_type = part.tool_name.removeprefix("server:") + return ( + str(server_tool_id) if server_tool_id is not None else None, + str(server_tool_type) if server_tool_type is not None else None, + ) + + +async def _messages_to_google( + messages: list[types.messages.Message], +) -> tuple[str | None, list[dict[str, Any]]]: + """Convert internal messages to google-genai content dictionaries.""" + system_parts: list[str] = [] + contents: list[dict[str, Any]] = [] + + for msg in messages: + match msg.role: + case "system": + text = "".join( + p.text for p in msg.parts if isinstance(p, types.messages.TextPart) + ) + if text: + system_parts.append(text) + + case "user": + parts: list[dict[str, Any]] = [] + for part in msg.parts: + match part: + case types.messages.TextPart(text=text): + parts.append({"text": text}) + case types.messages.FilePart(): + parts.append(_file_part_to_google(part)) + if parts: + contents.append({"role": "user", "parts": parts}) + + case "assistant": + parts = [] + for part in msg.parts: + match part: + case types.messages.TextPart(text=text): + if text: + wire: dict[str, Any] = {"text": text} + signature = _thought_signature_from_metadata( + part.provider_metadata + ) + if signature is not None: + wire["thought_signature"] = signature + parts.append(wire) + case types.messages.ReasoningPart(text=text): + if text: + wire = {"text": text, "thought": True} + signature = _thought_signature_from_metadata( + part.provider_metadata + ) + if signature is not None: + wire["thought_signature"] = signature + parts.append(wire) + case types.messages.FilePart(): + parts.append(_file_part_to_google(part)) + case types.messages.ToolCallPart(): + parts.append( + { + "function_call": { + "id": part.tool_call_id, + "name": part.tool_name, + "args": _parse_json_object(part.tool_args), + } + } + ) + case types.messages.BuiltinToolCallPart(): + tool_args = _parse_json_object(part.tool_args) + if part.tool_name == "code_execution": + parts.append({"executable_code": tool_args}) + else: + server_tool_id, server_tool_type = ( + _server_tool_metadata(part) + ) + if server_tool_type is None: + raise NotImplementedError( + "Google BuiltinToolCallPart requires " + "server tool metadata unless it is " + "code_execution" + ) + parts.append( + { + "tool_call": { + "id": server_tool_id or part.tool_call_id, + "tool_type": server_tool_type, + "args": tool_args, + } + } + ) + case types.messages.BuiltinToolReturnPart(): + if part.tool_name == "code_execution": + parts.append( + { + "code_execution_result": part.result + if isinstance(part.result, dict) + else {"output": part.result} + } + ) + else: + server_tool_id, server_tool_type = ( + _server_tool_metadata(part) + ) + if server_tool_type is None: + raise NotImplementedError( + "Google BuiltinToolReturnPart requires " + "server tool metadata unless it is " + "code_execution" + ) + parts.append( + { + "tool_response": { + "id": server_tool_id or part.tool_call_id, + "tool_type": server_tool_type, + "response": part.result + if isinstance(part.result, dict) + else {"output": part.result}, + } + } + ) + if parts: + contents.append({"role": "model", "parts": parts}) + + case "tool": + parts = [] + for part in msg.parts: + if isinstance(part, types.messages.ToolResultPart): + parts.append( + { + "function_response": { + "id": part.tool_call_id, + "name": part.tool_name, + "response": _tool_result_payload(part), + } + } + ) + if parts: + contents.append({"role": "user", "parts": parts}) + + system_instruction = "\n\n".join(system_parts) if system_parts else None + return system_instruction, contents + + +def _split_tools( + tools: Sequence[types.tools.Tool], +) -> tuple[list[types.tools.Tool], list[types.tools.Tool]]: + custom: list[types.tools.Tool] = [] + builtin: list[types.tools.Tool] = [] + for tool in tools: + if tool.kind == "provider": + builtin.append(tool) + else: + custom.append(tool) + return custom, builtin + + +def _custom_tools_to_google( + tools: Sequence[types.tools.Tool], +) -> dict[str, Any] | None: + function_declarations: list[dict[str, Any]] = [] + for tool in tools: + args = tool.args + if not isinstance(args, types.tools.FunctionToolArgs): + raise TypeError(f"function tool {tool.name!r} has invalid args") + function_declarations.append( + { + "name": tool.name, + "description": args.description or "", + "parameters_json_schema": args.params, + } + ) + if not function_declarations: + return None + return {"function_declarations": function_declarations} + + +def _builtin_tools_to_google( + tools: Sequence[types.tools.Tool], +) -> list[dict[str, Any]]: + wire: list[dict[str, Any]] = [] + for tool in tools: + args_model = tool.args + if not isinstance(args_model, google_tools.GoogleProviderArgs): + raise ValueError( + "GoogleModel does not support provider args " + f"{type(args_model).__name__}" + ) + args = args_model.model_dump(mode="json", exclude_none=True) + wire.append({args_model.google_tool_name: args}) + return wire + + +def _prepare_tools( + tools: Sequence[types.tools.Tool] | None, +) -> tuple[list[dict[str, Any]] | None, bool]: + if not tools: + return None, False + + custom_tools, builtin_tools = _split_tools(tools) + wire: list[dict[str, Any]] = [] + + custom_wire = _custom_tools_to_google(custom_tools) + if custom_wire is not None: + wire.append(custom_wire) + + if builtin_tools: + wire.extend(_builtin_tools_to_google(builtin_tools)) + + return wire or None, bool(builtin_tools) + + +def _coerce_params(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, Mapping): + return dict(value) + raise TypeError("google stream params must be a dict") + + +def _make_client(client: core.client.Client) -> Any: + """Construct a ``google-genai`` async client from our generic ``Client``.""" + from google import genai + + base_url = client.base_url.rstrip("/") + if base_url == _DEFAULT_BASE_URL: + return genai.Client(api_key=client.api_key or "").aio + return genai.Client( + api_key=client.api_key or "", + http_options={"base_url": client.base_url}, + ).aio + + +def _convert_usage(usage_metadata: Any) -> types.usage.Usage | None: + if usage_metadata is None: + return None + prompt_tokens = _get_field( + usage_metadata, "prompt_token_count", "promptTokenCount", default=0 + ) + candidates_tokens = _get_field( + usage_metadata, "candidates_token_count", "candidatesTokenCount", default=0 + ) + thoughts_tokens = _get_field( + usage_metadata, "thoughts_token_count", "thoughtsTokenCount" + ) + cached_tokens = _get_field( + usage_metadata, "cached_content_token_count", "cachedContentTokenCount" + ) + reasoning_tokens = int(thoughts_tokens) if thoughts_tokens is not None else None + return types.usage.Usage( + input_tokens=int(prompt_tokens or 0), + output_tokens=int(candidates_tokens or 0) + (reasoning_tokens or 0), + reasoning_tokens=reasoning_tokens, + cache_read_tokens=int(cached_tokens) if cached_tokens is not None else None, + raw=_dump_jsonable(usage_metadata), + ) + + +def _first_candidate(chunk: Any) -> Any: + candidates = _get_field(chunk, "candidates", default=None) + if not candidates: + return None + return candidates[0] + + +def _chunk_parts(chunk: Any, candidate: Any) -> list[Any]: + content = _get_field(candidate, "content", default=None) + parts = _get_field(content, "parts", default=None) + if parts is not None: + return list(parts) + direct_parts = _get_field(chunk, "parts", default=None) + return list(direct_parts or []) + + +def _tool_call_id(prefix: str = "google_tool") -> str: + return types.messages.generate_id(prefix) + + +async def stream( + client: core.client.Client, + model: core.model.Model, + messages: list[types.messages.Message], + *, + tools: Sequence[types.tools.Tool] | None = None, + output_type: type[pydantic.BaseModel] | None = None, + **kwargs: Any, +) -> AsyncGenerator[events.Event]: + """Stream an LLM response via the Gemini Developer API. + + ``params`` may be a raw dict of ``GenerateContentConfig`` fields. + Provider-specific request options are forwarded without local validation. + """ + sdk_client = _make_client(client) + stream_params = _coerce_params(kwargs.get("params")) + system_instruction, google_contents = await _messages_to_google(messages) + google_tools, has_builtin_tools = _prepare_tools(tools) + + config: dict[str, Any] = dict(stream_params) + if system_instruction: + config["system_instruction"] = system_instruction + if google_tools: + config["tools"] = google_tools + if has_builtin_tools: + tool_config = dict(config.get("tool_config") or {}) + tool_config.setdefault("include_server_side_tool_invocations", True) + config["tool_config"] = tool_config + if output_type is not None: + config["response_mime_type"] = "application/json" + config["response_json_schema"] = output_type.model_json_schema() + + current_text_block_id: str | None = None + current_reasoning_block_id: str | None = None + block_counter = 0 + last_code_execution_tool_call_id: str | None = None + last_server_tool_call_id: str | None = None + usage: types.usage.Usage | None = None + final_provider_metadata: dict[str, Any] | None = None + + def _close_text() -> list[events.Event]: + nonlocal current_text_block_id + if current_text_block_id is None: + return [] + ev = events.TextEnd(block_id=current_text_block_id) + current_text_block_id = None + return [ev] + + def _close_reasoning() -> list[events.Event]: + nonlocal current_reasoning_block_id + if current_reasoning_block_id is None: + return [] + ev = events.ReasoningEnd(block_id=current_reasoning_block_id) + current_reasoning_block_id = None + return [ev] + + def _close_language_blocks() -> list[events.Event]: + return [*_close_text(), *_close_reasoning()] + + def _start_text(provider_metadata: dict[str, Any] | None) -> list[events.Event]: + nonlocal block_counter, current_text_block_id + if current_text_block_id is not None: + return [] + current_text_block_id = str(block_counter) + block_counter += 1 + return [ + events.TextStart( + block_id=current_text_block_id, + provider_metadata=provider_metadata, + ) + ] + + def _start_reasoning( + provider_metadata: dict[str, Any] | None, + ) -> list[events.Event]: + nonlocal block_counter, current_reasoning_block_id + if current_reasoning_block_id is not None: + return [] + current_reasoning_block_id = str(block_counter) + block_counter += 1 + return [ + events.ReasoningStart( + block_id=current_reasoning_block_id, + provider_metadata=provider_metadata, + ) + ] + + try: + yield events.StreamStart() + + sdk_stream = await sdk_client.models.generate_content_stream( + model=model.id, + contents=google_contents, + config=config or None, + ) + + async for chunk in sdk_stream: + chunk_usage = _convert_usage( + _get_field(chunk, "usage_metadata", "usageMetadata") + ) + if chunk_usage is not None: + usage = chunk_usage + + candidate = _first_candidate(chunk) + if candidate is None: + continue + + parts = _chunk_parts(chunk, candidate) + for part in parts: + text = _get_field(part, "text") + if text is not None: + provider_metadata = _thought_signature_metadata(part) + if text == "": + continue + if _get_field(part, "thought", default=False) is True: + for ev in _close_text(): + yield ev + for ev in _start_reasoning(provider_metadata): + yield ev + if current_reasoning_block_id is not None: + yield events.ReasoningDelta( + block_id=current_reasoning_block_id, + chunk=str(text), + provider_metadata=provider_metadata, + ) + else: + for ev in _close_reasoning(): + yield ev + for ev in _start_text(provider_metadata): + yield ev + if current_text_block_id is not None: + yield events.TextDelta( + block_id=current_text_block_id, + chunk=str(text), + provider_metadata=provider_metadata, + ) + continue + + inline_data = _get_field(part, "inline_data", "inlineData") + if inline_data is not None: + for ev in _close_language_blocks(): + yield ev + data = _get_field(inline_data, "data") + media_type = _get_field( + inline_data, "mime_type", "mimeType", default="" + ) + if isinstance(data, bytes): + data = base64.b64encode(data).decode("ascii") + yield events.FileEvent( + block_id=types.messages.generate_id("file"), + media_type=str(media_type), + data=data or "", + provider_metadata=_thought_signature_metadata(part), + ) + continue + + executable_code = _get_field(part, "executable_code", "executableCode") + if executable_code is not None: + for ev in _close_language_blocks(): + yield ev + tool_call_id = _get_field(executable_code, "id") or _tool_call_id( + "google_builtin" + ) + last_code_execution_tool_call_id = str(tool_call_id) + payload = _dump_jsonable(executable_code) + payload_json = _json_dumps(payload) + provider_metadata = _provider_metadata() + yield events.BuiltinToolStart( + tool_call_id=last_code_execution_tool_call_id, + tool_name="code_execution", + provider_metadata=provider_metadata, + ) + yield events.BuiltinToolDelta( + tool_call_id=last_code_execution_tool_call_id, + chunk=payload_json, + provider_metadata=provider_metadata, + ) + yield events.BuiltinToolEnd( + tool_call_id=last_code_execution_tool_call_id, + tool_call=types.messages.BuiltinToolCallPart( + tool_call_id=last_code_execution_tool_call_id, + tool_name="code_execution", + provider_metadata=provider_metadata, + ), + provider_metadata=provider_metadata, + ) + continue + + code_execution_result = _get_field( + part, "code_execution_result", "codeExecutionResult" + ) + if code_execution_result is not None: + tool_call_id = _get_field(code_execution_result, "id") or ( + last_code_execution_tool_call_id + or _tool_call_id("google_builtin") + ) + result = _dump_jsonable(code_execution_result) + provider_metadata = _provider_metadata() + yield events.BuiltinToolResult( + tool_call_id=str(tool_call_id), + result=types.messages.BuiltinToolReturnPart( + tool_call_id=str(tool_call_id), + tool_name="code_execution", + result=result, + provider_metadata=provider_metadata, + ), + provider_metadata=provider_metadata, + ) + last_code_execution_tool_call_id = None + continue + + tool_call = _get_field(part, "tool_call", "toolCall") + if tool_call is not None: + for ev in _close_language_blocks(): + yield ev + tool_call_id = _get_field(tool_call, "id") or _tool_call_id( + "google_builtin" + ) + tool_type = str( + _wire_value(_get_field(tool_call, "tool_type", "toolType")) + ) + last_server_tool_call_id = str(tool_call_id) + provider_metadata = _provider_metadata( + serverToolCallId=str(tool_call_id), + serverToolType=tool_type, + ) + args = _get_field(tool_call, "args", default={}) or {} + yield events.BuiltinToolStart( + tool_call_id=str(tool_call_id), + tool_name=f"server:{tool_type}", + provider_metadata=provider_metadata, + ) + yield events.BuiltinToolDelta( + tool_call_id=str(tool_call_id), + chunk=_json_dumps(args), + provider_metadata=provider_metadata, + ) + yield events.BuiltinToolEnd( + tool_call_id=str(tool_call_id), + tool_call=types.messages.BuiltinToolCallPart( + tool_call_id=str(tool_call_id), + tool_name=f"server:{tool_type}", + provider_metadata=provider_metadata, + ), + provider_metadata=provider_metadata, + ) + continue + + tool_response = _get_field(part, "tool_response", "toolResponse") + if tool_response is not None: + tool_call_id = _get_field(tool_response, "id") or ( + last_server_tool_call_id or _tool_call_id("google_builtin") + ) + tool_type = str( + _wire_value(_get_field(tool_response, "tool_type", "toolType")) + ) + provider_metadata = _provider_metadata( + serverToolCallId=str(tool_call_id), + serverToolType=tool_type, + ) + yield events.BuiltinToolResult( + tool_call_id=str(tool_call_id), + result=types.messages.BuiltinToolReturnPart( + tool_call_id=str(tool_call_id), + tool_name=f"server:{tool_type}", + result=_dump_jsonable( + _get_field(tool_response, "response", default={}) + ), + provider_metadata=provider_metadata, + ), + provider_metadata=provider_metadata, + ) + last_server_tool_call_id = None + continue + + function_call = _get_field(part, "function_call", "functionCall") + if function_call is not None: + for ev in _close_language_blocks(): + yield ev + tool_name = _get_field(function_call, "name") + if not tool_name: + continue + tool_call_id = _get_field(function_call, "id") or _tool_call_id() + args = _get_field(function_call, "args", default={}) or {} + args_json = _json_dumps(args) + provider_metadata = _thought_signature_metadata(part) + yield events.ToolStart( + tool_call_id=str(tool_call_id), + tool_name=str(tool_name), + provider_metadata=provider_metadata, + ) + yield events.ToolDelta( + tool_call_id=str(tool_call_id), + chunk=args_json, + provider_metadata=provider_metadata, + ) + yield events.ToolEnd( + tool_call_id=str(tool_call_id), + tool_call=types.messages.DUMMY_TOOL_CALL, + provider_metadata=provider_metadata, + ) + continue + + finish_reason = _get_field(candidate, "finish_reason", "finishReason") + if finish_reason is not None: + raw_usage = _get_field(chunk, "usage_metadata", "usageMetadata") + final_provider_metadata = _provider_metadata( + promptFeedback=_dump_jsonable( + _get_field(chunk, "prompt_feedback", "promptFeedback") + ), + groundingMetadata=_dump_jsonable( + _get_field(candidate, "grounding_metadata", "groundingMetadata") + ), + urlContextMetadata=_dump_jsonable( + _get_field( + candidate, + "url_context_metadata", + "urlContextMetadata", + ) + ), + safetyRatings=_dump_jsonable( + _get_field(candidate, "safety_ratings", "safetyRatings") + ), + usageMetadata=_dump_jsonable(raw_usage), + finishReason=_dump_jsonable(finish_reason), + finishMessage=_dump_jsonable( + _get_field(candidate, "finish_message", "finishMessage") + ), + ) + + for ev in _close_language_blocks(): + yield ev + yield events.StreamEnd(usage=usage, provider_metadata=final_provider_metadata) + finally: + close = getattr(sdk_client, "aclose", None) + if close is not None: + await close() diff --git a/src/ai/models/google/check.py b/src/ai/models/google/check.py new file mode 100644 index 00000000..1c0c468c --- /dev/null +++ b/src/ai/models/google/check.py @@ -0,0 +1,29 @@ +"""Google Gemini connection check. + +Verifies that the client's credentials are valid and that the model exists on +the Gemini Developer API by hitting ``GET {base_url}/models/{model_id}``. + +This endpoint is free; no tokens or credits are consumed. +""" + +from .. import core + +# Google returns 400 for some bad-key / malformed-model cases. +_FAIL_STATUSES = frozenset({400, 401, 403, 404}) + + +async def check(client: core.client.Client, model: core.model.Model) -> bool: + """Return ``True`` if *client* can reach Google and *model* exists.""" + if not client.api_key: + return False + model_name = model.id if model.id.startswith("models/") else f"models/{model.id}" + url = f"{client.base_url.rstrip('/')}/{model_name}" + headers = {"x-goog-api-key": client.api_key} + response = await client.http.get(url, headers=headers) + if response.status_code == 200: + return True + if response.status_code in _FAIL_STATUSES: + return False + # Unexpected status - let the caller handle it. + response.raise_for_status() + return False # pragma: no cover diff --git a/src/ai/models/google/provider.py b/src/ai/models/google/provider.py new file mode 100644 index 00000000..94bc339d --- /dev/null +++ b/src/ai/models/google/provider.py @@ -0,0 +1,109 @@ +"""Google Gemini provider. + +Defines the callable :data:`google` provider, which satisfies the +:class:`~ai.models.core.proto.Provider` protocol. +""" + +from __future__ import annotations + +import os +from types import ModuleType + +from .. import core + +_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" +_BASE_URL_ENV = "GOOGLE_BASE_URL" +_PRIMARY_API_KEY_ENV = "GOOGLE_API_KEY" +_FALLBACK_API_KEY_ENV = "GEMINI_API_KEY" + + +def _api_key_from_env() -> str | None: + """Return Gemini Developer API key using google-genai precedence.""" + return os.environ.get(_PRIMARY_API_KEY_ENV) or os.environ.get(_FALLBACK_API_KEY_ENV) + + +class _Google: + """Callable provider factory for Google Gemini Developer API models. + + Satisfies the :class:`~ai.models.core.proto.Provider` protocol. + """ + + @property + def api_key_env(self) -> str: + return _PRIMARY_API_KEY_ENV + + @property + def base_url(self) -> str: + return os.environ.get(_BASE_URL_ENV) or _BASE_URL + + @property + def adapter(self) -> str: + return "google" + + @property + def name(self) -> str: + return "google" + + @property + def tools(self) -> ModuleType: + """The provider's built-in tool factories. + + Convenience accessor: ``google.tools.google_search(...)``. + """ + from . import tools as tools_module + + return tools_module + + def client(self) -> core.client.Client: + """Create a :class:`Client` from env-var credentials. + + ``GOOGLE_BASE_URL`` overrides the default Gemini Developer API base URL + when set. ``GOOGLE_API_KEY`` takes precedence over ``GEMINI_API_KEY``, + matching the first-party ``google-genai`` SDK. + """ + return core.client.Client( + base_url=self.base_url, + api_key=_api_key_from_env(), + ) + + async def check(self, client: core.client.Client, model: core.model.Model) -> bool: + """Delegate to :func:`google.check.check`.""" + from . import check as check_ + + return await check_.check(client, model) + + def __call__( + self, + model_id: str, + *, + client: core.client.Client | None = None, + ) -> core.model.Model: + return core.model.Model( + id=model_id, + adapter=self.adapter, + provider=self, + client=client, + ) + + async def list(self, *, client: core.client.Client | None = None) -> list[str]: + """List available Gemini Developer API model IDs.""" + c = client or self.client() + headers = {"x-goog-api-key": c.api_key or ""} + response = await c.http.get(f"{c.base_url.rstrip('/')}/models", headers=headers) + response.raise_for_status() + data: list[dict[str, object]] = response.json().get("models", []) + ids: list[str] = [] + for model in data: + raw = str(model.get("name") or model.get("id") or "") + if not raw: + continue + ids.append(raw.removeprefix("models/")) + return sorted(ids) + + def __repr__(self) -> str: + return "google" + + +google = _Google() + +__all__ = ["google"] diff --git a/src/ai/models/google/tools.py b/src/ai/models/google/tools.py new file mode 100644 index 00000000..ad8aa0e7 --- /dev/null +++ b/src/ai/models/google/tools.py @@ -0,0 +1,151 @@ +"""Google provider-executed tools.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import pydantic + +from ... import types + +_CONFIG_MODEL = pydantic.ConfigDict( + frozen=True, + populate_by_name=True, + extra="forbid", +) + + +class SearchTypes(pydantic.BaseModel): + """Google Search result types.""" + + model_config = _CONFIG_MODEL + + web_search: dict[str, Any] | None = None + image_search: dict[str, Any] | None = None + + +class TimeRangeFilter(pydantic.BaseModel): + """Timestamp range filter for Google Search.""" + + model_config = _CONFIG_MODEL + + start_time: str + end_time: str + + +class GoogleProviderArgs(pydantic.BaseModel): + """Base for Google provider-executed tool args.""" + + model_config = _CONFIG_MODEL + + google_tool_name: ClassVar[str] + + +class GoogleSearchArgs(GoogleProviderArgs): + google_tool_name: ClassVar[str] = "google_search" + + model_config = _CONFIG_MODEL + + search_types: SearchTypes | dict[str, Any] | None = None + time_range_filter: TimeRangeFilter | dict[str, Any] | None = None + + +class UrlContextArgs(GoogleProviderArgs): + google_tool_name: ClassVar[str] = "url_context" + + model_config = _CONFIG_MODEL + + +class CodeExecutionArgs(GoogleProviderArgs): + google_tool_name: ClassVar[str] = "code_execution" + + model_config = _CONFIG_MODEL + + +class FileSearchArgs(GoogleProviderArgs): + google_tool_name: ClassVar[str] = "file_search" + + model_config = _CONFIG_MODEL + + file_search_store_names: list[str] + top_k: int | None = None + metadata_filter: str | None = None + + +class GoogleMapsArgs(GoogleProviderArgs): + google_tool_name: ClassVar[str] = "google_maps" + + model_config = _CONFIG_MODEL + + +def google_search( + *, + search_types: SearchTypes | dict[str, Any] | None = None, + time_range_filter: TimeRangeFilter | dict[str, Any] | None = None, +) -> types.tools.Tool: + return types.tools.Tool( + kind="provider", + name="google_search", + args=GoogleSearchArgs( + search_types=search_types, + time_range_filter=time_range_filter, + ), + ) + + +def url_context() -> types.tools.Tool: + return types.tools.Tool( + kind="provider", + name="url_context", + args=UrlContextArgs(), + ) + + +def code_execution() -> types.tools.Tool: + return types.tools.Tool( + kind="provider", + name="code_execution", + args=CodeExecutionArgs(), + ) + + +def file_search( + *, + file_search_store_names: list[str], + top_k: int | None = None, + metadata_filter: str | None = None, +) -> types.tools.Tool: + return types.tools.Tool( + kind="provider", + name="file_search", + args=FileSearchArgs( + file_search_store_names=file_search_store_names, + top_k=top_k, + metadata_filter=metadata_filter, + ), + ) + + +def google_maps() -> types.tools.Tool: + return types.tools.Tool( + kind="provider", + name="google_maps", + args=GoogleMapsArgs(), + ) + + +__all__ = [ + "CodeExecutionArgs", + "FileSearchArgs", + "GoogleMapsArgs", + "GoogleProviderArgs", + "GoogleSearchArgs", + "SearchTypes", + "TimeRangeFilter", + "UrlContextArgs", + "code_execution", + "file_search", + "google_maps", + "google_search", + "url_context", +] diff --git a/tests/models/google/__init__.py b/tests/models/google/__init__.py new file mode 100644 index 00000000..ca138134 --- /dev/null +++ b/tests/models/google/__init__.py @@ -0,0 +1 @@ +"""Google model tests.""" diff --git a/tests/models/google/conftest.py b/tests/models/google/conftest.py new file mode 100644 index 00000000..e5d3e86f --- /dev/null +++ b/tests/models/google/conftest.py @@ -0,0 +1,73 @@ +"""Shared fakes for the Google adapter tests.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from google.genai import types as genai_types + + +class FakeGoogleStream: + """Async-iterable stand-in for google-genai stream chunks.""" + + def __init__(self, chunks: Iterable[Any] = ()) -> None: + self._chunks = list(chunks) + + def __aiter__(self) -> FakeGoogleStream: + self._iter = iter(self._chunks) + return self + + async def __anext__(self) -> Any: + try: + return next(self._iter) + except StopIteration: + raise StopAsyncIteration from None + + +class FakeModels: + def __init__(self, captured: dict[str, Any], stream: FakeGoogleStream) -> None: + self._captured = captured + self._stream = stream + + async def generate_content_stream( + self, + *, + model: str, + contents: list[dict[str, Any]], + config: dict[str, Any] | None = None, + ) -> FakeGoogleStream: + sdk_contents = [genai_types.Content.model_validate(item) for item in contents] + sdk_config = ( + genai_types.GenerateContentConfig.model_validate(config) + if config is not None + else None + ) + self._captured.update( + { + "model": model, + "contents": contents, + "config": config, + "sdk_contents": sdk_contents, + "sdk_config": sdk_config, + } + ) + return self._stream + + +class FakeGoogleClient: + """Stand-in for ``google.genai.Client(...).aio``.""" + + def __init__( + self, + captured: dict[str, Any] | None = None, + stream: FakeGoogleStream | None = None, + ) -> None: + self.models = FakeModels( + captured if captured is not None else {}, + stream or FakeGoogleStream(), + ) + self.closed = False + + async def aclose(self) -> None: + self.closed = True diff --git a/tests/models/google/test_adapter.py b/tests/models/google/test_adapter.py new file mode 100644 index 00000000..68e0f922 --- /dev/null +++ b/tests/models/google/test_adapter.py @@ -0,0 +1,264 @@ +"""Tests for the Google adapter's request shaping.""" + +from __future__ import annotations + +from typing import Any + +import pydantic +import pytest + +import ai +from ai import models +from ai.models.google import adapter, google +from ai.models.google import tools as google_tools +from ai.types import messages +from ai.types import tools as tool_types + +from .conftest import FakeGoogleClient + + +class _Answer(pydantic.BaseModel): + answer: str + + +_TEST_CLIENT = models.Client(base_url="https://google.test/v1beta", api_key="sk-test") +_MODEL = google("gemini-2.5-flash") + + +def _patch(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + captured: dict[str, Any] = {} + fake = FakeGoogleClient(captured) + monkeypatch.setattr(adapter, "_make_client", lambda client: fake) + return captured + + +async def _drain(stream: Any) -> None: + async for _ in stream: + pass + + +async def test_system_and_user_content_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = _patch(monkeypatch) + + await _drain( + adapter.stream( + _TEST_CLIENT, + _MODEL, + [ + ai.system_message("rules"), + ai.user_message( + "Describe this", + messages.FilePart( + data=b"img", + media_type="image/png", + filename="image.png", + ), + ), + ], + ) + ) + + assert captured["model"] == "gemini-2.5-flash" + assert captured["sdk_contents"] + assert captured["sdk_config"] is not None + assert captured["config"]["system_instruction"] == "rules" + assert captured["contents"] == [ + { + "role": "user", + "parts": [ + {"text": "Describe this"}, + { + "inline_data": { + "mime_type": "image/png", + "data": "aW1n", + "display_name": "image.png", + } + }, + ], + } + ] + + +async def test_raw_params_pass_through_to_generate_content_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = _patch(monkeypatch) + + await _drain( + adapter.stream( + _TEST_CLIENT, + _MODEL, + [ai.user_message("Hi")], + params={ + "temperature": 0.2, + "top_p": 0.9, + "thinking_config": {"thinking_budget": 128}, + }, + ) + ) + + assert captured["config"]["temperature"] == 0.2 + assert captured["config"]["top_p"] == 0.9 + assert captured["config"]["thinking_config"] == {"thinking_budget": 128} + + +async def test_structured_output_maps_to_json_schema_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = _patch(monkeypatch) + + await _drain( + adapter.stream( + _TEST_CLIENT, + _MODEL, + [ai.user_message("Hi")], + output_type=_Answer, + ) + ) + + assert captured["config"]["response_mime_type"] == "application/json" + assert captured["config"]["response_json_schema"]["title"] == "_Answer" + + +async def test_function_and_provider_tools_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = _patch(monkeypatch) + weather = tool_types.Tool( + kind="function", + name="weather", + args=tool_types.FunctionToolArgs( + description="Get weather", + params={ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + ), + ) + + await _drain( + adapter.stream( + _TEST_CLIENT, + _MODEL, + [ai.user_message("Hi")], + tools=[ + weather, + google_tools.google_search( + search_types={"web_search": {}}, + time_range_filter={ + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2026-01-02T00:00:00Z", + }, + ), + google_tools.file_search( + file_search_store_names=["fileSearchStores/store-1"], + top_k=3, + ), + ], + ) + ) + + assert captured["config"]["tools"] == [ + { + "function_declarations": [ + { + "name": "weather", + "description": "Get weather", + "parameters_json_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + } + ] + }, + { + "google_search": { + "search_types": {"web_search": {}}, + "time_range_filter": { + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2026-01-02T00:00:00Z", + }, + } + }, + { + "file_search": { + "file_search_store_names": ["fileSearchStores/store-1"], + "top_k": 3, + } + }, + ] + assert captured["config"]["tool_config"] == { + "include_server_side_tool_invocations": True + } + assert captured["sdk_config"] is not None + + +async def test_non_dict_params_rejected_by_adapter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch(monkeypatch) + + stream = adapter.stream( + _TEST_CLIENT, + _MODEL, + [ai.user_message("Hi")], + params=[{"temperature": 0.2}], + ) + + with pytest.raises(TypeError, match="dict"): + await _drain(stream) + + +async def test_message_history_round_trips_tool_calls_and_results( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = _patch(monkeypatch) + convo = [ + ai.user_message("Weather?"), + messages.Message( + role="assistant", + parts=[ + messages.ToolCallPart( + tool_call_id="fc_1", + tool_name="weather", + tool_args='{"city":"SF"}', + ) + ], + ), + ai.tool_message( + tool_call_id="fc_1", + tool_name="weather", + result={"temp": 62}, + ), + ] + + await _drain(adapter.stream(_TEST_CLIENT, _MODEL, convo)) + + assert captured["contents"][1] == { + "role": "model", + "parts": [ + { + "function_call": { + "id": "fc_1", + "name": "weather", + "args": {"city": "SF"}, + } + } + ], + } + assert captured["contents"][2] == { + "role": "user", + "parts": [ + { + "function_response": { + "id": "fc_1", + "name": "weather", + "response": {"output": {"temp": 62}}, + } + } + ], + } diff --git a/tests/models/google/test_check.py b/tests/models/google/test_check.py new file mode 100644 index 00000000..94b884bf --- /dev/null +++ b/tests/models/google/test_check.py @@ -0,0 +1,51 @@ +"""Google ``check`` tests.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest + +from ai.models.core import client as client_ +from ai.models.google import check, google + +_MODEL = google("gemini-2.5-flash") + + +def _client_with_mock( + status_code: int = 200, + json_body: Any = None, + base_url: str = "https://google.test/v1beta", +) -> client_.Client: + def _handler(request: httpx.Request) -> httpx.Response: + body = json.dumps(json_body or {}).encode() + return httpx.Response(status_code, content=body) + + client = client_.Client(base_url=base_url, api_key="sk-test-key") + client._http = httpx.AsyncClient( + base_url=base_url, + transport=httpx.MockTransport(_handler), + ) + return client + + +async def test_200_returns_true() -> None: + client = _client_with_mock(200, {"name": "models/gemini-2.5-flash"}) + assert await check.check(client, _MODEL) is True + + +@pytest.mark.parametrize("status", [400, 401, 403, 404]) +async def test_client_error_returns_false(status: int) -> None: + assert await check.check(_client_with_mock(status), _MODEL) is False + + +async def test_500_raises() -> None: + with pytest.raises(httpx.HTTPStatusError): + await check.check(_client_with_mock(500), _MODEL) + + +async def test_no_api_key_returns_false() -> None: + client = client_.Client(base_url="https://google.test/v1beta", api_key=None) + assert await check.check(client, _MODEL) is False diff --git a/tests/models/google/test_provider.py b/tests/models/google/test_provider.py new file mode 100644 index 00000000..d07aec36 --- /dev/null +++ b/tests/models/google/test_provider.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import httpx +import pytest + +from ai.models.core import client as client_ +from ai.models.google import google + + +async def test_list_gets_models_with_api_key_header_and_sorts_ids() -> None: + captured_urls: list[str] = [] + captured_headers: dict[str, str] = {} + + def _handler(request: httpx.Request) -> httpx.Response: + captured_urls.append(str(request.url)) + captured_headers.update(dict(request.headers)) + return httpx.Response( + 200, + json={ + "models": [ + {"name": "models/gemini-z"}, + {"name": "models/gemini-a"}, + ] + }, + ) + + client = client_.Client(base_url="https://google.test/v1beta", api_key="sk-test") + client._http = httpx.AsyncClient(transport=httpx.MockTransport(_handler)) + + try: + ids = await google.list(client=client) + finally: + await client.aclose() + + assert captured_urls == ["https://google.test/v1beta/models"] + assert captured_headers["x-goog-api-key"] == "sk-test" + assert ids == ["gemini-a", "gemini-z"] + + +def test_base_url_defaults_when_env_var_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GOOGLE_BASE_URL", raising=False) + assert google.base_url == "https://generativelanguage.googleapis.com/v1beta" + + +def test_base_url_reads_google_base_url_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GOOGLE_BASE_URL", "https://proxy.example.com/v1beta") + assert google.base_url == "https://proxy.example.com/v1beta" + + +def test_client_uses_google_api_key_before_gemini_api_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GOOGLE_BASE_URL", "https://proxy.example.com/v1beta") + monkeypatch.setenv("GOOGLE_API_KEY", "google-key") + monkeypatch.setenv("GEMINI_API_KEY", "gemini-key") + c = google.client() + assert c.base_url == "https://proxy.example.com/v1beta" + assert c.api_key == "google-key" + + +def test_client_uses_gemini_api_key_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.setenv("GEMINI_API_KEY", "gemini-key") + c = google.client() + assert c.api_key == "gemini-key" diff --git a/tests/models/google/test_stream.py b/tests/models/google/test_stream.py new file mode 100644 index 00000000..55d4586c --- /dev/null +++ b/tests/models/google/test_stream.py @@ -0,0 +1,241 @@ +"""Tests for Google stream parsing.""" + +from __future__ import annotations + +import json + +import pytest +from google.genai import types as genai_types + +import ai +from ai import models +from ai.models.google import adapter, google + +from .conftest import FakeGoogleClient, FakeGoogleStream + +_CLIENT = models.Client(base_url="https://google.test/v1beta", api_key="sk-test") +_MODEL = google("gemini-2.5-flash") + + +def _chunk( + *parts: dict[str, object], + finish_reason: str | None = None, +) -> genai_types.GenerateContentResponse: + candidate: dict[str, object] = { + "content": {"role": "model", "parts": list(parts)}, + } + if finish_reason is not None: + candidate.update( + { + "finish_reason": finish_reason, + "safety_ratings": [{"category": "HARM_CATEGORY_HATE_SPEECH"}], + } + ) + + payload: dict[str, object] = {"candidates": [candidate]} + if finish_reason is not None: + payload["usage_metadata"] = { + "prompt_token_count": 5, + "candidates_token_count": 7, + "thoughts_token_count": 2, + "cached_content_token_count": 1, + } + return genai_types.GenerateContentResponse.model_validate(payload) + + +def _part(**values: object) -> dict[str, object]: + return genai_types.Part.model_validate(values).model_dump( + mode="json", + exclude_none=True, + ) + + +async def _drain( + stream: FakeGoogleStream, + monkeypatch: pytest.MonkeyPatch, +) -> models.Stream: + fake = FakeGoogleClient(stream=stream) + monkeypatch.setattr(adapter, "_make_client", lambda client: fake) + s = models.Stream(adapter.stream(_CLIENT, _MODEL, [ai.user_message("Hi")])) + async for _ in s: + pass + return s + + +async def test_text_reasoning_file_and_function_call_events( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sdk_stream = FakeGoogleStream( + [ + _chunk(_part(text="hello")), + _chunk(_part(text="thinking", thought=True)), + _chunk(_part(inline_data={"mime_type": "image/png", "data": "cG5n"})), + _chunk( + _part( + function_call={ + "id": "fc_1", + "name": "weather", + "args": {"city": "SF"}, + } + ), + finish_reason="STOP", + ), + ] + ) + + s = await _drain(sdk_stream, monkeypatch) + + assert s.message.text == "hello" + assert s.message.reasoning == "thinking" + assert s.message.usage is not None + assert s.message.usage.input_tokens == 5 + assert s.message.usage.output_tokens == 9 + assert s.message.usage.reasoning_tokens == 2 + assert s.message.usage.cache_read_tokens == 1 + assert s.message.provider_metadata is not None + assert s.message.provider_metadata["provider"] == "google" + assert s.message.provider_metadata["finishReason"] == "STOP" + + file = s.message.files[0] + assert file.media_type == "image/png" + assert file.data == "cG5n" + + calls = s.message.tool_calls + assert len(calls) == 1 + assert calls[0].tool_call_id == "fc_1" + assert calls[0].tool_name == "weather" + assert calls[0].tool_args == '{"city":"SF"}' + + +async def test_code_execution_emits_builtin_call_and_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sdk_stream = FakeGoogleStream( + [ + _chunk( + _part( + executable_code={ + "id": "code_1", + "language": "PYTHON", + "code": "print(1)", + } + ) + ), + _chunk( + _part( + code_execution_result={ + "id": "code_1", + "outcome": "OUTCOME_OK", + "output": "1\n", + } + ) + ), + ] + ) + + s = await _drain(sdk_stream, monkeypatch) + + calls = s.message.builtin_tool_calls + assert len(calls) == 1 + assert calls[0].tool_call_id == "code_1" + assert calls[0].tool_name == "code_execution" + assert json.loads(calls[0].tool_args) == { + "id": "code_1", + "language": "PYTHON", + "code": "print(1)", + } + + returns = s.message.builtin_tool_returns + assert len(returns) == 1 + assert returns[0].tool_call_id == "code_1" + assert returns[0].tool_name == "code_execution" + assert returns[0].result == { + "id": "code_1", + "outcome": "OUTCOME_OK", + "output": "1\n", + } + + +async def test_server_tool_call_and_response_round_trip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sdk_stream = FakeGoogleStream( + [ + _chunk( + _part( + tool_call={ + "id": "srv_1", + "tool_type": "GOOGLE_SEARCH_WEB", + "args": {"query": "weather"}, + } + ) + ), + _chunk( + _part( + tool_response={ + "id": "srv_1", + "tool_type": "GOOGLE_SEARCH_WEB", + "response": {"results": [{"title": "Forecast"}]}, + } + ) + ), + ] + ) + + s = await _drain(sdk_stream, monkeypatch) + + calls = s.message.builtin_tool_calls + assert len(calls) == 1 + assert calls[0].tool_call_id == "srv_1" + assert calls[0].tool_name == "server:GOOGLE_SEARCH_WEB" + assert calls[0].tool_args == '{"query":"weather"}' + assert calls[0].provider_metadata == { + "provider": "google", + "serverToolCallId": "srv_1", + "serverToolType": "GOOGLE_SEARCH_WEB", + } + + returns = s.message.builtin_tool_returns + assert len(returns) == 1 + assert returns[0].tool_call_id == "srv_1" + assert returns[0].tool_name == "server:GOOGLE_SEARCH_WEB" + assert returns[0].result == {"results": [{"title": "Forecast"}]} + + +async def test_thought_signature_round_trips_from_provider_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + fake = FakeGoogleClient(captured) + monkeypatch.setattr(adapter, "_make_client", lambda client: fake) + + stream = adapter.stream( + _CLIENT, + _MODEL, + [ + ai.assistant_message( + ai.thinking( + "hidden", + provider_metadata={ + "provider": "google", + "thoughtSignature": "sig", + }, + ) + ) + ], + ) + async for _ in stream: + pass + + assert captured["contents"] == [ + { + "role": "model", + "parts": [ + { + "text": "hidden", + "thought": True, + "thought_signature": "sig", + } + ], + } + ] diff --git a/uv.lock b/uv.lock index 994b964b..bb2e6faa 100644 --- a/uv.lock +++ b/uv.lock @@ -131,6 +131,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -226,6 +299,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "google-auth" +version = "2.52.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/f8/80d2493cbedece1c623dc3e3cb1883300871af0dcdae254409522985ac23/google_auth-2.52.0.tar.gz", hash = "sha256:01f30e1a9e3638698d89464f5e603ce29d18e1c0e63ec31ac570aba4e164aaf5", size = 335027, upload-time = "2026-05-07T19:45:24.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/fc/2cdc74252746f547f81ff3f02d4d4234a3f411b5de5b61af97e633a060b9/google_auth-2.52.0-py3-none-any.whl", hash = "sha256:aee92803ba0ff93a70a3b8a35c7b4797837751cd6380b63ff38372b98f3ed627", size = 245614, upload-time = "2026-05-07T19:45:21.914Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/ae/8504f6fa44aae887909c3fda1d49c6ffe129225b68f6f63b8904c49e7e90/google_genai-2.0.1.tar.gz", hash = "sha256:32cec7c07157c0e65e4dfc740e3288ff8e8bfc2d506cde49f884d79ed8377867", size = 537456, upload-time = "2026-05-09T01:37:12.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/20/7f427041c3660fabd4c396e80b27dd40b7d89b121ba384d69005a764910d/google_genai-2.0.1-py3-none-any.whl", hash = "sha256:5cb61ff5b8d33129bb7f5df0b5384ed2e71e5dd06ccc012cdbad28b070f6ce99", size = 791449, upload-time = "2026-05-09T01:37:10.841Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -588,6 +700,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -810,6 +943,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "requests" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -964,6 +1112,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/c4/09985a03dba389d4fe16a9014147a7b02fa76ef3519bf5846462a485876d/starlette-0.51.0-py3-none-any.whl", hash = "sha256:fb460a3d6fd3c958d729fdd96aee297f89a51b0181f16401fe8fd4cb6129165d", size = 74133, upload-time = "2026-01-10T20:23:13.445Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -997,6 +1154,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "uvicorn" version = "0.40.0" @@ -1032,6 +1198,7 @@ version = "0.0.1.dev10" source = { editable = "." } dependencies = [ { name = "anthropic" }, + { name = "google-genai" }, { name = "httpx" }, { name = "mcp" }, { name = "openai" }, @@ -1054,6 +1221,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.83.0" }, + { name = "google-genai", specifier = ">=2.0.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.18.0" }, { name = "openai", specifier = ">=2.14.0" },