fix(gemini): strip prefixItems from Gemini tool schemas (#12540)

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.
This commit is contained in:
Paco Cartones
2026-09-11 22:42:08 +02:00
committed by GitHub
parent 7ad5e1120e
commit b23b0ca68e
3 changed files with 148 additions and 0 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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<string, unknown> };
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<string, Record<string, unknown>>)
.ranges;
assert.equal(ranges.type, "array");
const inner = ranges.items as Record<string, unknown>;
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<string, Record<string, unknown>>)
.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<string, Record<string, unknown>>).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"));
});