From 20f83cae910b98bc39c45193f3e05072afea81f8 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 28 Jul 2026 03:36:30 +0800 Subject: [PATCH] feat(translator): map OpenAI `response_format` to Gemini structured output settings - Added `applyOpenAIResponseFormatToGemini` for translating OpenAI Chat Completions `response_format` to Gemini configuration. - Supported automatic mapping of `json_object` and `json_schema` types to Gemini `responseMimeType` and `responseJsonSchema`. - Updated structured output handling to pass schemas through and clean unsupported fields. Closes: #4582 --- .../chat-completions/gemini_openai_request.go | 25 ++++ .../gemini_openai_request_test.go | 116 ++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index c6a2dd80..0eea0925 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -81,6 +81,9 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } } + // Map OpenAI response_format to Gemini structured output settings. + out = applyOpenAIResponseFormatToGemini(out, rawJSON) + // Map OpenAI modalities -> Gemini generationConfig.responseModalities // e.g. "modalities": ["image", "text"] -> ["IMAGE", "TEXT"] if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() { @@ -477,3 +480,25 @@ func openAIInputAudioMimeType(audioFormat string) string { return "audio/" + audioFormat } } + +// applyOpenAIResponseFormatToGemini maps OpenAI Chat Completions structured output settings to Gemini. +// Response schemas pass through unchanged because the tool schema cleaner removes supported response fields. +func applyOpenAIResponseFormatToGemini(out []byte, rawJSON []byte) []byte { + responseFormat := gjson.GetBytes(rawJSON, "response_format") + if !responseFormat.Exists() { + return out + } + + switch strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())) { + case "json_object": + out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json") + case "json_schema": + out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json") + out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema") + if schema := responseFormat.Get("json_schema.schema"); schema.Exists() { + out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw)) + } + } + + return out +} diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go index 4672950e..c12d0114 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go @@ -299,3 +299,119 @@ func TestConvertOpenAIRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T t.Fatalf("required[1] = %q, want industry. Schema: %s", got, schema.Raw) } } + +func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchema(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.1-flash-lite", + "generationConfig": { + "temperature": 0.2, + "responseSchema": {"type": "string"} + }, + "messages": [{"role": "user", "content": "Return structured JSON."}], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "response", + "strict": true, + "schema": { + "type": "object", + "properties": {"cleanedContent": {"type": "string"}}, + "required": ["cleanedContent"], + "additionalProperties": false + } + } + } + }` + + output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false) + generationConfig := gjson.GetBytes(output, "generationConfig") + + if got := generationConfig.Get("responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output) + } + schema := generationConfig.Get("responseJsonSchema") + if !schema.Exists() { + t.Fatalf("responseJsonSchema missing. Output: %s", output) + } + if generationConfig.Get("responseSchema").Exists() { + t.Fatalf("responseSchema should be removed. Output: %s", output) + } + if additionalProperties := schema.Get("additionalProperties"); !additionalProperties.Exists() || additionalProperties.Bool() { + t.Fatalf("additionalProperties = %s, want false. Output: %s", additionalProperties.Raw, output) + } + if got := generationConfig.Get("temperature").Float(); got != 0.2 { + t.Fatalf("temperature = %v, want 0.2. Output: %s", got, output) + } +} + +func TestConvertOpenAIRequestToGeminiResponseFormatJSONObject(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.1-flash-lite", + "generationConfig": {"temperature": 0.6}, + "messages": [{"role": "user", "content": "Return a JSON object."}], + "response_format": {"type": "json_object"} + }` + + output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false) + generationConfig := gjson.GetBytes(output, "generationConfig") + + if got := generationConfig.Get("responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output) + } + if generationConfig.Get("responseJsonSchema").Exists() { + t.Fatalf("responseJsonSchema should not be set for json_object. Output: %s", output) + } + if got := generationConfig.Get("temperature").Float(); got != 0.6 { + t.Fatalf("temperature = %v, want 0.6. Output: %s", got, output) + } +} + +func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchemaWithoutSchema(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.1-flash-lite", + "messages": [{"role": "user", "content": "Return structured JSON."}], + "response_format": {"type": "json_schema", "json_schema": {"name": "response"}} + }` + + output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false) + generationConfig := gjson.GetBytes(output, "generationConfig") + + if got := generationConfig.Get("responseMimeType").String(); got != "application/json" { + t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output) + } + if generationConfig.Get("responseJsonSchema").Exists() { + t.Fatalf("responseJsonSchema should not be set without a schema. Output: %s", output) + } +} + +func TestConvertOpenAIRequestToGeminiResponseFormatNoOp(t *testing.T) { + tests := []struct { + name string + body string + }{ + { + name: "absent", + body: `{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"plain text"}],"temperature":0.5}`, + }, + { + name: "unknown type", + body: `{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"plain text"}],"temperature":0.5,"response_format":{"type":"text"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(tt.body), false) + generationConfig := gjson.GetBytes(output, "generationConfig") + if generationConfig.Get("responseMimeType").Exists() { + t.Fatalf("responseMimeType should not be set. Output: %s", output) + } + if generationConfig.Get("responseJsonSchema").Exists() { + t.Fatalf("responseJsonSchema should not be set. Output: %s", output) + } + if got := generationConfig.Get("temperature").Float(); got != 0.5 { + t.Fatalf("temperature = %v, want 0.5. Output: %s", got, output) + } + }) + } +} -- 2.51.2