Clean-room port of the approach in zed-industries/zed#63342. The native Gemini adapter previously down-translated every tool schema into the restricted FunctionDeclaration.parameters subset, which was lossy: anyOf unions without an outer type, bare arrays, $ref/$defs indirection and additionalProperties had to be stripped or repaired, and one unrepresentable construct could 400 the entire request (live repro: INVALID_ARGUMENT ...properties[bare_array].items: missing field). Google now accepts plain JSON Schema in parametersJsonSchema on all current models. The adapter sends full schemas through that field; the old subset translator is replaced by a light normalizer that deep-copies, strips root $schema, inlines same-document $refs (MCP pydantic / zod emit them; unresolvable or circular refs pass through untouched with the reason logged), and guarantees an object root. Live-verified against the real API: the union+bare-array+$ref schema that 400s through the legacy parameters field is accepted with 200 via parametersJsonSchema on gemini-3.7-flash and gemini-2.5-flash, and gemini-2.5-flash returns a correct functionCall against it.
199 lines
9.0 KiB
Python
199 lines
9.0 KiB
Python
"""Tool-schema preparation for Gemini's native API.
|
|
|
|
Two wire shapes: ``parametersJsonSchema`` (plain JSON Schema, v1beta only) gets a light
|
|
normalizer (``prepare_gemini_tool_parameters``); the legacy ``parameters`` field accepts
|
|
only the OpenAPI ``Schema`` subset and keeps the lossy translator
|
|
(``sanitize_gemini_tool_parameters``) for API versions without the JSON Schema field.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import logging
|
|
import math
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from tools.schema_sanitizer import _normalize_type_array
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Gemini's ``FunctionDeclaration.parameters`` accepts only a subset of OpenAPI 3.0 /
|
|
# JSON Schema (the ``Schema`` object); 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",
|
|
}
|
|
|
|
|
|
_GEMINI_STRUCTURAL_KEYS = {
|
|
"array": {"items", "minItems", "maxItems"},
|
|
"object": {"properties", "required", "minProperties", "maxProperties", "propertyOrdering"},
|
|
}
|
|
|
|
|
|
def _stringify_enum_value(item: Any) -> Any:
|
|
"""Gemini-safe string for a scalar enum entry, or None to drop it."""
|
|
if isinstance(item, bool):
|
|
return "true" if item else "false"
|
|
if isinstance(item, (int, float)) and math.isfinite(item):
|
|
return str(item)
|
|
return item if isinstance(item, str) else None
|
|
|
|
|
|
def _normalize_gemini_type_array(type_array: list, cleaned: Dict[str, Any]) -> None:
|
|
"""Keep union alternatives and their branch-local structural constraints."""
|
|
derived: Dict[str, Any] = {}
|
|
_normalize_type_array(type_array, derived)
|
|
if "anyOf" in derived:
|
|
constraints = {"anyOf": cleaned["anyOf"]} if "anyOf" in cleaned else {}
|
|
# Gemini requires items/properties on the typed branch itself, not
|
|
# its typeless parent. Keep required paired with those properties.
|
|
structural = {key: cleaned.pop(key) for keys in _GEMINI_STRUCTURAL_KEYS.values()
|
|
for key in keys if key in cleaned}
|
|
cleaned["anyOf"] = [
|
|
sanitize_gemini_schema({**branch, **constraints, **{
|
|
key: value for key, value in structural.items()
|
|
if key in _GEMINI_STRUCTURAL_KEYS.get(branch["type"], ())
|
|
}}) for branch in derived["anyOf"]
|
|
]
|
|
else:
|
|
cleaned["type"] = derived["type"]
|
|
if derived.get("nullable"):
|
|
# Derived from "null" in the array. Set AFTER the loop so it beats an input
|
|
# ``nullable: false`` regardless of which key the producer emitted first.
|
|
cleaned["nullable"] = True
|
|
|
|
|
|
def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
|
"""Gemini-compatible copy of a tool parameter schema: keeps only the documented subset
|
|
(drops e.g. ``$schema`` / ``additionalProperties``) and recursively sanitizes nested
|
|
``properties`` / ``items`` / ``anyOf``."""
|
|
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
|
|
|
|
type_array = cleaned.get("type")
|
|
if isinstance(type_array, list):
|
|
cleaned.pop("type")
|
|
_normalize_gemini_type_array(type_array, cleaned)
|
|
|
|
# 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. dict.fromkeys = ordered dedupe.
|
|
enum_val = cleaned.get("enum")
|
|
if isinstance(enum_val, list) and (
|
|
isinstance(type_array, list) or cleaned.get("type") in {"integer", "number", "boolean"}
|
|
):
|
|
if stringified := list(dict.fromkeys(v for v in map(_stringify_enum_value, enum_val) if v is not None)):
|
|
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) 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": {}}
|
|
|
|
|
|
# ── parametersJsonSchema (full JSON Schema) ─────────────────────────────────
|
|
#
|
|
# The legacy translator is lossy: anyOf unions without an outer type, bare arrays,
|
|
# $ref/$defs and additionalProperties had to be stripped or repaired, and one
|
|
# unrepresentable construct 400s the ENTIRE request. Through parametersJsonSchema the
|
|
# schema goes as-is; only same-document $refs are inlined (MCP pydantic / zod emit
|
|
# them and Google rejects reference indirection) and root ``$schema`` is dropped.
|
|
|
|
_EMPTY_OBJECT_SCHEMA: Dict[str, Any] = {"type": "object", "properties": {}}
|
|
# Real tool schemas hold a handful of refs; the cap stops circular pydantic models
|
|
# from expanding forever.
|
|
_MAX_REF_EXPANSIONS = 256
|
|
|
|
|
|
def _resolve_local_ref(root: Dict[str, Any], ref: str) -> Optional[Dict[str, Any]]:
|
|
"""Resolve a same-document JSON pointer (``#/$defs/Foo``) against *root*."""
|
|
if not isinstance(ref, str) or not ref.startswith("#/"):
|
|
return None
|
|
node: Any = root
|
|
for raw_part in ref[2:].split("/"):
|
|
part = raw_part.replace("~1", "/").replace("~0", "~")
|
|
if not isinstance(node, dict) or part not in node:
|
|
return None
|
|
node = node[part]
|
|
return node if isinstance(node, dict) else None
|
|
|
|
|
|
def _inline_refs(node: Any, root: Dict[str, Any], budget: List[int], stack: tuple = ()) -> Any:
|
|
"""Recursively inline same-document ``$ref`` nodes; ``ValueError`` on an unresolvable
|
|
or circular reference or an exhausted budget (the caller then keeps the original)."""
|
|
if isinstance(node, list):
|
|
return [_inline_refs(item, root, budget, stack) for item in node]
|
|
if not isinstance(node, dict):
|
|
return node
|
|
ref = node.get("$ref")
|
|
if not isinstance(ref, str):
|
|
return {key: _inline_refs(value, root, budget, stack) for key, value in node.items()}
|
|
if ref in stack:
|
|
raise ValueError(f"circular $ref {ref!r}")
|
|
budget[0] -= 1
|
|
if budget[0] < 0:
|
|
raise ValueError("$ref expansion budget exhausted")
|
|
target = _resolve_local_ref(root, ref)
|
|
if target is None:
|
|
raise ValueError(f"unresolvable $ref {ref!r}")
|
|
inlined = _inline_refs(target, root, budget, stack + (ref,))
|
|
# JSON Schema: siblings of $ref (description, default, ...) apply alongside the
|
|
# referenced schema and win over it.
|
|
siblings = {k: v for k, v in node.items() if k != "$ref"}
|
|
return {**inlined, **_inline_refs(siblings, root, budget, stack)} if siblings else inlined
|
|
|
|
|
|
def prepare_gemini_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
|
"""Full JSON Schema for ``parametersJsonSchema``: deep-copied, root ``$schema`` dropped,
|
|
same-document ``$ref`` inlined, object root guaranteed. A schema whose references
|
|
cannot all be resolved is sent untouched so the provider names the real problem."""
|
|
if not isinstance(parameters, dict) or not parameters:
|
|
return dict(_EMPTY_OBJECT_SCHEMA)
|
|
schema = copy.deepcopy(parameters)
|
|
schema.pop("$schema", None)
|
|
try:
|
|
schema = _inline_refs(schema, schema, [_MAX_REF_EXPANSIONS])
|
|
except ValueError as exc:
|
|
logger.debug("Gemini tool schema kept as-is ($ref inlining skipped): %s", exc)
|
|
return schema
|
|
schema.pop("$defs", None)
|
|
schema.pop("definitions", None)
|
|
if not schema:
|
|
return dict(_EMPTY_OBJECT_SCHEMA)
|
|
if schema.get("type") == "object" and "properties" not in schema:
|
|
schema["properties"] = {}
|
|
return schema
|