Akshay66777's picture
AgentScope Gradio chat app — ZeroGPU-ready
9792ea7 verified
Raw
History Blame Contribute Delete
20.7 kB
# -*- 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 {}
@classmethod
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