Files
frigate/frigate/genai/plugins/gemini.py
T

714 lines
29 KiB
Python
Raw Normal View History

2024-06-21 17:30:19 -04:00
"""Gemini Provider for Frigate AI."""
2026-05-22 08:52:01 -05:00
import base64
import binascii
2026-02-28 10:40:26 -07:00
import json
2024-09-24 15:04:35 -05:00
import logging
2026-07-06 09:28:02 -08:00
from collections.abc import AsyncGenerator
from typing import Any
2024-06-21 17:30:19 -04:00
2026-01-15 07:08:49 -07:00
from google import genai
from google.genai import errors, types
2026-03-25 09:28:48 -06:00
from google.genai.types import FunctionCallingConfigMode
2024-06-21 17:30:19 -04:00
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
2024-09-24 15:04:35 -05:00
logger = logging.getLogger(__name__)
2024-06-21 17:30:19 -04:00
2026-07-06 09:28:02 -08:00
def _decode_thought_signature(value: Any) -> bytes | None:
2026-05-22 08:52:01 -05:00
"""Decode a base64-encoded thought_signature carried across conversation turns."""
if not value:
return None
if isinstance(value, bytes):
return value
if isinstance(value, str):
try:
return base64.b64decode(value)
except (binascii.Error, ValueError):
return None
return None
2026-07-06 09:28:02 -08:00
def _encode_thought_signature(signature: bytes | None) -> str | None:
2026-05-22 08:52:01 -05:00
"""Encode bytes thought_signature as base64 so it survives JSON-friendly transport."""
if not signature:
return None
return base64.b64encode(signature).decode("ascii")
2026-07-13 05:33:15 -08:00
def _decode_data_uri(url: str) -> tuple[str, bytes] | None:
"""Decode a ``data:`` URI into ``(mime_type, bytes)``; None if not a data URI."""
if not isinstance(url, str) or not url.startswith("data:"):
return None
try:
header, b64 = url.split(",", 1)
mime = header[len("data:") :].split(";")[0] or "image/jpeg"
return mime, base64.b64decode(b64)
except (ValueError, binascii.Error):
return None
def _parts_from_content(content: Any) -> list[types.Part]:
"""Convert OpenAI-style message content (str or multimodal list) to Gemini parts."""
if isinstance(content, list):
parts: list[types.Part] = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text":
parts.append(types.Part.from_text(text=item.get("text") or ""))
elif item.get("type") == "image_url":
decoded = _decode_data_uri((item.get("image_url") or {}).get("url", ""))
if decoded is not None:
mime, data = decoded
parts.append(types.Part.from_bytes(data=data, mime_type=mime))
# Gemini rejects empty parts; fall back to a single space.
return parts or [types.Part.from_text(text=" ")]
return [types.Part.from_text(text=content or "")]
2026-07-06 09:28:02 -08:00
def _stats_from_gemini_usage(usage: Any) -> dict[str, Any] | None:
2026-05-14 11:05:38 -06:00
"""Build a stats dict from a Gemini usage_metadata object."""
prompt_tokens = getattr(usage, "prompt_token_count", None)
completion_tokens = getattr(usage, "candidates_token_count", None)
if prompt_tokens is None and completion_tokens is None:
return None
stats: dict[str, Any] = {}
if isinstance(prompt_tokens, int):
stats["prompt_tokens"] = prompt_tokens
if isinstance(completion_tokens, int):
stats["completion_tokens"] = completion_tokens
return stats or None
2024-06-21 17:30:19 -04:00
@register_genai_provider(GenAIProviderEnum.gemini)
class GeminiClient(GenAIClient):
"""Generative AI client for Frigate using Gemini."""
2026-01-15 07:08:49 -07:00
provider: genai.Client
2024-06-21 17:30:19 -04:00
2026-03-25 09:28:48 -06:00
def _init_provider(self) -> genai.Client:
2024-06-21 17:30:19 -04:00
"""Initialize the client."""
2026-01-15 07:08:49 -07:00
# Merge provider_options into HttpOptions
2026-03-25 09:28:48 -06:00
http_options_dict: dict[str, Any] = {
2026-01-15 07:08:49 -07:00
"timeout": int(self.timeout * 1000), # requires milliseconds
2026-01-18 07:36:27 -06:00
"retry_options": types.HttpRetryOptions(
attempts=3,
initial_delay=1.0,
max_delay=60.0,
exp_base=2.0,
jitter=1.0,
http_status_codes=[429, 500, 502, 503, 504],
),
2026-01-15 07:08:49 -07:00
}
if isinstance(self.genai_config.provider_options, dict):
http_options_dict.update(self.genai_config.provider_options)
return genai.Client(
api_key=self.genai_config.api_key,
http_options=types.HttpOptions(**http_options_dict),
)
2024-06-21 17:30:19 -04:00
2026-03-09 18:47:37 -06:00
def _send(
self,
prompt: str,
images: list[bytes],
2026-07-06 09:28:02 -08:00
response_format: dict | None = None,
2026-05-21 11:54:23 -06:00
enable_thinking: bool = False,
2026-07-06 09:28:02 -08:00
) -> str | None:
2024-06-21 17:30:19 -04:00
"""Submit a request to Gemini."""
2026-03-27 07:48:50 -06:00
contents = [prompt] + [
2026-01-15 07:08:49 -07:00
types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images
2026-03-27 07:48:50 -06:00
]
2024-06-21 17:30:19 -04:00
try:
2026-01-12 20:36:38 -07:00
# Merge runtime_options into generation_config if provided
2026-03-25 09:28:48 -06:00
generation_config_dict: dict[str, Any] = {"candidate_count": 1}
2026-01-12 20:36:38 -07:00
generation_config_dict.update(self.genai_config.runtime_options)
2026-03-09 18:47:37 -06:00
if response_format and response_format.get("type") == "json_schema":
generation_config_dict["response_mime_type"] = "application/json"
schema = response_format.get("json_schema", {}).get("schema")
if schema:
generation_config_dict["response_schema"] = schema
2026-01-15 07:08:49 -07:00
response = self.provider.models.generate_content(
model=self.genai_config.model,
2026-03-25 09:28:48 -06:00
contents=contents, # type: ignore[arg-type]
2026-01-15 07:08:49 -07:00
config=types.GenerateContentConfig(
**generation_config_dict,
2024-06-21 17:30:19 -04:00
),
)
2026-01-15 07:08:49 -07:00
except errors.APIError as e:
2024-09-24 15:04:35 -05:00
logger.warning("Gemini returned an error: %s", str(e))
2024-06-21 17:30:19 -04:00
return None
2026-01-15 07:08:49 -07:00
except Exception as e:
logger.warning("An unexpected error occurred with Gemini: %s", str(e))
return None
2024-06-21 17:30:19 -04:00
try:
2026-03-25 09:28:48 -06:00
if response.text is None:
return None
2024-06-21 17:30:19 -04:00
description = response.text.strip()
2026-01-15 07:08:49 -07:00
except (ValueError, AttributeError):
2024-06-21 17:30:19 -04:00
# No description was generated
return None
return description
2025-10-02 09:17:25 -06:00
2026-04-03 17:13:52 -06:00
def list_models(self) -> list[str]:
"""Return available model names from Gemini."""
try:
return sorted(m.name or "" for m in self.provider.models.list())
except Exception as e:
logger.warning("Failed to list Gemini models: %s", e)
return []
2025-10-02 09:17:25 -06:00
def get_context_size(self) -> int:
"""Get the context window size for Gemini."""
# Gemini Pro Vision has a 1M token context window
return 1000000
def chat_with_tools(
self,
messages: list[dict[str, Any]],
2026-07-06 09:28:02 -08:00
tools: list[dict[str, Any]] | None = None,
tool_choice: str | None = "auto",
enable_thinking: bool | None = None,
) -> dict[str, Any]:
2026-02-25 09:19:56 -07:00
"""
Send chat messages to Gemini with optional tool definitions.
2026-05-21 11:54:23 -06:00
Implements function calling/tool usage for Gemini models. Thinking is
configured at the model level for Gemini, so ``enable_thinking`` is
accepted for interface parity and ignored.
2026-02-25 09:19:56 -07:00
"""
try:
2026-02-25 09:19:56 -07:00
# Convert messages to Gemini format
2026-03-25 09:28:48 -06:00
gemini_messages: list[types.Content] = []
2026-02-25 09:19:56 -07:00
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
# Map roles to Gemini format
if role == "system":
# Gemini doesn't have system role, prepend to first user message
2026-03-25 09:28:48 -06:00
if (
gemini_messages
and gemini_messages[0].role == "user"
and gemini_messages[0].parts
):
2026-02-25 09:19:56 -07:00
gemini_messages[0].parts[
0
].text = f"{content}\n\n{gemini_messages[0].parts[0].text}"
else:
gemini_messages.append(
types.Content(
role="user", parts=[types.Part.from_text(text=content)]
)
)
elif role == "assistant":
2026-04-30 11:53:34 -06:00
parts: list[types.Part] = []
if content:
parts.append(types.Part.from_text(text=content))
for tc in msg.get("tool_calls") or []:
func = tc.get("function") or {}
tc_name = func.get("name") or ""
tc_args: Any = func.get("arguments")
if isinstance(tc_args, str):
try:
tc_args = json.loads(tc_args)
except (json.JSONDecodeError, TypeError):
tc_args = {}
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
2026-05-22 08:52:01 -05:00
fc_part = types.Part.from_function_call(
name=tc_name, args=tc_args
2026-04-30 11:53:34 -06:00
)
2026-05-22 08:52:01 -05:00
# Thinking-capable Gemini models require the original
# thought_signature to be echoed back on functionCall
# parts after a tool response, or the next request
# fails with INVALID_ARGUMENT.
sig = _decode_thought_signature(tc.get("thought_signature"))
if sig:
fc_part.thought_signature = sig
parts.append(fc_part)
2026-04-30 11:53:34 -06:00
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
2026-02-25 09:19:56 -07:00
elif role == "tool":
# Handle tool response
2026-04-29 17:20:19 -05:00
response_payload = (
content if isinstance(content, dict) else {"result": content}
)
2026-02-25 09:19:56 -07:00
gemini_messages.append(
types.Content(
role="function",
parts=[
2026-04-29 17:20:19 -05:00
types.Part.from_function_response(
2026-04-30 11:53:34 -06:00
name=msg.get("name")
or msg.get("tool_call_id")
or "",
2026-04-29 17:20:19 -05:00
response=response_payload,
)
2026-02-25 09:19:56 -07:00
],
)
)
else: # user
gemini_messages.append(
2026-07-13 05:33:15 -08:00
types.Content(role="user", parts=_parts_from_content(content))
2026-02-25 09:19:56 -07:00
)
# Convert tools to Gemini format
gemini_tools = None
if tools:
2026-02-25 09:19:56 -07:00
gemini_tools = []
for tool in tools:
if tool.get("type") == "function":
2026-02-25 09:19:56 -07:00
func = tool.get("function", {})
gemini_tools.append(
types.Tool(
function_declarations=[
types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters", {}),
)
]
)
)
2026-02-25 09:19:56 -07:00
# Configure tool choice
tool_config = None
if tool_choice:
if tool_choice == "none":
2026-02-25 09:19:56 -07:00
tool_config = types.ToolConfig(
2026-03-25 09:28:48 -06:00
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.NONE
)
2026-02-25 09:19:56 -07:00
)
elif tool_choice == "auto":
tool_config = types.ToolConfig(
2026-03-25 09:28:48 -06:00
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.AUTO
)
)
elif tool_choice == "required":
2026-02-25 09:19:56 -07:00
tool_config = types.ToolConfig(
2026-03-25 09:28:48 -06:00
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.ANY
)
)
2026-02-25 09:19:56 -07:00
# Build request config
2026-03-25 09:28:48 -06:00
config_params: dict[str, Any] = {"candidate_count": 1}
2026-02-25 09:19:56 -07:00
if gemini_tools:
config_params["tools"] = gemini_tools
if tool_config:
config_params["tool_config"] = tool_config
2026-05-19 12:03:57 -06:00
# Ask thinking-capable models (Gemini 2.5+) to include their
# reasoning trace as separate `thought` parts so we can surface
# it on the reasoning channel. Older models ignore this field.
config_params["thinking_config"] = types.ThinkingConfig(
include_thoughts=True
)
2026-02-25 09:19:56 -07:00
# Merge runtime_options
if isinstance(self.genai_config.runtime_options, dict):
config_params.update(self.genai_config.runtime_options)
response = self.provider.models.generate_content(
model=self.genai_config.model,
2026-03-25 09:28:48 -06:00
contents=gemini_messages, # type: ignore[arg-type]
2026-02-25 09:19:56 -07:00
config=types.GenerateContentConfig(**config_params),
)
2026-02-25 09:19:56 -07:00
# Check if response is valid
if not response or not response.candidates:
return {
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
2026-02-25 09:19:56 -07:00
"tool_calls": None,
"finish_reason": "error",
}
candidate = response.candidates[0]
content = None
2026-05-19 12:03:57 -06:00
reasoning_parts: list[str] = []
tool_calls = None
2026-05-19 12:03:57 -06:00
# Extract content, reasoning, and tool calls from response
2026-02-25 09:19:56 -07:00
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text:
2026-05-19 12:03:57 -06:00
if getattr(part, "thought", False):
reasoning_parts.append(part.text)
else:
content = part.text.strip()
2026-02-25 09:19:56 -07:00
elif part.function_call:
# Handle function call
if tool_calls is None:
tool_calls = []
try:
arguments = (
dict(part.function_call.args)
if part.function_call.args
else {}
)
except Exception:
arguments = {}
tool_calls.append(
{
2026-02-25 09:19:56 -07:00
"id": part.function_call.name or "",
"name": part.function_call.name or "",
"arguments": arguments,
2026-05-22 08:52:01 -05:00
"thought_signature": _encode_thought_signature(
getattr(part, "thought_signature", None)
),
}
)
2026-05-19 12:03:57 -06:00
reasoning = "".join(reasoning_parts).strip() or None
2026-02-25 09:19:56 -07:00
# Determine finish reason
finish_reason = "error"
2026-02-25 09:19:56 -07:00
if hasattr(candidate, "finish_reason") and candidate.finish_reason:
from google.genai.types import FinishReason
if candidate.finish_reason == FinishReason.STOP:
finish_reason = "stop"
elif candidate.finish_reason == FinishReason.MAX_TOKENS:
finish_reason = "length"
elif candidate.finish_reason in [
FinishReason.SAFETY,
FinishReason.RECITATION,
]:
finish_reason = "error"
elif tool_calls:
finish_reason = "tool_calls"
elif content:
finish_reason = "stop"
elif tool_calls:
finish_reason = "tool_calls"
elif content:
finish_reason = "stop"
return {
"content": content,
2026-05-19 12:03:57 -06:00
"reasoning": reasoning,
"tool_calls": tool_calls,
"finish_reason": finish_reason,
}
2026-02-25 09:19:56 -07:00
except errors.APIError as e:
logger.warning("Gemini API error during chat_with_tools: %s", str(e))
return {
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
except Exception as e:
2026-02-25 09:19:56 -07:00
logger.warning(
"Gemini returned an error during chat_with_tools: %s", str(e)
)
return {
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
"tool_calls": None,
"finish_reason": "error",
}
2026-02-28 10:40:26 -07:00
async def chat_with_tools_stream(
self,
messages: list[dict[str, Any]],
2026-07-06 09:28:02 -08:00
tools: list[dict[str, Any]] | None = None,
tool_choice: str | None = "auto",
enable_thinking: bool | None = None,
2026-03-25 09:28:48 -06:00
) -> AsyncGenerator[tuple[str, Any], None]:
2026-02-28 10:40:26 -07:00
"""
Stream chat with tools; yields content deltas then final message.
Implements streaming function calling/tool usage for Gemini models.
2026-05-21 14:38:38 -06:00
``enable_thinking`` is accepted for interface parity; Gemini configures
thinking at the model level, so it is ignored here.
2026-02-28 10:40:26 -07:00
"""
try:
# Convert messages to Gemini format
2026-03-25 09:28:48 -06:00
gemini_messages: list[types.Content] = []
2026-02-28 10:40:26 -07:00
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
# Map roles to Gemini format
if role == "system":
# Gemini doesn't have system role, prepend to first user message
2026-03-25 09:28:48 -06:00
if (
gemini_messages
and gemini_messages[0].role == "user"
and gemini_messages[0].parts
):
2026-02-28 10:40:26 -07:00
gemini_messages[0].parts[
0
].text = f"{content}\n\n{gemini_messages[0].parts[0].text}"
else:
gemini_messages.append(
types.Content(
role="user", parts=[types.Part.from_text(text=content)]
)
)
elif role == "assistant":
2026-04-30 11:53:34 -06:00
parts: list[types.Part] = []
if content:
parts.append(types.Part.from_text(text=content))
for tc in msg.get("tool_calls") or []:
func = tc.get("function") or {}
tc_name = func.get("name") or ""
tc_args: Any = func.get("arguments")
if isinstance(tc_args, str):
try:
tc_args = json.loads(tc_args)
except (json.JSONDecodeError, TypeError):
tc_args = {}
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
2026-05-22 08:52:01 -05:00
fc_part = types.Part.from_function_call(
name=tc_name, args=tc_args
2026-04-30 11:53:34 -06:00
)
2026-05-22 08:52:01 -05:00
# Thinking-capable Gemini models require the original
# thought_signature to be echoed back on functionCall
# parts after a tool response, or the next request
# fails with INVALID_ARGUMENT.
sig = _decode_thought_signature(tc.get("thought_signature"))
if sig:
fc_part.thought_signature = sig
parts.append(fc_part)
2026-04-30 11:53:34 -06:00
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
2026-02-28 10:40:26 -07:00
elif role == "tool":
# Handle tool response
2026-04-29 17:20:19 -05:00
response_payload = (
content if isinstance(content, dict) else {"result": content}
)
2026-02-28 10:40:26 -07:00
gemini_messages.append(
types.Content(
role="function",
parts=[
2026-04-29 17:20:19 -05:00
types.Part.from_function_response(
2026-04-30 11:53:34 -06:00
name=msg.get("name")
or msg.get("tool_call_id")
or "",
2026-04-29 17:20:19 -05:00
response=response_payload,
)
2026-02-28 10:40:26 -07:00
],
)
)
else: # user
gemini_messages.append(
2026-07-13 05:33:15 -08:00
types.Content(role="user", parts=_parts_from_content(content))
2026-02-28 10:40:26 -07:00
)
# Convert tools to Gemini format
gemini_tools = None
if tools:
gemini_tools = []
for tool in tools:
if tool.get("type") == "function":
func = tool.get("function", {})
gemini_tools.append(
types.Tool(
function_declarations=[
types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters", {}),
)
]
)
)
# Configure tool choice
tool_config = None
if tool_choice:
if tool_choice == "none":
tool_config = types.ToolConfig(
2026-03-25 09:28:48 -06:00
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.NONE
)
2026-02-28 10:40:26 -07:00
)
elif tool_choice == "auto":
tool_config = types.ToolConfig(
2026-03-25 09:28:48 -06:00
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.AUTO
)
2026-02-28 10:40:26 -07:00
)
elif tool_choice == "required":
tool_config = types.ToolConfig(
2026-03-25 09:28:48 -06:00
function_calling_config=types.FunctionCallingConfig(
mode=FunctionCallingConfigMode.ANY
)
2026-02-28 10:40:26 -07:00
)
# Build request config
2026-03-25 09:28:48 -06:00
config_params: dict[str, Any] = {"candidate_count": 1}
2026-02-28 10:40:26 -07:00
if gemini_tools:
config_params["tools"] = gemini_tools
if tool_config:
config_params["tool_config"] = tool_config
2026-05-19 12:03:57 -06:00
# Ask thinking-capable models to include their reasoning trace
# as separate `thought` parts (Gemini 2.5+; ignored elsewhere).
config_params["thinking_config"] = types.ThinkingConfig(
include_thoughts=True
)
2026-02-28 10:40:26 -07:00
# Merge runtime_options
if isinstance(self.genai_config.runtime_options, dict):
config_params.update(self.genai_config.runtime_options)
# Use streaming API
content_parts: list[str] = []
2026-05-19 12:03:57 -06:00
reasoning_parts: list[str] = []
2026-07-13 05:33:15 -08:00
tool_calls_accum: list[dict[str, Any]] = []
2026-02-28 10:40:26 -07:00
finish_reason = "stop"
2026-07-06 09:28:02 -08:00
usage_stats: dict[str, Any] | None = None
2026-02-28 10:40:26 -07:00
2026-03-19 10:39:24 -06:00
stream = await self.provider.aio.models.generate_content_stream(
2026-02-28 10:40:26 -07:00
model=self.genai_config.model,
2026-03-25 09:28:48 -06:00
contents=gemini_messages, # type: ignore[arg-type]
2026-02-28 10:40:26 -07:00
config=types.GenerateContentConfig(**config_params),
)
2026-03-19 10:39:24 -06:00
async for chunk in stream:
2026-05-14 11:05:38 -06:00
chunk_usage = getattr(chunk, "usage_metadata", None)
if chunk_usage is not None:
maybe_stats = _stats_from_gemini_usage(chunk_usage)
if maybe_stats is not None:
usage_stats = maybe_stats
2026-02-28 10:40:26 -07:00
if not chunk or not chunk.candidates:
continue
candidate = chunk.candidates[0]
# Check for finish reason
if hasattr(candidate, "finish_reason") and candidate.finish_reason:
from google.genai.types import FinishReason
if candidate.finish_reason == FinishReason.STOP:
finish_reason = "stop"
elif candidate.finish_reason == FinishReason.MAX_TOKENS:
finish_reason = "length"
elif candidate.finish_reason in [
FinishReason.SAFETY,
FinishReason.RECITATION,
]:
finish_reason = "error"
2026-05-19 12:03:57 -06:00
# Extract content, reasoning, and tool calls from chunk
2026-02-28 10:40:26 -07:00
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text:
2026-05-19 12:03:57 -06:00
if getattr(part, "thought", False):
reasoning_parts.append(part.text)
yield ("reasoning_delta", part.text)
else:
content_parts.append(part.text)
yield ("content_delta", part.text)
2026-02-28 10:40:26 -07:00
elif part.function_call:
2026-07-13 05:33:15 -08:00
# Gemini streams complete function calls (not partial
# argument deltas), so each part is a distinct tool
# call. Append rather than accumulate by name — the
# latter concatenated parallel/repeated calls into one
# invalid arguments string (e.g. `{...}{...}`).
2026-02-28 10:40:26 -07:00
try:
arguments = (
dict(part.function_call.args)
if part.function_call.args
else {}
)
except Exception:
arguments = {}
2026-07-13 05:33:15 -08:00
tool_calls_accum.append(
{
"id": part.function_call.name or "",
"name": part.function_call.name or "",
"arguments": arguments,
"thought_signature": getattr(
part, "thought_signature", None
),
2026-02-28 10:40:26 -07:00
}
2026-07-13 05:33:15 -08:00
)
2026-05-22 08:52:01 -05:00
2026-02-28 10:40:26 -07:00
# Build final message
full_content = "".join(content_parts).strip() or None
2026-05-19 12:03:57 -06:00
full_reasoning = "".join(reasoning_parts).strip() or None
2026-02-28 10:40:26 -07:00
# Convert tool calls to list format
tool_calls_list = None
2026-07-13 05:33:15 -08:00
if tool_calls_accum:
tool_calls_list = [
{
"id": tc["id"],
"name": tc["name"],
"arguments": tc["arguments"]
if isinstance(tc["arguments"], dict)
else {},
"thought_signature": _encode_thought_signature(
tc.get("thought_signature")
),
}
for tc in tool_calls_accum
]
2026-02-28 10:40:26 -07:00
finish_reason = "tool_calls"
2026-05-14 11:05:38 -06:00
if usage_stats is not None:
yield ("stats", usage_stats)
2026-02-28 10:40:26 -07:00
yield (
"message",
{
"content": full_content,
2026-05-19 12:03:57 -06:00
"reasoning": full_reasoning,
2026-02-28 10:40:26 -07:00
"tool_calls": tool_calls_list,
"finish_reason": finish_reason,
},
)
except errors.APIError as e:
logger.warning("Gemini API error during streaming: %s", str(e))
yield (
"message",
{
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
2026-02-28 10:40:26 -07:00
"tool_calls": None,
"finish_reason": "error",
},
)
except Exception as e:
logger.warning(
"Gemini returned an error during chat_with_tools_stream: %s", str(e)
)
yield (
"message",
{
"content": None,
2026-05-19 12:03:57 -06:00
"reasoning": None,
2026-02-28 10:40:26 -07:00
"tool_calls": None,
"finish_reason": "error",
},
)