Spaces:
Paused
Paused
| # -*- coding: utf-8 -*- | |
| """The Google Gemini chat model implementation.""" | |
| import base64 | |
| import json | |
| from datetime import datetime | |
| from typing import Literal, Any, AsyncGenerator, TYPE_CHECKING, List, Type | |
| from pydantic import BaseModel, Field | |
| from ..._utils._common import _generate_id, _flatten_json_schema | |
| from .._base import ChatModelBase, _TOOL_CHOICE_LITERAL_MODES | |
| from .._model_response import ChatResponse | |
| from .._model_usage import ChatUsage | |
| from ...credential import GeminiCredential | |
| from ...formatter import FormatterBase, GeminiChatFormatter | |
| from ...message import Msg, ThinkingBlock, ToolCallBlock, TextBlock | |
| from ...tool import ToolChoice | |
| if TYPE_CHECKING: | |
| from google.genai.types import GenerateContentResponse | |
| else: | |
| GenerateContentResponse = Any | |
| def _sanitize_schema_for_gemini(schema: Any) -> Any: | |
| """Sanitize a JSON schema to be compatible with the Gemini API. | |
| Gemini API does not support certain JSON Schema constructs. This | |
| function removes or rewrites the following: | |
| - ``additionalProperties``: removed entirely. | |
| - ``const``: converted to an equivalent single-value ``enum``, | |
| since Gemini's ``Schema`` model does not support ``const``. | |
| - ``anyOf`` containing a ``{"type": "null"}`` entry: simplified to | |
| the single non-null type. If there is exactly one non-null | |
| alternative it is inlined directly; otherwise the ``anyOf`` is | |
| kept but the null entry is dropped. | |
| - All nested sub-schemas (``properties``, ``items``, ``$defs``, | |
| etc.) are processed recursively. | |
| Args: | |
| schema (`Any`): | |
| The JSON schema to sanitize. Non-dict values are returned | |
| unchanged; lists are recursively sanitized element-wise. | |
| Returns: | |
| `Any`: | |
| A sanitized copy of the schema, or the original value if it | |
| is not a dict or list. | |
| """ | |
| if not isinstance(schema, dict): | |
| if isinstance(schema, list): | |
| return [_sanitize_schema_for_gemini(v) for v in schema] | |
| return schema | |
| schema = dict(schema) | |
| # Gemini (and many third-party proxies) reject `null` as a standalone | |
| # functionDeclaration property type. Some MCP servers emit | |
| # {"type": "null"} directly (not wrapped in anyOf) for parameters that | |
| # accept None — rewrite it to "object" so it round-trips through the API. | |
| if schema.get("type") == "null": | |
| schema["type"] = "object" | |
| # Remove additionalProperties — not supported by Gemini | |
| schema.pop("additionalProperties", None) | |
| # Convert `const` into an equivalent single-value `enum` — Gemini's | |
| # Schema model does not support the `const` keyword. | |
| if "const" in schema: | |
| const_value = schema.pop("const") | |
| schema.setdefault("enum", [const_value]) | |
| # Simplify anyOf that only differs by a null type, e.g. Optional[X] | |
| if "anyOf" in schema and isinstance(schema["anyOf"], list): | |
| any_of = schema["anyOf"] | |
| non_null = [v for v in any_of if v != {"type": "null"}] | |
| if len(non_null) < len(any_of): # at least one null entry removed | |
| if len(non_null) == 1: | |
| # Inline the single non-null type, preserving outer keys | |
| merged = dict(_sanitize_schema_for_gemini(non_null[0])) | |
| for k, v in schema.items(): | |
| if k != "anyOf": | |
| merged.setdefault(k, v) | |
| return merged | |
| elif non_null: | |
| schema["anyOf"] = [ | |
| _sanitize_schema_for_gemini(v) for v in non_null | |
| ] | |
| else: | |
| del schema["anyOf"] | |
| # Recursively process nested object schemas | |
| for key in ["properties", "patternProperties", "$defs"]: | |
| if key in schema and isinstance(schema[key], dict): | |
| schema[key] = { | |
| k: _sanitize_schema_for_gemini(v) | |
| for k, v in schema[key].items() | |
| } | |
| for key in ["items", "not", "if", "then", "else"]: | |
| if key in schema: | |
| schema[key] = _sanitize_schema_for_gemini(schema[key]) | |
| for key in ["allOf", "oneOf", "anyOf"]: | |
| if key in schema and isinstance(schema[key], list): | |
| schema[key] = [_sanitize_schema_for_gemini(v) for v in schema[key]] | |
| return schema | |
| class GeminiChatModel(ChatModelBase): | |
| """The Google Gemini chat model.""" | |
| class Parameters(BaseModel): | |
| """The parameters for the Gemini chat model.""" | |
| max_tokens: int | None = Field( | |
| default=None, | |
| title="Max Tokens", | |
| description="The maximum number of tokens for the LLM output.", | |
| gt=0, | |
| ) | |
| thinking_enable: bool = Field( | |
| default=False, | |
| title="Thinking", | |
| description="Whether to enable thinking output.", | |
| ) | |
| thinking_budget: int | None = Field( | |
| default=None, | |
| title="Thinking Budget", | |
| description="The thinking budget in tokens.", | |
| gt=0, | |
| ) | |
| temperature: float | None = Field( | |
| default=None, | |
| title="Temperature", | |
| description="The temperature for the LLM output.", | |
| ge=0, | |
| le=2, | |
| ) | |
| top_p: float | None = Field( | |
| default=None, | |
| title="Top P", | |
| description="The top P value for the LLM output.", | |
| gt=0, | |
| le=1, | |
| ) | |
| type: Literal["gemini_chat"] = "gemini_chat" | |
| """The type of the chat model.""" | |
| def __init__( | |
| self, | |
| credential: GeminiCredential, | |
| model: str, | |
| parameters: "GeminiChatModel.Parameters | None" = None, | |
| stream: bool = True, | |
| max_retries: int = 3, | |
| retry_delay: float = 1.0, | |
| context_size: int = 1048576, | |
| formatter: FormatterBase | None = None, | |
| client_kwargs: dict[str, Any] | None = None, | |
| ) -> None: | |
| """Initialize the Gemini chat model. | |
| Args: | |
| credential (`GeminiCredential`): | |
| The Google Gemini credential used to authenticate API calls. | |
| model (`str`): | |
| The Gemini model name, e.g. ``gemini-2.0-flash-exp``. | |
| parameters (`GeminiChatModel.Parameters | None`, defaults to \ | |
| `None`): | |
| The Gemini API parameters. When ``None``, the default | |
| parameters will be used. | |
| stream (`bool`, defaults to `True`): | |
| Whether to enable streaming output. | |
| max_retries (`int`, defaults to `3`): | |
| The maximum number of retries for the Gemini API. | |
| retry_delay (`float`, defaults to `1.0`): | |
| Seconds to sleep between retry attempts. | |
| context_size (`int`, defaults to `1048576`): | |
| The model context size used for context compression. | |
| formatter (`FormatterBase | None`, defaults to `None`): | |
| The formatter that converts ``Msg`` objects to the format | |
| required by the Gemini API. When ``None``, a | |
| ``GeminiChatFormatter`` instance will be used. | |
| client_kwargs (`dict[str, Any] | None`, defaults to `None`): | |
| Extra keyword arguments forwarded to ``google.genai.Client`` | |
| (e.g. ``vertexai``, ``project``, ``location``, | |
| ``credentials``, ``http_options``). | |
| """ | |
| super().__init__( | |
| credential=credential, | |
| model=model, | |
| parameters=parameters or self.Parameters(), | |
| stream=stream, | |
| max_retries=max_retries, | |
| retry_delay=retry_delay, | |
| context_size=context_size, | |
| ) | |
| self.formatter = formatter or GeminiChatFormatter() | |
| self.client_kwargs = client_kwargs or {} | |
| def _get_retryable_exceptions(cls) -> tuple[Type[Exception], ...]: | |
| from google.genai import errors | |
| # APIError is the common parent of ClientError (4xx) and ServerError | |
| # (5xx). The google-genai SDK does not expose a dedicated rate-limit | |
| # subclass, and 429 surfaces as ClientError — so we accept the wider | |
| # set to make sure 429s are retried, at the cost of also retrying | |
| # rare 4xx like auth/bad-request a few times. | |
| return (errors.APIError,) | |
| async def _call_api( | |
| self, | |
| model_name: str, | |
| messages: list[Msg], | |
| tools: list[dict] | None = None, | |
| tool_choice: ToolChoice | None = None, | |
| **config_kwargs: Any, | |
| ) -> ChatResponse | AsyncGenerator[ChatResponse, None]: | |
| """Call the Gemini chat API. | |
| Args: | |
| model_name (`str`): | |
| The model name to use for this call. | |
| messages (`list`): | |
| A list of message objects for Gemini API. | |
| tools (`list[dict]`, default `None`): | |
| The tools JSON schemas. | |
| tool_choice (`ToolChoice | None`, optional): | |
| Controls which (if any) tool is called by the model. | |
| **config_kwargs (`Any`): | |
| Extra keyword arguments for the Gemini config. | |
| Returns: | |
| `ChatResponse | AsyncGenerator[ChatResponse, None]`: | |
| A ``ChatResponse`` when streaming is disabled, or an async | |
| generator of ``ChatResponse`` objects when streaming is | |
| enabled. | |
| """ | |
| from google import genai | |
| client = genai.Client( | |
| **{ | |
| "api_key": self.credential.api_key.get_secret_value(), | |
| **self.client_kwargs, | |
| }, | |
| ) | |
| formatted_messages = await self.formatter.format(messages) | |
| config: dict[str, Any] = {**config_kwargs} | |
| if self.parameters.max_tokens is not None: | |
| config["max_output_tokens"] = self.parameters.max_tokens | |
| if self.parameters.temperature is not None: | |
| config["temperature"] = self.parameters.temperature | |
| if self.parameters.top_p is not None: | |
| config["top_p"] = self.parameters.top_p | |
| if self.parameters.thinking_enable: | |
| config["thinking_config"] = { | |
| "include_thoughts": True, | |
| "thinking_budget": self.parameters.thinking_budget or 1024, | |
| } | |
| else: | |
| config["thinking_config"] = { | |
| "include_thoughts": False, | |
| "thinking_budget": 0, | |
| } | |
| fmt_tools, fmt_tool_choice = self._format_tools(tools, tool_choice) | |
| if fmt_tools is not None: | |
| config["tools"] = fmt_tools | |
| if fmt_tool_choice is not None: | |
| config["tool_config"] = fmt_tool_choice | |
| kwargs: dict[str, Any] = { | |
| "model": model_name, | |
| "contents": formatted_messages, | |
| "config": config, | |
| } | |
| start_datetime = datetime.now() | |
| if self.stream: | |
| response = await client.aio.models.generate_content_stream( | |
| **kwargs, | |
| ) | |
| # Pass client to the generator so the aiohttp session it owns | |
| # stays alive until the stream is fully consumed. | |
| return self._parse_stream_response( | |
| start_datetime, | |
| response, | |
| client, | |
| ) | |
| response = await client.aio.models.generate_content(**kwargs) | |
| return self._parse_completion_response(start_datetime, response) | |
| async def _parse_stream_response( | |
| self, | |
| start_datetime: datetime, | |
| response: Any, | |
| _client: Any = None, | |
| ) -> AsyncGenerator[ChatResponse, None]: | |
| """Parse the Gemini streaming response. | |
| Args: | |
| start_datetime (`datetime`): | |
| The start datetime of the response generation. | |
| response (`Any`): | |
| The Gemini async stream object from | |
| ``client.aio.models.generate_content_stream``. | |
| _client (`Any`, optional): | |
| The ``genai.Client`` that produced the stream. Held here so | |
| its aiohttp session is not garbage-collected before the | |
| stream is fully consumed. | |
| Yields: | |
| `ChatResponse`: | |
| Incremental ``ChatResponse`` objects with ``is_last=False`` | |
| followed by a final one with ``is_last=True``. | |
| """ | |
| response_id: str = _generate_id() | |
| text_id: str = _generate_id() | |
| thinking_id: str = _generate_id() | |
| async for chunk in response: | |
| # Capture response_id from the first chunk that carries it | |
| delta_res = ChatResponse( | |
| content=[], | |
| is_last=False, | |
| id=response_id, | |
| ) | |
| # Update the response ID if exists | |
| response_id = getattr(chunk, "response_id", None) or response_id | |
| delta_res.id = response_id | |
| if ( | |
| chunk.candidates | |
| and chunk.candidates[0].content | |
| and chunk.candidates[0].content.parts | |
| ): | |
| for part in chunk.candidates[0].content.parts: | |
| if part.text: | |
| # Thinking | |
| if part.thought: | |
| delta_res.append_thinking( | |
| block_id=thinking_id, | |
| thinking=part.text, | |
| ) | |
| # Text | |
| else: | |
| delta_res.append_text( | |
| block_id=text_id, | |
| text=part.text, | |
| ) | |
| # Tool call | |
| if part.function_call: | |
| if part.thought_signature: | |
| call_id = base64.b64encode( | |
| part.thought_signature, | |
| ).decode("utf-8") | |
| else: | |
| call_id = part.function_call.id or _generate_id() | |
| delta_res.append_tool_call( | |
| block_id=call_id, | |
| name=part.function_call.name, | |
| input=json.dumps( | |
| part.function_call.args or {}, | |
| ensure_ascii=False, | |
| ), | |
| ) | |
| usage = self._extract_usage(chunk.usage_metadata, start_datetime) | |
| if delta_res.content or usage: | |
| delta_res.usage = usage | |
| yield delta_res | |
| def _parse_completion_response( | |
| self, | |
| start_datetime: datetime, | |
| response: GenerateContentResponse, | |
| ) -> ChatResponse: | |
| """Parse the Gemini non-streaming response. | |
| Args: | |
| start_datetime (`datetime`): | |
| The start datetime of the response generation. | |
| response (`GenerateContentResponse`): | |
| The Gemini generate content response object. | |
| Returns: | |
| `ChatResponse`: | |
| A single ``ChatResponse`` with ``is_last=True``. | |
| """ | |
| content_blocks: List[TextBlock | ToolCallBlock | ThinkingBlock] = [] | |
| if ( | |
| response.candidates | |
| and response.candidates[0].content | |
| and response.candidates[0].content.parts | |
| ): | |
| for part in response.candidates[0].content.parts: | |
| if part.text: | |
| if part.thought: | |
| content_blocks.append( | |
| ThinkingBlock(thinking=part.text), | |
| ) | |
| else: | |
| content_blocks.append(TextBlock(text=part.text)) | |
| if part.function_call: | |
| keyword_args = part.function_call.args or {} | |
| if part.thought_signature: | |
| call_id = base64.b64encode( | |
| part.thought_signature, | |
| ).decode("utf-8") | |
| else: | |
| call_id = part.function_call.id or _generate_id() | |
| content_blocks.append( | |
| ToolCallBlock( | |
| id=call_id, | |
| name=part.function_call.name, | |
| input=json.dumps(keyword_args, ensure_ascii=False), | |
| ), | |
| ) | |
| usage = self._extract_usage(response.usage_metadata, start_datetime) | |
| return ChatResponse( | |
| id=getattr(response, "response_id", None) or _generate_id(), | |
| content=content_blocks, | |
| is_last=True, | |
| usage=usage, | |
| ) | |
| def _extract_usage( | |
| self, | |
| usage_metadata: Any, | |
| start_datetime: datetime, | |
| ) -> ChatUsage | None: | |
| """Extract ChatUsage from usage_metadata. | |
| Args: | |
| usage_metadata (`Any`): | |
| The usage metadata object from a Gemini response. | |
| start_datetime (`datetime`): | |
| The start datetime of the response generation. | |
| Returns: | |
| `ChatUsage | None`: | |
| A ``ChatUsage`` object if usage data is available, otherwise | |
| ``None``. | |
| """ | |
| if not usage_metadata: | |
| return None | |
| prompt_tokens = usage_metadata.prompt_token_count | |
| total_tokens = usage_metadata.total_token_count | |
| if prompt_tokens is not None and total_tokens is not None: | |
| return ChatUsage( | |
| input_tokens=prompt_tokens, | |
| output_tokens=total_tokens - prompt_tokens, | |
| time=(datetime.now() - start_datetime).total_seconds(), | |
| cache_input_tokens=getattr( | |
| usage_metadata, | |
| "cached_content_token_count", | |
| 0, | |
| ), | |
| ) | |
| return None | |
| def _format_tools( | |
| self, | |
| tools: list[dict] | None, | |
| tool_choice: ToolChoice | None, | |
| ) -> tuple[list[dict] | None, dict | None]: | |
| """Validate and format tools and tool_choice for Gemini. | |
| Converts tool schemas to Gemini's ``function_declarations`` | |
| format (resolving ``$ref`` references) and maps tool_choice | |
| modes to Gemini's ``function_calling_config``. When | |
| ``tool_choice.tools`` is specified the schemas list is filtered | |
| to only those tools. When ``tool_choice.mode`` is a specific | |
| tool name (str) the model is restricted via | |
| ``allowed_function_names`` without needing to filter the list, | |
| preserving prompt-cache efficiency. | |
| Args: | |
| tools (`list[dict] | None`, optional): | |
| The raw tool schemas. | |
| tool_choice (`ToolChoice | None`, optional): | |
| The tool choice configuration. | |
| Returns: | |
| `tuple[list[dict] | None, dict | None]`: | |
| A tuple of (formatted_tools, formatted_tool_config). | |
| """ | |
| if tool_choice and tools: | |
| self._validate_tool_choice(tool_choice, tools) | |
| if tool_choice.tools: | |
| allowed = set(tool_choice.tools) | |
| tools = [t for t in tools if t["function"]["name"] in allowed] | |
| fmt_tools = None | |
| if tools: | |
| function_declarations = [] | |
| for schema in tools: | |
| if "function" not in schema: | |
| continue | |
| func = schema["function"].copy() | |
| if "parameters" in func: | |
| func["parameters"] = _sanitize_schema_for_gemini( | |
| _flatten_json_schema(func["parameters"]), | |
| ) | |
| function_declarations.append(func) | |
| fmt_tools = [{"function_declarations": function_declarations}] | |
| if not tool_choice: | |
| return fmt_tools, None | |
| mode = tool_choice.mode | |
| if mode not in _TOOL_CHOICE_LITERAL_MODES: | |
| # mode is a specific tool name — restrict to that single tool | |
| fmt_choice: dict = { | |
| "function_calling_config": { | |
| "mode": "ANY", | |
| "allowed_function_names": [mode], | |
| }, | |
| } | |
| return fmt_tools, fmt_choice | |
| mode_mapping = { | |
| "auto": "AUTO", | |
| "none": "NONE", | |
| "required": "ANY", | |
| } | |
| fmt_choice = { | |
| "function_calling_config": {"mode": mode_mapping[mode]}, | |
| } | |
| return fmt_tools, fmt_choice | |