From 9976113505f6f1a5e1bb0b0b866aec77787fa643 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Sun, 20 Sep 2026 20:42:54 +0200 Subject: [PATCH] fix(gemini): strip tilde-prefixed Standard Schema keys from tool parameters Gemini/antigravity returned a hard 400 ("Unknown name \"~optional\" ... Cannot find field") the moment a tool's parameter schema contained a `~optional` key, taking down every Gemini-family model in a combo's fallback chain at once -- confirmed live: this exact error killed 100% of a production combo's usable fallbacks for ~18 hours (the two remaining candidates ahead of it were OpenCode free-tier models, permanently inaccessible via API regardless). Root cause: `~`-prefixed keys are the Standard Schema convention (Zod 4+, Valibot, ArkType) for internal/vendor metadata, namespaced with a leading `~` specifically so it can never collide with a real schema property name. A tool built from one of those libraries leaked a literal `~optional` key into a property's subschema. GEMINI_UNSUPPORTED_SCHEMA_KEYS already listed the plain `"optional"` string, but `removeUnsupportedKeywords`'s exact-match check doesn't catch the tilde-prefixed form, so it survived sanitization and Gemini's OpenAPI 3.0 schema subset rejected the whole request. Fix: strip any `~`-prefixed key at every schema level, the same way `x-` vendor extensions are already stripped -- this covers `~optional` and any other Standard Schema metadata key the same libraries may emit, rather than only patching this one literal key. Testing: new regression test confirmed failing on unpatched code and passing after the fix; full existing Gemini/schema-stripping test suite (495 tests) still green. --- open-sse/translator/helpers/geminiHelper.ts | 11 ++- ...ini-standard-schema-tilde-optional.test.ts | 80 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/unit/gemini-standard-schema-tilde-optional.test.ts diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index fefa882c3525..73ca48cfea88 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -534,7 +534,16 @@ function removeUnsupportedKeywords(obj: unknown, keywords: Set): void { const record = obj as JsonRecord; // Delete unsupported *constraint* keywords at the current schema level. for (const key of Object.keys(record)) { - if (keywords.has(key) || key.startsWith("x-")) { + // `~`-prefixed keys are the Standard Schema convention (Zod 4+, Valibot, + // ArkType) for internal/vendor metadata namespaced to avoid colliding + // with real schema property names -- e.g. a tool built from one of those + // libraries can leak a literal `~optional` key into a property's + // subschema. The plain `"optional"` entry in the denylist above doesn't + // match the tilde-prefixed form, and Gemini 400s the entire tool list on + // the unrecognized field ("Unknown name \"~optional\" ... Cannot find + // field"), taking down every model behind it. Strip the whole class the + // same way `x-` vendor extensions are already stripped below. + if (keywords.has(key) || key.startsWith("x-") || key.startsWith("~")) { delete record[key]; } } diff --git a/tests/unit/gemini-standard-schema-tilde-optional.test.ts b/tests/unit/gemini-standard-schema-tilde-optional.test.ts new file mode 100644 index 000000000000..b892796444cd --- /dev/null +++ b/tests/unit/gemini-standard-schema-tilde-optional.test.ts @@ -0,0 +1,80 @@ +/** + * antigravity/gemini returned [400] "Invalid JSON payload received. + * Unknown name \"~optional\" at 'tools[0].function_declarations[N].parameters + * .properties[M].value': Cannot find field." -- taking down every model behind + * it (this exact error killed the entire `default` combo fallback chain live). + * + * Root cause: `~`-prefixed keys are the Standard Schema convention (Zod 4+, + * Valibot, ArkType) for internal/vendor metadata, namespaced with a leading + * `~` specifically so it can never collide with a real user-defined schema + * property name. A tool built from one of those libraries leaked a literal + * `~optional` key into a property's subschema. `GEMINI_UNSUPPORTED_SCHEMA_KEYS` + * already listed the plain `"optional"` string, but that exact-match check + * doesn't catch the tilde-prefixed form, so `removeUnsupportedKeywords` left + * it in place and Gemini's OpenAPI 3.0 schema subset rejects the whole + * request on the unrecognized field. + * + * Fix: strip any `~`-prefixed key at every schema level, the same way `x-` + * vendor extensions are already stripped, instead of only matching literal + * keys in the denylist. This covers `~optional` and any other Standard + * Schema metadata key the same libraries may emit. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { cleanJSONSchemaForAntigravity } from "../../open-sse/translator/helpers/geminiHelper.ts"; +import { openaiToGeminiRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; + +test("~optional (and other tilde-prefixed Standard Schema keys) are stripped at all levels for antigravity/gemini schemas", () => { + const schema = { + type: "object", + "~standard": { version: 1, vendor: "zod" }, + properties: { + query: { type: "string" }, + value: { type: "string", "~optional": true, description: "the value" }, + }, + }; + + const cleaned = JSON.stringify(cleanJSONSchemaForAntigravity(schema)); + + assert.ok(!cleaned.includes("~optional"), "~optional must be removed"); + assert.ok(!cleaned.includes("~standard"), "~standard must be removed"); + assert.ok(cleaned.includes("query"), "unrelated properties must be preserved"); + assert.ok( + cleaned.includes("the value"), + "unrelated sibling keys on the same subschema must be preserved" + ); +}); + +test("OpenAI -> Gemini request strips ~optional from a tool parameter's subschema", () => { + const body = { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "set_config", + description: "set a config value", + parameters: { + type: "object", + properties: { + key: { type: "string" }, + value: { type: "string", "~optional": true }, + }, + }, + }, + }, + ], + }; + + const result = openaiToGeminiRequest("gemini-3.7-flash-low", body, false) as { + tools?: Array<{ functionDeclarations?: Array<{ parameters: unknown }> }>; + }; + + const parameters = result.tools?.[0]?.functionDeclarations?.[0]?.parameters; + assert.ok(parameters, "expected a translated function declaration"); + assert.ok( + !JSON.stringify(parameters).includes("~optional"), + "~optional must not reach the upstream request" + ); +});