From b23b0ca68ec22e7f813fa92740e30eee199fa0d9 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:42:08 +0200 Subject: [PATCH] fix(gemini): strip prefixItems from Gemini tool schemas (#12540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High-impact and precisely diagnosed: `prefixItems` missing from the strip-list rejected every tool-bearing Claude Code request routed to Gemini before generation, because Claude Code's built-in tools describe line ranges as tuples. All three tool shapes going through the same cleaner is what makes the one-key fix sufficient. --- Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the rest of this batch — zero conflicts between the 19 PRs. - `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, all within the frozen baseline); `check:changelog-integrity` OK - complexity 2802 / baseline 3218 and cognitive-complexity 1267 / baseline 1437 — both under baseline - 226 of 228 focused assertions green across the batch's 23 test files. The 2 remaining belong to #12551, which is held separately. Two batch-owned defects were found and fixed in flight, both pure base drift: `173_xp_action_counts.sql` collided with `173_call_logs_video_content_removed.sql` (renumbered to 176 on #12651 — it aborted every DB open, which is what 53 of the first run's failures were), and the feature-flag catalog was missing the `SERVER_OWNED_TOOL_LOOP_ENABLED` row the base gained after #12552 was written. ⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, which this batch does not touch). Thanks @pacocartones — the `file:line` citations and the explicit out-of-scope notes on every one of these made a 19-PR batch reviewable in one pass. --- .../12540-gemini-strip-prefixitems-nested.md | 1 + open-sse/translator/helpers/geminiHelper.ts | 6 + tests/unit/12509-gemini-prefixitems.test.ts | 141 ++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 changelog.d/fixes/12540-gemini-strip-prefixitems-nested.md create mode 100644 tests/unit/12509-gemini-prefixitems.test.ts diff --git a/changelog.d/fixes/12540-gemini-strip-prefixitems-nested.md b/changelog.d/fixes/12540-gemini-strip-prefixitems-nested.md new file mode 100644 index 0000000000..d1b24b21ab --- /dev/null +++ b/changelog.d/fixes/12540-gemini-strip-prefixitems-nested.md @@ -0,0 +1 @@ +- **fix(gemini):** strip the JSON-Schema-2020-12 `prefixItems` keyword from Gemini tool schemas at every nesting level, so Claude Code tool definitions no longer fail with `400 Unknown name "prefixItems"` on Gemini models (#12540 — thanks @pacocartones) diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 95fea6dcea..fefa882c35 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -63,6 +63,12 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([ // it, rejecting the whole request with "Unknown name \"uniqueItems\"". // Upstream 9router already strips it alongside `contains` for the same error. "uniqueItems", + // #12509: JSON-Schema-2020-12 tuple keyword. Claude Code's built-in tools + // describe `[start_line, end_line]` ranges with it (nested under `items`), + // and Gemini's schema parser rejects the whole tool list with + // "Unknown name \"prefixItems\" ... Cannot find field". ensureArrayItems + // below still guarantees an `items` schema for the tuple-typed array. + "prefixItems", // Complex schema keywords (handled by flattenAnyOfOneOf/mergeAllOf) "anyOf", "oneOf", diff --git a/tests/unit/12509-gemini-prefixitems.test.ts b/tests/unit/12509-gemini-prefixitems.test.ts new file mode 100644 index 0000000000..43ea7ab015 --- /dev/null +++ b/tests/unit/12509-gemini-prefixitems.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildGeminiTools } from "../../open-sse/translator/helpers/geminiToolsSanitizer.ts"; +import { GEMINI_UNSUPPORTED_SCHEMA_KEYS } from "../../open-sse/translator/helpers/geminiHelper.ts"; + +// Issue #12509: Gemini rejects the JSON-Schema-2020-12 tuple keyword `prefixItems` in +// function_declarations parameter schemas with HTTP 400 +// `Unknown name "prefixItems" at 'tools[0].function_declarations[1].parameters.properties[5] +// .value.properties[0].value.items': Cannot find field.` — the same class of error already +// fixed for `uniqueItems` (#9617), `multipleOf`, `strict` and `encrypted` in +// GEMINI_UNSUPPORTED_SCHEMA_KEYS (open-sse/translator/helpers/geminiHelper.ts). + +type GeminiFunctionDeclaration = { name: string; parameters: Record }; + +function declarationsOf(tools: unknown[]): GeminiFunctionDeclaration[] { + const geminiTools = buildGeminiTools(tools) as Array<{ + functionDeclarations?: GeminiFunctionDeclaration[]; + }> | null; + assert.ok(geminiTools, "expected buildGeminiTools to return a tools array"); + return geminiTools.flatMap((tool) => tool.functionDeclarations ?? []); +} + +function assertNoPrefixItems(tools: unknown[]): GeminiFunctionDeclaration[] { + const declarations = declarationsOf(tools); + const serialized = JSON.stringify(declarations); + assert.equal( + serialized.includes("prefixItems"), + false, + `prefixItems leaked into the Gemini payload (would trigger upstream 400 "Unknown name \\"prefixItems\\""): ${serialized}` + ); + return declarations; +} + +// The reporter's shape: a tuple nested under `items` — an array of `[start_line, end_line]` +// ranges, i.e. `properties.ranges.items.prefixItems`. +const nestedTupleParameters = { + type: "object", + properties: { + file_path: { type: "string" }, + ranges: { + type: "array", + description: "Line ranges to read", + items: { + type: "array", + prefixItems: [{ type: "integer" }, { type: "integer" }], + items: false, + minItems: 2, + maxItems: 2, + }, + }, + }, + required: ["file_path", "ranges"], +}; + +test("buildGeminiTools strips prefixItems nested under items (OpenAI tool shape, issue #12509)", () => { + const [declaration] = assertNoPrefixItems([ + { + type: "function", + function: { + name: "read_ranges", + description: "tuple-typed array parameter nested under items", + parameters: nestedTupleParameters, + }, + }, + ]); + + const ranges = (declaration.parameters.properties as Record>) + .ranges; + assert.equal(ranges.type, "array"); + const inner = ranges.items as Record; + assert.equal(inner.type, "array"); + assert.ok(inner.items && typeof inner.items === "object", "inner array keeps an items schema"); +}); + +test("buildGeminiTools strips prefixItems from a Claude input_schema (issue #12509)", () => { + const [declaration] = assertNoPrefixItems([ + { + name: "read_ranges", + description: "Claude Messages tool shape", + input_schema: nestedTupleParameters, + }, + ]); + assert.equal(declaration.name, "read_ranges"); +}); + +test("buildGeminiTools strips a top-level prefixItems tuple and keeps a usable items schema (issue #12509)", () => { + const [declaration] = assertNoPrefixItems([ + { + type: "function", + function: { + name: "read_range", + description: "single [start_line, end_line] tuple", + parameters: { + type: "object", + properties: { + range: { + type: "array", + prefixItems: [{ type: "integer" }, { type: "integer" }], + }, + }, + required: ["range"], + }, + }, + }, + ]); + + const range = (declaration.parameters.properties as Record>) + .range; + assert.equal(range.type, "array"); + assert.ok(range.items && typeof range.items === "object", "Gemini requires items on arrays"); +}); + +test("buildGeminiTools strips prefixItems that sits next to a regular items schema (issue #12509)", () => { + const [declaration] = assertNoPrefixItems([ + { + type: "function", + function: { + name: "pair", + description: "tuple keyword as a sibling of a regular items schema", + parameters: { + type: "object", + properties: { + pair: { + type: "array", + prefixItems: [{ type: "string" }], + items: { type: "string" }, + }, + }, + }, + }, + }, + ]); + + const pair = (declaration.parameters.properties as Record>).pair; + assert.deepEqual(pair.items, { type: "string" }); +}); + +test("prefixItems is registered in GEMINI_UNSUPPORTED_SCHEMA_KEYS (issue #12509)", () => { + assert.ok(GEMINI_UNSUPPORTED_SCHEMA_KEYS.has("prefixItems")); +});