Collapse the four Gemini chat/completions wrapper classes into SimpleNamespace shims, finish-reason/tool-choice/HTTP-error mappings become dicts, shared _usage_from_metadata/_assistant_message between sync and streaming translation, vertex credential loading split into _load_credentials/_needs_refresh. Gemini request/response/schema output byte-identical against merge-base.
87 lines
3.6 KiB
Python
87 lines
3.6 KiB
Python
"""Helpers for translating OpenAI-style tool schemas to Gemini's schema subset."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any, Dict
|
|
|
|
# Gemini's ``FunctionDeclaration.parameters`` accepts the ``Schema`` object,
|
|
# only a subset of OpenAPI 3.0 / JSON Schema; everything else is stripped.
|
|
_GEMINI_SCHEMA_ALLOWED_KEYS = {
|
|
"type", "format", "title", "description", "nullable", "enum",
|
|
"maxItems", "minItems", "properties", "required", "minProperties",
|
|
"maxProperties", "minLength", "maxLength", "pattern", "example",
|
|
"anyOf", "propertyOrdering", "default", "items", "minimum", "maximum",
|
|
}
|
|
|
|
|
|
def _stringify_enum_value(item: Any) -> Any:
|
|
"""Gemini-safe string for a scalar enum entry, or None to drop it."""
|
|
if isinstance(item, str):
|
|
return item
|
|
if isinstance(item, bool):
|
|
return "true" if item else "false"
|
|
if isinstance(item, (int, float)) and math.isfinite(item):
|
|
return str(item)
|
|
return None
|
|
|
|
|
|
def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
|
"""Return a Gemini-compatible copy of a tool parameter schema.
|
|
|
|
Keeps only the documented Gemini subset (drops e.g. ``$schema`` /
|
|
``additionalProperties``) and recursively sanitizes nested
|
|
``properties`` / ``items`` / ``anyOf`` definitions.
|
|
"""
|
|
if not isinstance(schema, dict):
|
|
return {}
|
|
|
|
cleaned: Dict[str, Any] = {}
|
|
for key, value in schema.items():
|
|
if key not in _GEMINI_SCHEMA_ALLOWED_KEYS:
|
|
continue
|
|
if key == "properties":
|
|
if isinstance(value, dict):
|
|
cleaned[key] = {name: sanitize_gemini_schema(sub) for name, sub in value.items() if isinstance(name, str)}
|
|
elif key == "items":
|
|
cleaned[key] = sanitize_gemini_schema(value)
|
|
elif key == "anyOf":
|
|
if isinstance(value, list):
|
|
cleaned[key] = [sanitize_gemini_schema(item) for item in value if isinstance(item, dict)]
|
|
else:
|
|
cleaned[key] = value
|
|
|
|
# Gemini requires every ``enum`` entry to be a string even for
|
|
# integer/number/boolean types; the declared type stays intact and Gemini
|
|
# still emits typed tool arguments at runtime.
|
|
enum_val = cleaned.get("enum")
|
|
if isinstance(enum_val, list) and cleaned.get("type") in {"integer", "number", "boolean"}:
|
|
# dict.fromkeys = order-preserving dedupe.
|
|
stringified = list(dict.fromkeys(v for v in map(_stringify_enum_value, enum_val) if v is not None))
|
|
if stringified:
|
|
cleaned["enum"] = stringified
|
|
else:
|
|
cleaned.pop("enum", None)
|
|
|
|
# Gemini validates ``required`` strictly against the same node's
|
|
# ``properties`` (HTTP 400 "property is not defined"), and one bad tool
|
|
# schema fails the ENTIRE request. MCP servers routinely emit ``required``
|
|
# without ``properties``, so keep only names that exist here; the tool
|
|
# handler still validates required fields at execution time.
|
|
required_val = cleaned.get("required")
|
|
if isinstance(required_val, list):
|
|
props_val = cleaned.get("properties")
|
|
prop_names = set(props_val.keys()) if isinstance(props_val, dict) else set()
|
|
valid_required = [name for name in required_val if isinstance(name, str) and name in prop_names]
|
|
if not valid_required:
|
|
cleaned.pop("required", None)
|
|
elif len(valid_required) != len(required_val):
|
|
cleaned["required"] = valid_required
|
|
|
|
return cleaned
|
|
|
|
|
|
def sanitize_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
|
"""Normalize tool parameters to a valid Gemini object schema."""
|
|
return sanitize_gemini_schema(parameters) or {"type": "object", "properties": {}}
|