@@ -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.
145147const 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
160194function 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
190385function 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