Files
hermes-agent/tests/agent/test_gemini_schema.py
teknium 743140cd82 feat(gemini): send full JSON Schema tool parameters via parametersJsonSchema
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.
2026-09-14 06:44:54 -07:00

241 lines
10 KiB
Python

"""Tests for agent.gemini_schema — OpenAI→Gemini tool parameter translation."""
import copy
from agent.gemini_schema import (
prepare_gemini_tool_parameters,
sanitize_gemini_schema,
sanitize_gemini_tool_parameters,
)
class TestSanitizeGeminiSchema:
def test_strips_unknown_top_level_keys(self):
"""$schema / additionalProperties etc. must not reach Gemini."""
schema = {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": False,
"properties": {"foo": {"type": "string"}},
}
cleaned = sanitize_gemini_schema(schema)
assert "$schema" not in cleaned
assert "additionalProperties" not in cleaned
assert cleaned["type"] == "object"
assert cleaned["properties"] == {"foo": {"type": "string"}}
def test_stringifies_integer_enum_to_satisfy_gemini(self):
"""Gemini rejects numeric enum metadata unless values are strings.
Regression for the Discord tool's ``auto_archive_duration``:
``{type: integer, enum: [60, 1440, 4320, 10080]}`` caused
Gemini HTTP 400 INVALID_ARGUMENT
"Invalid value ... (TYPE_STRING), 60" on every request that
shipped the full tool catalog to generativelanguage.googleapis.com.
"""
schema = {
"type": "integer",
"enum": [60, 1440, 4320, 10080],
"description": "Minutes (60, 1440, 4320, 10080).",
}
cleaned = sanitize_gemini_schema(schema)
assert cleaned["type"] == "integer"
assert cleaned["enum"] == ["60", "1440", "4320", "10080"]
# Description remains useful model guidance.
assert cleaned["description"].startswith("Minutes")
def test_stringifies_nested_integer_enum_inside_properties(self):
"""The fix must apply recursively — the Discord case is nested."""
schema = {
"type": "object",
"properties": {
"auto_archive_duration": {
"type": "integer",
"enum": [60, 1440, 4320, 10080],
"description": "Thread archive duration in minutes.",
},
"status": {
"type": "string",
"enum": ["active", "archived"],
},
},
}
cleaned = sanitize_gemini_schema(schema)
props = cleaned["properties"]
# Integer enum is retained as Gemini-compatible string metadata...
assert props["auto_archive_duration"]["type"] == "integer"
assert props["auto_archive_duration"]["enum"] == ["60", "1440", "4320", "10080"]
# ...but the sibling string enum is preserved.
assert props["status"]["enum"] == ["active", "archived"]
def test_non_dict_input_returns_empty(self):
assert sanitize_gemini_schema(None) == {}
assert sanitize_gemini_schema("not a schema") == {}
assert sanitize_gemini_schema([1, 2, 3]) == {}
class TestRequiredPropertyPruning:
"""Gemini rejects ``required`` names missing from the node's ``properties``.
Regression for the Kilo-Org/kilocode#11955 bug class: MCP servers (e.g.
the GitHub remote MCP) emit array item schemas whose ``required`` lists
reference properties that don't exist in the same node — Google fails the
entire GenerateContentRequest with HTTP 400 "property is not defined".
"""
def test_prunes_inside_array_items(self):
"""The exact shape from the GitHub MCP report — nested in items."""
schema = {
"type": "object",
"properties": {
"issue_fields": {
"type": "array",
"items": {
"type": "object",
"required": ["field_id", "value"],
},
},
},
"required": ["issue_fields"],
}
cleaned = sanitize_gemini_schema(schema)
items = cleaned["properties"]["issue_fields"]["items"]
assert "required" not in items
# Top-level required is valid and survives.
assert cleaned["required"] == ["issue_fields"]
def test_valid_required_untouched(self):
schema = {
"type": "object",
"properties": {"a": {"type": "string"}, "b": {"type": "integer"}},
"required": ["a", "b"],
}
cleaned = sanitize_gemini_schema(schema)
assert cleaned["required"] == ["a", "b"]
def test_prunes_inside_anyof_branches(self):
schema = {
"anyOf": [
{
"type": "object",
"properties": {"x": {"type": "string"}},
"required": ["x", "ghost"],
},
{"type": "object", "required": ["orphan"]},
]
}
cleaned = sanitize_gemini_schema(schema)
assert cleaned["anyOf"][0]["required"] == ["x"]
assert "required" not in cleaned["anyOf"][1]
class TestSanitizeGeminiToolParameters:
def test_empty_parameters_return_valid_object_schema(self):
"""Gemini requires ``parameters`` to be a valid object schema."""
cleaned = sanitize_gemini_tool_parameters({})
assert cleaned == {"type": "object", "properties": {}}
def test_discord_create_thread_parameters_no_longer_trip_gemini(self):
"""End-to-end regression: the exact shape that was rejected in prod."""
params = {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["create_thread"]},
"auto_archive_duration": {
"type": "integer",
"enum": [60, 1440, 4320, 10080],
"description": "Thread archive duration in minutes "
"(create_thread, default 1440).",
},
},
"required": ["action"],
}
cleaned = sanitize_gemini_tool_parameters(params)
aad = cleaned["properties"]["auto_archive_duration"]
# The field that triggered the Gemini 400 is now string metadata.
assert aad["enum"] == ["60", "1440", "4320", "10080"]
# Type + description survive so the model still knows what to send.
assert aad["type"] == "integer"
assert "1440" in aad["description"]
# And the string-enum sibling is untouched.
assert cleaned["properties"]["action"]["enum"] == ["create_thread"]
# ── parametersJsonSchema (full JSON Schema) path ─────────────────────────────
class TestPrepareGeminiToolParameters:
def test_full_json_schema_survives_and_input_is_not_mutated(self):
params = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"globs": {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
"bare_array": {"type": "array"},
"opts": {"type": "object", "additionalProperties": {"type": "string"}},
},
"required": ["globs"],
}
original = copy.deepcopy(params)
out = prepare_gemini_tool_parameters(params)
assert params == original
assert "$schema" not in out
assert out["properties"]["globs"]["anyOf"][1] == {"type": "array", "items": {"type": "string"}}
assert out["properties"]["bare_array"] == {"type": "array"}
assert out["properties"]["opts"]["additionalProperties"] == {"type": "string"}
assert prepare_gemini_tool_parameters({}) == {"type": "object", "properties": {}}
def test_local_refs_inlined_with_sibling_override_and_defs_dropped(self):
params = {
"type": "object",
"properties": {"user": {"$ref": "#/$defs/User", "description": "who"}},
"$defs": {"User": {"type": "object", "description": "generic", "properties": {
"tags": {"type": "array", "items": {"$ref": "#/$defs/Tag"}}}},
"Tag": {"type": "string"}},
}
out = prepare_gemini_tool_parameters(params)
assert "$defs" not in out
user = out["properties"]["user"]
assert user["description"] == "who"
assert user["properties"]["tags"]["items"] == {"type": "string"}
def test_unresolvable_or_circular_ref_passes_schema_through_untouched(self):
for defs in ({"Loop": {"type": "object", "properties": {"next": {"$ref": "#/$defs/Loop"}}}}, {}):
params = {"type": "object", "properties": {"p": {"$ref": "#/$defs/Loop"}}, "$defs": defs}
out = prepare_gemini_tool_parameters(params)
assert out["properties"]["p"] == {"$ref": "#/$defs/Loop"}
assert out["$defs"] == defs
class TestAdapterWireShape:
_TOOLS = [{"type": "function", "function": {"name": "t", "parameters": {
"type": "object", "properties": {"g": {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}}}}}]
def test_v1beta_sends_parameters_json_schema_other_versions_send_legacy_subset(self):
from agent.gemini_native_adapter import GeminiNativeClient
def decl(base_url):
client = GeminiNativeClient(api_key="k", base_url=base_url)
captured = {}
client._http = type("H", (), {"post": lambda self, url, json, headers, timeout: captured.update(json) or
type("R", (), {"status_code": 200, "json": lambda self: {"candidates": []}})()})()
client._create_chat_completion(model="gemini-2.5-flash", messages=[{"role": "user", "content": "hi"}], tools=self._TOOLS)
return captured["tools"][0]["functionDeclarations"][0]
beta = decl("https://generativelanguage.googleapis.com/v1beta")
assert "parameters" not in beta
assert beta["parametersJsonSchema"]["properties"]["g"]["anyOf"][1]["items"] == {"type": "string"}
v1 = decl("https://generativelanguage.googleapis.com/v1")
assert "parametersJsonSchema" not in v1
assert v1["parameters"]["properties"]["g"]["anyOf"] # legacy translator still applied