@@ -405,6 +405,26 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
405405 out , _ = sjson .SetRawBytes (out , "generationConfig" , genConfig )
406406 }
407407
408+ // Handle JSON schema structured output (OpenAI text.format with type=json_schema)
409+ if textFormat := root .Get ("text.format" ); textFormat .Exists () {
410+ formatType := textFormat .Get ("type" ).String ()
411+ if formatType == "json_schema" {
412+ // Set response_mime_type to application/json
413+ if ! gjson .GetBytes (out , "generationConfig" ).Exists () {
414+ out , _ = sjson .SetRawBytes (out , "generationConfig" , []byte (`{}` ))
415+ }
416+ out , _ = sjson .SetBytes (out , "generationConfig.response_mime_type" , "application/json" )
417+
418+ // Set response_schema from the schema field, normalizing it for Gemini's
419+ // supported JSON Schema subset (removes unsupported keywords like additionalProperties,
420+ // pattern, minLength, multipleOf, etc.)
421+ if schema := textFormat .Get ("schema" ); schema .Exists () {
422+ schemaStr := normalizeJSONSchema (schema .Raw )
423+ out , _ = sjson .SetRawBytes (out , "generationConfig.response_schema" , []byte (schemaStr ))
424+ }
425+ }
426+ }
427+
408428 // Handle temperature if present
409429 if temperature := root .Get ("temperature" ); temperature .Exists () {
410430 if ! gjson .GetBytes (out , "generationConfig" ).Exists () {
@@ -459,3 +479,275 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
459479func openAIResponsesGeminiThoughtSignature (rawSignature string ) string {
460480 return sigcompat .GeminiReplaySignatureOrBypass (rawSignature , sigcompat .SignatureBlockKindGeminiModelPart )
461481}
482+
483+ // normalizeSchemaForGemini recursively normalizes an OpenAI JSON Schema to Gemini's supported subset.
484+ //
485+ // OpenAI and Gemini have different JSON Schema support:
486+ //
487+ // OpenAI supports:
488+ // - Types: string, number, boolean, integer, object, array, enum, anyOf
489+ // - String: pattern, format (date-time, time, date, duration, email, hostname, ipv4, ipv6, uuid)
490+ // - Number: multipleOf, maximum, exclusiveMaximum, minimum, exclusiveMinimum
491+ // - Array: minItems, maxItems
492+ // - Requires: additionalProperties: false, all fields in required
493+ // - Root must be object (not anyOf)
494+ // - Does NOT support: allOf, not, dependentRequired, dependentSchemas, if/then/else
495+ //
496+ // Gemini supports:
497+ // - Types: string, number, integer, boolean, object, array, null (via type array ["string", "null"])
498+ // - Object: properties, required, additionalProperties (boolean or schema)
499+ // - Array: items, prefixItems, minItems, maxItems
500+ // - String: enum, format (ONLY date-time, date, time)
501+ // - Number/Integer: enum, minimum, maximum
502+ // - Composition: anyOf
503+ // - Recursive: $ref
504+ // - Does NOT support: pattern, minLength, maxLength, multipleOf, exclusiveMinimum, exclusiveMaximum,
505+ // uniqueItems, contains, const, default, allOf, oneOf, not, if/then/else
506+ //
507+ // This function:
508+ // 1. Removes additionalProperties (Gemini rejects it)
509+ // 2. Keeps nullable type arrays ["string", "null"] (Gemini supports this)
510+ // 3. Removes unsupported keywords: pattern, minLength, maxLength, multipleOf, exclusiveMinimum,
511+ // exclusiveMaximum, uniqueItems, contains, const, default
512+ // 4. Filters format to only date-time, date, time
513+ // 5. Recursively processes all nested schemas
514+ func normalizeSchemaForGemini (schema interface {}) interface {} {
515+ switch v := schema .(type ) {
516+ case map [string ]interface {}:
517+ result := make (map [string ]interface {})
518+
519+ for key , val := range v {
520+ switch key {
521+ case "additionalProperties" :
522+ // Remove this - Gemini rejects it in some contexts
523+ continue
524+
525+ case "type" :
526+ // Handle nullable types: convert ["string", "null"] to "string" + "nullable": true
527+ if typeArray , ok := val .([]interface {}); ok {
528+ var nonNullTypes []string
529+ hasNull := false
530+ for _ , t := range typeArray {
531+ if typeStr , ok := t .(string ); ok {
532+ if typeStr == "null" {
533+ hasNull = true
534+ } else {
535+ nonNullTypes = append (nonNullTypes , typeStr )
536+ }
537+ }
538+ }
539+ // If only one non-null type remains, use it as string
540+ if len (nonNullTypes ) == 1 {
541+ result ["type" ] = nonNullTypes [0 ]
542+ if hasNull {
543+ result ["nullable" ] = true
544+ }
545+ } else if len (nonNullTypes ) > 1 {
546+ // Multiple non-null types - use anyOf
547+ var anyOfSchemas []map [string ]interface {}
548+ for _ , t := range nonNullTypes {
549+ schema := map [string ]interface {}{"type" : t }
550+ anyOfSchemas = append (anyOfSchemas , schema )
551+ }
552+ result ["anyOf" ] = anyOfSchemas
553+ if hasNull {
554+ result ["nullable" ] = true
555+ }
556+ } else if hasNull {
557+ // Only null type
558+ result ["type" ] = "null"
559+ }
560+ } else {
561+ // Keep single type as-is
562+ result [key ] = val
563+ }
564+
565+ case "properties" :
566+ // Recursively normalize each property
567+ if propsMap , ok := val .(map [string ]interface {}); ok {
568+ normalizedProps := make (map [string ]interface {})
569+ for propName , propSchema := range propsMap {
570+ normalizedProps [propName ] = normalizeSchemaForGemini (propSchema )
571+ }
572+ result [key ] = normalizedProps
573+ }
574+
575+ case "items" :
576+ // Recursively normalize items schema
577+ result [key ] = normalizeSchemaForGemini (val )
578+
579+ case "anyOf" :
580+ // Recursively normalize each anyOf option
581+ if anyOfArray , ok := val .([]interface {}); ok {
582+ normalized := make ([]interface {}, len (anyOfArray ))
583+ for i , subSchema := range anyOfArray {
584+ normalized [i ] = normalizeSchemaForGemini (subSchema )
585+ }
586+ result [key ] = normalized
587+ }
588+
589+ case "allOf" :
590+ // Merge all schemas in allOf into a single schema
591+ if allOfArray , ok := val .([]interface {}); ok {
592+ mergedSchema := mergeAllOf (allOfArray )
593+ normalized := normalizeSchemaForGemini (mergedSchema )
594+ if normalizedMap , ok := normalized .(map [string ]interface {}); ok {
595+ for k , v := range normalizedMap {
596+ result [k ] = v
597+ }
598+ }
599+ }
600+
601+ case "oneOf" :
602+ // Convert oneOf to anyOf
603+ if oneOfArray , ok := val .([]interface {}); ok {
604+ normalized := make ([]interface {}, len (oneOfArray ))
605+ for i , subSchema := range oneOfArray {
606+ normalized [i ] = normalizeSchemaForGemini (subSchema )
607+ }
608+ result ["anyOf" ] = normalized
609+ }
610+
611+ case "prefixItems" :
612+ // Recursively normalize prefix items
613+ if itemsArray , ok := val .([]interface {}); ok {
614+ normalized := make ([]interface {}, len (itemsArray ))
615+ for i , item := range itemsArray {
616+ normalized [i ] = normalizeSchemaForGemini (item )
617+ }
618+ result [key ] = normalized
619+ }
620+
621+ case "format" :
622+ // Only preserve supported formats: date-time, date, time
623+ if formatStr , ok := val .(string ); ok {
624+ if formatStr == "date-time" || formatStr == "date" || formatStr == "time" {
625+ result [key ] = val
626+ }
627+ // Drop other formats (email, hostname, ipv4, ipv6, uuid, duration)
628+ }
629+
630+ case "required" , "enum" , "title" , "description" , "minimum" , "maximum" ,
631+ "minItems" , "maxItems" :
632+ // These are supported by Gemini, preserve as-is
633+ result [key ] = val
634+
635+ case "$ref" :
636+ // Gemini supports $ref for recursive schemas
637+ result [key ] = val
638+
639+ case "$defs" , "definitions" :
640+ // Preserve definition for recursive schemas
641+ if defsMap , ok := val .(map [string ]interface {}); ok {
642+ normalized := make (map [string ]interface {})
643+ for k , def := range defsMap {
644+ normalized [k ] = normalizeSchemaForGemini (def )
645+ }
646+ result [key ] = normalized
647+ }
648+
649+ // Skip unsupported keywords:
650+ // pattern, minLength, maxLength (string constraints)
651+ // multipleOf, exclusiveMinimum, exclusiveMaximum (number constraints)
652+ // uniqueItems, contains (array constraints)
653+ // const, default (value constraints)
654+ // allOf, oneOf, not, if, then, else (composition)
655+ // dependentRequired, dependentSchemas (dependencies)
656+ }
657+ }
658+
659+ return result
660+
661+ case []interface {}:
662+ result := make ([]interface {}, len (v ))
663+ for i , item := range v {
664+ result [i ] = normalizeSchemaForGemini (item )
665+ }
666+ return result
667+
668+ default :
669+ return v
670+ }
671+ }
672+
673+ // mergeAllOf merges all schemas in an allOf array into a single schema
674+ func mergeAllOf (schemas []interface {}) map [string ]interface {} {
675+ merged := make (map [string ]interface {})
676+ properties := make (map [string ]interface {})
677+ var required []interface {}
678+
679+ for _ , schema := range schemas {
680+ if schemaMap , ok := schema .(map [string ]interface {}); ok {
681+ // Merge type
682+ if typ , exists := schemaMap ["type" ]; exists {
683+ merged ["type" ] = typ
684+ }
685+
686+ // Merge properties
687+ if props , exists := schemaMap ["properties" ]; exists {
688+ if propsMap , ok := props .(map [string ]interface {}); ok {
689+ for k , v := range propsMap {
690+ properties [k ] = v
691+ }
692+ }
693+ }
694+
695+ // Merge required fields
696+ if req , exists := schemaMap ["required" ]; exists {
697+ if reqArray , ok := req .([]interface {}); ok {
698+ required = append (required , reqArray ... )
699+ }
700+ }
701+ }
702+ }
703+
704+ // Build final merged schema
705+ if len (properties ) > 0 {
706+ merged ["properties" ] = properties
707+ }
708+ if len (required ) > 0 {
709+ // Deduplicate required fields
710+ seen := make (map [string ]bool )
711+ var uniqueRequired []interface {}
712+ for _ , r := range required {
713+ if rStr , ok := r .(string ); ok {
714+ if ! seen [rStr ] {
715+ seen [rStr ] = true
716+ uniqueRequired = append (uniqueRequired , rStr )
717+ }
718+ }
719+ }
720+ if len (uniqueRequired ) > 0 {
721+ merged ["required" ] = uniqueRequired
722+ }
723+ }
724+
725+ return merged
726+ }
727+
728+ // normalizeJSONSchema parses a JSON schema string, normalizes it for Gemini's supported subset,
729+ // and returns the normalized JSON string. This is the main entry point for schema normalization.
730+ //
731+ // The function handles the complete conversion from OpenAI's JSON Schema format to Gemini's
732+ // supported subset, including:
733+ // - Removing unsupported keywords (additionalProperties, pattern, minLength, multipleOf, etc.)
734+ // - Preserving nullable type arrays ["string", "null"]
735+ // - Filtering format values to only date-time, date, time
736+ // - Recursively processing all nested schemas
737+ //
738+ // If parsing fails, the original schema is returned unchanged.
739+ func normalizeJSONSchema (schemaJSON string ) string {
740+ var schema interface {}
741+ if err := json .Unmarshal ([]byte (schemaJSON ), & schema ); err != nil {
742+ return schemaJSON
743+ }
744+
745+ normalized := normalizeSchemaForGemini (schema )
746+
747+ result , err := json .Marshal (normalized )
748+ if err != nil {
749+ return schemaJSON
750+ }
751+
752+ return string (result )
753+ }
0 commit comments