fix(antigravity): unblock Gemini chats — schema sanitizer + UA bump (#7170)

* fix(antigravity): translate JSON Schema unions to Gemini nullable

Tool parameter schemas using JSON Schema 2020-12 unions like
"type": ["string", "null"] crash Gemini's function_declarations parser
with HTTP 400. Two existing tools trip this:

- core/framework/tasks/tools/colony_tools.py:52 (owner in _update_schema)
- core/framework/tasks/tools/session_tools.py:84-87 (same shape)

Add an adapter-level sanitizer that walks the schema tree and converts
union-with-null to OpenAPI 3.0 "nullable": true (which Gemini accepts).
Recurses into properties, items, additionalProperties, and the
anyOf/oneOf/allOf combinators. Source schemas remain valid JSON Schema
so OpenAI/Anthropic backends are unaffected.

* fix(antigravity): bump spoofed UA past Google's deprecation cutoff

Google has deprecated client version "Antigravity/1.18.3" — chats now
return "This version of Antigravity is no longer supported" instead of
a real model response.

Bump the spoofed User-Agent to "Antigravity/1.23.2" + "Electron/39.2.3"
(current desktop release) and add a comment that this needs periodic
re-bumping. A more durable fix (auto-detect from the installed app's
Info.plist) is a follow-up.

* fix(antigravity): fail loud on multi-type non-null Gemini schema unions

Per review on PR #7170: silently picking the first type from a union
like ["string", "integer", "null"] changes the contract for callers
that rely on the other types, and the failure is hard to diagnose at
the Gemini side. Replace the silent narrowing with a ValueError that
points the schema author at anyOf or a single type.

A repo scan finds no current Gemini-bound schemas using multi-type
non-null unions, so this branch is preventative for future authors.

* chore(antigravity): drop em dash from test docstring
This commit is contained in:
Hundao
2026-05-05 01:16:48 +08:00
committed by GitHub
parent 8cb0531959
commit 4a9b22719b
2 changed files with 134 additions and 7 deletions
+58 -4
View File
@@ -61,10 +61,12 @@ _IDE_STATE_DB_KEY = "antigravityUnifiedStateSync.oauthToken"
_BASE_HEADERS: dict[str, str] = {
# Mimic the Antigravity Electron app so the API accepts the request.
# Google deprecates older client versions over time, so this needs periodic
# bumping to match whatever the current Antigravity desktop release advertises.
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Antigravity/1.18.3 Chrome/138.0.7204.235 "
"Electron/37.3.1 Safari/537.36"
"(KHTML, like Gecko) Antigravity/1.23.2 Chrome/138.0.7204.235 "
"Electron/39.2.3 Safari/537.36"
),
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": '{"ideType":"ANTIGRAVITY","platform":"MACOS","pluginType":"GEMINI"}',
@@ -254,6 +256,56 @@ def _clean_tool_name(name: str) -> str:
return name[:64]
def _sanitize_schema_for_gemini(schema: Any) -> Any:
"""Convert JSON Schema 2020-12 features to the OpenAPI 3.0 dialect Gemini accepts.
Gemini's function_declarations parser rejects union ``"type": ["string", "null"]``.
Translate any such union to a single type plus ``"nullable": true``. Recurse into
``properties``, ``items``, and the ``anyOf``/``oneOf``/``allOf`` combinators.
"""
if isinstance(schema, list):
return [_sanitize_schema_for_gemini(s) for s in schema]
if not isinstance(schema, dict):
return schema
out = dict(schema)
t = out.get("type")
if isinstance(t, list):
non_null = [x for x in t if x != "null"]
has_null = "null" in t
if len(non_null) == 1:
out["type"] = non_null[0]
if has_null:
out["nullable"] = True
elif not non_null and has_null:
# Pure null type: fall back to string-nullable.
out["type"] = "string"
out["nullable"] = True
else:
# Multi-type non-null unions (e.g. ["string", "integer", "null"])
# have no faithful Gemini equivalent. Silently picking one type
# changes the contract for callers who rely on the others, so
# fail loud and let the schema author rewrite it as anyOf or
# narrow to a single type.
raise ValueError(
f"Unsupported Gemini schema union: {t!r}. "
"Gemini accepts a single primitive type plus optional 'nullable: true'. "
"Rewrite as anyOf or pick a single type."
)
if "properties" in out and isinstance(out["properties"], dict):
out["properties"] = {k: _sanitize_schema_for_gemini(v) for k, v in out["properties"].items()}
if "items" in out:
out["items"] = _sanitize_schema_for_gemini(out["items"])
if "additionalProperties" in out and isinstance(out["additionalProperties"], dict):
out["additionalProperties"] = _sanitize_schema_for_gemini(out["additionalProperties"])
for combinator in ("anyOf", "oneOf", "allOf"):
if combinator in out:
out[combinator] = _sanitize_schema_for_gemini(out[combinator])
return out
def _to_gemini_contents(
messages: list[dict[str, Any]],
thought_sigs: dict[str, str] | None = None,
@@ -555,11 +607,13 @@ class AntigravityProvider(LLMProvider):
{
"name": _clean_tool_name(t.name),
"description": t.description,
"parameters": t.parameters
"parameters": _sanitize_schema_for_gemini(
t.parameters
or {
"type": "object",
"properties": {},
},
}
),
}
for t in tools
]
+73
View File
@@ -0,0 +1,73 @@
"""Tests for the Antigravity Gemini schema sanitizer.
Run with:
cd core
pytest tests/test_antigravity_schema.py -v
"""
import pytest
from framework.llm.antigravity import _sanitize_schema_for_gemini
def test_union_with_null_becomes_nullable():
assert _sanitize_schema_for_gemini({"type": ["string", "null"]}) == {
"type": "string",
"nullable": True,
}
def test_plain_schema_passthrough():
assert _sanitize_schema_for_gemini({"type": "string"}) == {"type": "string"}
def test_recurses_into_properties():
out = _sanitize_schema_for_gemini(
{
"type": "object",
"properties": {
"id": {"type": "integer"},
"owner": {"type": ["string", "null"]},
},
"required": ["id"],
}
)
assert out["properties"]["id"] == {"type": "integer"}
assert out["properties"]["owner"] == {"type": "string", "nullable": True}
assert out["required"] == ["id"]
def test_recurses_into_items():
assert _sanitize_schema_for_gemini({"type": "array", "items": {"type": ["integer", "null"]}}) == {
"type": "array",
"items": {"type": "integer", "nullable": True},
}
def test_recurses_into_combinators():
assert _sanitize_schema_for_gemini({"anyOf": [{"type": ["string", "null"]}, {"type": "integer"}]}) == {
"anyOf": [{"type": "string", "nullable": True}, {"type": "integer"}]
}
def test_does_not_mutate_input():
schema = {"type": "object", "properties": {"x": {"type": ["string", "null"]}}}
snapshot = {"type": "object", "properties": {"x": {"type": ["string", "null"]}}}
_sanitize_schema_for_gemini(schema)
assert schema == snapshot
def test_pure_null_type_falls_back_to_string():
assert _sanitize_schema_for_gemini({"type": ["null"]}) == {
"type": "string",
"nullable": True,
}
def test_multi_type_non_null_union_raises():
"""Silently picking one type would change the contract; fail loud instead."""
with pytest.raises(ValueError, match="Unsupported Gemini schema union"):
_sanitize_schema_for_gemini({"type": ["string", "integer", "null"]})
with pytest.raises(ValueError, match="Unsupported Gemini schema union"):
_sanitize_schema_for_gemini({"type": ["string", "integer"]})