Skip to content

Commit 8010a3a

Browse files
authored
fix(gemini): strip JSON-Schema keywords the tool API rejects (#4761)
1 parent 6227cae commit 8010a3a

2 files changed

Lines changed: 385 additions & 23 deletions

File tree

packages/runtime/src/providers/gemini-provider.ts

Lines changed: 218 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -138,23 +138,57 @@ interface GeminiVideoOperation {
138138
}
139139

140140
// Gemini's function-declaration schema is a strict subset of OpenAPI 3.0.
141-
// It rejects JSON-Schema-only fields like `additionalProperties`, `$schema`,
142-
// `$id`, `$ref`, `definitions`, `patternProperties`, etc. Any one of these
141+
// It rejects JSON-Schema-only fields like `const`, `additionalProperties`,
142+
// `$schema`, `$ref`, `definitions`, `patternProperties`, etc. Any one of these
143143
// anywhere in the tree causes a 400 that aborts the entire tool batch, so we
144-
// recursively strip them before sending.
144+
// recursively strip them before sending. Zod 4's `z.toJSONSchema` (draft
145+
// 2020-12) emits several of them — `const` for every `z.literal()`, `$ref` +
146+
// `$defs` for every reused schema — so tools defined in Zod hit this.
145147
const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([
146148
"additionalProperties",
147149
"$schema",
148150
"$id",
149151
"$ref",
152+
"$defs",
153+
"$comment",
150154
"definitions",
151155
"patternProperties",
152156
"propertyNames",
153157
"unevaluatedProperties",
158+
"unevaluatedItems",
154159
"dependentSchemas",
155160
"dependentRequired",
156161
"exclusiveMinimum",
157-
"exclusiveMaximum"
162+
"exclusiveMaximum",
163+
"const",
164+
"allOf",
165+
"oneOf",
166+
"not",
167+
"if",
168+
"then",
169+
"else",
170+
"prefixItems",
171+
"additionalItems",
172+
"contains",
173+
"minContains",
174+
"maxContains",
175+
"uniqueItems",
176+
"multipleOf",
177+
"examples",
178+
"readOnly",
179+
"writeOnly",
180+
"deprecated",
181+
"contentEncoding",
182+
"contentMediaType"
183+
]);
184+
185+
/** Keywords whose value is data, not a schema — never recurse into them. */
186+
const GEMINI_DATA_KEYS = new Set([
187+
"enum",
188+
"const",
189+
"default",
190+
"example",
191+
"examples"
158192
]);
159193

160194
function isArraySchemaType(type: unknown): boolean {
@@ -167,24 +201,185 @@ function isArraySchemaType(type: unknown): boolean {
167201
return false;
168202
}
169203

170-
function sanitizeGeminiSchema(value: unknown): unknown {
171-
if (Array.isArray(value)) {
172-
return value.map(sanitizeGeminiSchema);
204+
function isPlainObject(value: unknown): value is Record<string, unknown> {
205+
return !!value && typeof value === "object" && !Array.isArray(value);
206+
}
207+
208+
/** The JSON Schema type name for a primitive literal, if it has one. */
209+
function primitiveSchemaType(value: unknown): string | undefined {
210+
if (typeof value === "string") return "string";
211+
if (typeof value === "boolean") return "boolean";
212+
if (typeof value === "number" && Number.isFinite(value)) {
213+
return Number.isInteger(value) ? "integer" : "number";
214+
}
215+
return undefined;
216+
}
217+
218+
function resolveJsonPointer(
219+
root: unknown,
220+
pointer: string
221+
): { found: boolean; value: unknown } {
222+
if (pointer === "#" || pointer === "") return { found: true, value: root };
223+
if (!pointer.startsWith("#/")) return { found: false, value: undefined };
224+
let cursor: unknown = root;
225+
for (const rawSegment of pointer.slice(2).split("/")) {
226+
const segment = decodeURIComponent(rawSegment)
227+
.replace(/~1/g, "/")
228+
.replace(/~0/g, "~");
229+
if (Array.isArray(cursor)) {
230+
const index = Number(segment);
231+
if (!Number.isInteger(index) || index < 0 || index >= cursor.length) {
232+
return { found: false, value: undefined };
233+
}
234+
cursor = cursor[index];
235+
continue;
236+
}
237+
if (!isPlainObject(cursor) || !(segment in cursor)) {
238+
return { found: false, value: undefined };
239+
}
240+
cursor = cursor[segment];
241+
}
242+
return { found: true, value: cursor };
243+
}
244+
245+
/**
246+
* Inline local `$ref`s so dropping `$defs` doesn't leave empty schemas behind.
247+
* A ref that is cyclic or unresolvable degrades to a permissive object.
248+
*/
249+
function inlineGeminiRefs(
250+
node: unknown,
251+
root: unknown,
252+
seen: ReadonlySet<string>
253+
): unknown {
254+
if (Array.isArray(node)) {
255+
return node.map((item) => inlineGeminiRefs(item, root, seen));
256+
}
257+
if (!isPlainObject(node)) return node;
258+
259+
if (typeof node.$ref === "string") {
260+
const { $ref, ...rest } = node;
261+
if (seen.has($ref)) return { type: "object", ...rest };
262+
const { found, value } = resolveJsonPointer(root, $ref);
263+
if (!found || !isPlainObject(value)) return { type: "object", ...rest };
264+
const resolved = inlineGeminiRefs(
265+
value,
266+
root,
267+
new Set([...seen, $ref])
268+
) as Record<string, unknown>;
269+
const overrides = inlineGeminiRefs(rest, root, seen) as Record<
270+
string,
271+
unknown
272+
>;
273+
return { ...resolved, ...overrides };
274+
}
275+
276+
const out: Record<string, unknown> = {};
277+
for (const [key, value] of Object.entries(node)) {
278+
out[key] = GEMINI_DATA_KEYS.has(key)
279+
? value
280+
: inlineGeminiRefs(value, root, seen);
281+
}
282+
return out;
283+
}
284+
285+
/** Fold `allOf` members into the parent schema; parent keys win. */
286+
function mergeAllOf(
287+
out: Record<string, unknown>,
288+
members: unknown[]
289+
): Record<string, unknown> {
290+
for (const member of members) {
291+
const sanitized = sanitizeSchemaNode(member);
292+
if (!isPlainObject(sanitized)) continue;
293+
for (const [key, value] of Object.entries(sanitized)) {
294+
if (key === "properties" && isPlainObject(value)) {
295+
out.properties = { ...value, ...((out.properties as object) ?? {}) };
296+
continue;
297+
}
298+
if (key === "required" && Array.isArray(value)) {
299+
const existing = Array.isArray(out.required) ? out.required : [];
300+
out.required = [...new Set([...existing, ...value])];
301+
continue;
302+
}
303+
if (out[key] === undefined) out[key] = value;
304+
}
305+
}
306+
return out;
307+
}
308+
309+
function sanitizeSchemaNode(value: unknown): unknown {
310+
if (Array.isArray(value)) return value.map(sanitizeSchemaNode);
311+
if (!isPlainObject(value)) return value;
312+
313+
const out: Record<string, unknown> = {};
314+
for (const [key, nested] of Object.entries(value)) {
315+
if (GEMINI_UNSUPPORTED_SCHEMA_KEYS.has(key)) continue;
316+
if (key === "properties" && isPlainObject(nested)) {
317+
const properties: Record<string, unknown> = {};
318+
// Property *names* are data — a property called "const" must survive.
319+
for (const [name, sub] of Object.entries(nested)) {
320+
properties[name] = sanitizeSchemaNode(sub);
321+
}
322+
out.properties = properties;
323+
continue;
324+
}
325+
if (key === "items") {
326+
out.items = sanitizeSchemaNode(
327+
Array.isArray(nested) ? (nested[0] ?? { type: "string" }) : nested
328+
);
329+
continue;
330+
}
331+
if (key === "anyOf" && Array.isArray(nested)) {
332+
out.anyOf = nested.map(sanitizeSchemaNode);
333+
continue;
334+
}
335+
out[key] = GEMINI_DATA_KEYS.has(key) ? nested : sanitizeSchemaNode(nested);
336+
}
337+
338+
// `oneOf` means the same thing to a model as `anyOf`, which Gemini accepts.
339+
if (out.anyOf === undefined && Array.isArray(value.oneOf)) {
340+
out.anyOf = value.oneOf.map(sanitizeSchemaNode);
341+
}
342+
if (Array.isArray(value.allOf)) mergeAllOf(out, value.allOf);
343+
344+
// `const` is what Zod emits for a literal. A single-value `enum` says the
345+
// same thing in Gemini's dialect, but only for strings — its `enum` is a
346+
// list of strings — so other literals keep the constraint in the description.
347+
if (value.const !== undefined && out.enum === undefined) {
348+
const literalType = primitiveSchemaType(value.const);
349+
if (literalType === "string") {
350+
out.enum = [value.const];
351+
out.type ??= "string";
352+
} else if (literalType) {
353+
out.type ??= literalType;
354+
const hint = `Must be ${JSON.stringify(value.const)}.`;
355+
out.description =
356+
typeof out.description === "string" && out.description
357+
? `${out.description} ${hint}`
358+
: hint;
359+
}
360+
}
361+
362+
// Gemini's `type` is one string; JSON Schema allows a union. `["x","null"]`
363+
// is Zod's optional/nullable shape and maps onto `nullable`.
364+
if (Array.isArray(out.type)) {
365+
const named = out.type.filter(
366+
(t): t is string => typeof t === "string" && t.toLowerCase() !== "null"
367+
);
368+
if (named.length < out.type.length) out.nullable = true;
369+
if (named.length > 0) out.type = named[0];
370+
else delete out.type;
173371
}
174-
if (value && typeof value === "object") {
175-
const out: Record<string, unknown> = {};
176-
for (const [k, v] of Object.entries(value)) {
177-
if (GEMINI_UNSUPPORTED_SCHEMA_KEYS.has(k)) continue;
178-
out[k] = sanitizeGeminiSchema(v);
179-
}
180-
// Gemini rejects an array schema that omits `items` ("items: missing
181-
// field"). JSON Schema allows it, so backfill a permissive default.
182-
if (isArraySchemaType(out.type) && out.items === undefined) {
183-
out.items = { type: "string" };
184-
}
185-
return out;
372+
373+
// Gemini rejects an array schema that omits `items` ("items: missing
374+
// field"). JSON Schema allows it, so backfill a permissive default.
375+
if (isArraySchemaType(out.type) && out.items === undefined) {
376+
out.items = { type: "string" };
186377
}
187-
return value;
378+
return out;
379+
}
380+
381+
function sanitizeGeminiSchema(value: unknown): unknown {
382+
return sanitizeSchemaNode(inlineGeminiRefs(value, value, new Set()));
188383
}
189384

190385
function sanitizeToolName(name: string): string {
@@ -713,7 +908,9 @@ export class GeminiProvider extends BaseProvider {
713908
private applyTools(
714909
body: GeminiRequest,
715910
tools: ProviderTool[],
716-
geminiTools: Array<{ functionDeclarations: Array<Record<string, unknown>> }>,
911+
geminiTools: Array<{
912+
functionDeclarations: Array<Record<string, unknown>>;
913+
}>,
717914
nameMap: Map<string, string>,
718915
toolChoice?: string | "any"
719916
): void {

0 commit comments

Comments
 (0)