Skip to content

Commit 7d50806

Browse files
committed
feat(gemini): implement complete OpenAI to Gemini JSON Schema conversion
Implement comprehensive schema conversion from OpenAI structured output format to Gemini API format with full support for: - Nullable types: convert ["string", "null"] to type + nullable field - allOf merging: combine multiple schemas into single object schema - oneOf conversion: transform to anyOf for Gemini compatibility - additionalProperties removal: Gemini rejects this field - Recursive normalization: handle nested objects, arrays, and complex schemas - Format validation: preserve date-time, date, time; remove unsupported formats - Type constraints: preserve min/max, enum, descriptions - Recursive schemas: support $ref for self-referential schemas Includes helper functions: - normalizeSchemaForGemini: recursive schema normalization - mergeAllOf: merge allOf schemas with property and required field deduplication - normalizeJSONSchema: entry point for JSON string processing This enables Firecrawl and other tools using OpenAI's structured output format to work seamlessly with Gemini models through the proxy.
1 parent bbef8da commit 7d50806

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

internal/translator/gemini/openai/responses/gemini_openai-responses_request.go

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
459479
func 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

Comments
 (0)