Skip to content

Commit dc5cf9e

Browse files
xyoniumclaude
andcommitted
feat(translator): add response_format conversion in Chat Completions to Gemini
Map OpenAI Chat Completions response_format (json_object / json_schema) to Gemini generationConfig.responseMimeType and responseJsonSchema, mirroring the Responses-side translator (bc652c7). The schema is passed through raw to preserve additionalProperties:false and $defs/$ref that Gemini response structured output requires; util.CleanJSONSchemaForGemini is deliberately not applied (it is tuned for tool-calling schemas). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8b9c4da commit dc5cf9e

2 files changed

Lines changed: 234 additions & 0 deletions

File tree

internal/translator/gemini/openai/chat-completions/gemini_openai_request.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
7575
}
7676
}
7777

78+
// JSON structured output (OpenAI response_format -> Gemini responseMimeType/responseJsonSchema)
79+
out = applyOpenAIResponseFormatToGemini(out, rawJSON)
80+
7881
// Map OpenAI modalities -> Gemini generationConfig.responseModalities
7982
// e.g. "modalities": ["image", "text"] -> ["IMAGE", "TEXT"]
8083
if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
@@ -484,3 +487,29 @@ func openAIInputAudioMimeType(audioFormat string) string {
484487
return "audio/" + audioFormat
485488
}
486489
}
490+
491+
// applyOpenAIResponseFormatToGemini maps OpenAI Chat Completions response_format to Gemini
492+
// generationConfig structured-output fields. The schema is passed through raw (not cleaned)
493+
// to match the Responses-side translator (bc652c7): Gemini response structured output requires
494+
// additionalProperties:false on strict schemas and supports $defs/$ref, which the tool-calling
495+
// schema cleaner (util.CleanJSONSchemaForGemini) would strip.
496+
func applyOpenAIResponseFormatToGemini(out []byte, rawJSON []byte) []byte {
497+
rf := gjson.GetBytes(rawJSON, "response_format")
498+
if !rf.Exists() {
499+
return out
500+
}
501+
502+
formatType := strings.ToLower(strings.TrimSpace(rf.Get("type").String()))
503+
switch formatType {
504+
case "json_object":
505+
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
506+
case "json_schema":
507+
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
508+
out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema")
509+
if schema := rf.Get("json_schema.schema"); schema.Exists() {
510+
out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
511+
}
512+
}
513+
514+
return out
515+
}

internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,208 @@ func TestConvertOpenAIRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T
182182
t.Fatalf("required[1] = %q, want industry. Schema: %s", got, schema.Raw)
183183
}
184184
}
185+
186+
func TestConvertOpenAIRequestToGemini_ResponseFormatJSONSchema(t *testing.T) {
187+
inputJSON := `{
188+
"model": "gemini-3.1-flash-lite",
189+
"temperature": 0.2,
190+
"messages": [
191+
{
192+
"role": "user",
193+
"content": [
194+
{
195+
"type": "text",
196+
"text": "Return structured JSON."
197+
}
198+
]
199+
}
200+
],
201+
"response_format": {
202+
"type": "json_schema",
203+
"json_schema": {
204+
"name": "response",
205+
"strict": true,
206+
"schema": {
207+
"type": "object",
208+
"properties": {
209+
"cleanedContent": {
210+
"type": "string"
211+
}
212+
},
213+
"required": [
214+
"cleanedContent"
215+
],
216+
"additionalProperties": false
217+
}
218+
}
219+
}
220+
}`
221+
222+
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
223+
result := gjson.ParseBytes(output)
224+
genConfig := result.Get("generationConfig")
225+
226+
if got := genConfig.Get("responseMimeType").String(); got != "application/json" {
227+
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
228+
}
229+
schema := genConfig.Get("responseJsonSchema")
230+
if !schema.Exists() {
231+
t.Fatalf("responseJsonSchema missing. Output: %s", output)
232+
}
233+
if genConfig.Get("responseSchema").Exists() {
234+
t.Fatalf("responseSchema should not be set with responseJsonSchema. Output: %s", output)
235+
}
236+
if got := schema.Get("type").String(); got != "object" {
237+
t.Fatalf("schema type = %q, want object. Output: %s", got, output)
238+
}
239+
if got := schema.Get("properties.cleanedContent.type").String(); got != "string" {
240+
t.Fatalf("cleanedContent type = %q, want string. Output: %s", got, output)
241+
}
242+
if additionalProperties := schema.Get("additionalProperties"); !additionalProperties.Exists() || additionalProperties.Bool() {
243+
t.Fatalf("additionalProperties = %s, want false. Output: %s", additionalProperties.Raw, output)
244+
}
245+
if got := genConfig.Get("temperature").Float(); got != 0.2 {
246+
t.Fatalf("temperature = %v, want 0.2. Output: %s", got, output)
247+
}
248+
}
249+
250+
func TestConvertOpenAIRequestToGemini_ResponseFormatJSONObject(t *testing.T) {
251+
inputJSON := `{
252+
"model": "gemini-flash-lite",
253+
"messages": [{"role": "user", "content": "Return a JSON object."}],
254+
"response_format": {
255+
"type": "json_object"
256+
}
257+
}`
258+
259+
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
260+
result := gjson.ParseBytes(output)
261+
genConfig := result.Get("generationConfig")
262+
263+
if got := genConfig.Get("responseMimeType").String(); got != "application/json" {
264+
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
265+
}
266+
if genConfig.Get("responseJsonSchema").Exists() {
267+
t.Fatalf("responseJsonSchema should not be set for json_object. Output: %s", output)
268+
}
269+
}
270+
271+
func TestConvertOpenAIRequestToGemini_ResponseFormatAbsent(t *testing.T) {
272+
inputJSON := `{
273+
"model": "gemini-flash-lite",
274+
"messages": [{"role": "user", "content": "plain text, no response_format"}],
275+
"temperature": 0.5
276+
}`
277+
278+
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
279+
result := gjson.ParseBytes(output)
280+
281+
if result.Get("generationConfig.responseMimeType").Exists() {
282+
t.Fatalf("responseMimeType should not be set when response_format absent. Output: %s", output)
283+
}
284+
if result.Get("generationConfig.responseJsonSchema").Exists() {
285+
t.Fatalf("responseJsonSchema should not be set when response_format absent. Output: %s", output)
286+
}
287+
if got := result.Get("generationConfig.temperature").Float(); got != 0.5 {
288+
t.Fatalf("temperature = %v, want 0.5. Output: %s", got, output)
289+
}
290+
}
291+
292+
func TestConvertOpenAIRequestToGemini_ResponseFormatJSONSchemaNoSchema(t *testing.T) {
293+
// json_schema without an inner schema field: responseMimeType is still set,
294+
// but responseJsonSchema is not (there is nothing to embed).
295+
inputJSON := `{
296+
"model": "gemini-flash-lite",
297+
"messages": [{"role": "user", "content": "shape only"}],
298+
"response_format": {
299+
"type": "json_schema",
300+
"json_schema": {
301+
"name": "response"
302+
}
303+
}
304+
}`
305+
306+
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
307+
result := gjson.ParseBytes(output)
308+
genConfig := result.Get("generationConfig")
309+
310+
if got := genConfig.Get("responseMimeType").String(); got != "application/json" {
311+
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
312+
}
313+
if genConfig.Get("responseJsonSchema").Exists() {
314+
t.Fatalf("responseJsonSchema should not be set when no schema provided. Output: %s", output)
315+
}
316+
}
317+
318+
func TestConvertOpenAIRequestToGemini_ResponseFormatUnknownType(t *testing.T) {
319+
// An unrecognized response_format.type is a no-op: no responseMimeType,
320+
// no responseJsonSchema, no spurious generationConfig created. Sibling
321+
// fields like temperature still pass through.
322+
inputJSON := `{
323+
"model": "gemini-flash-lite",
324+
"messages": [{"role": "user", "content": "plain text"}],
325+
"temperature": 0.7,
326+
"response_format": {
327+
"type": "text"
328+
}
329+
}`
330+
331+
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
332+
result := gjson.ParseBytes(output)
333+
334+
if result.Get("generationConfig.responseMimeType").Exists() {
335+
t.Fatalf("responseMimeType should not be set for unknown type. Output: %s", output)
336+
}
337+
if result.Get("generationConfig.responseJsonSchema").Exists() {
338+
t.Fatalf("responseJsonSchema should not be set for unknown type. Output: %s", output)
339+
}
340+
if got := result.Get("generationConfig.temperature").Float(); got != 0.7 {
341+
t.Fatalf("temperature = %v, want 0.7. Output: %s", got, output)
342+
}
343+
}
344+
345+
func TestConvertOpenAIRequestToGemini_ResponseFormatPreservesUserGenerationConfig(t *testing.T) {
346+
// A user-supplied generationConfig must be preserved; response_format only
347+
// adds responseMimeType/responseJsonSchema on top rather than replacing it.
348+
inputJSON := `{
349+
"model": "gemini-flash-lite",
350+
"messages": [{"role": "user", "content": "merge check"}],
351+
"generationConfig": {
352+
"temperature": 0.9,
353+
"topP": 0.8
354+
},
355+
"response_format": {
356+
"type": "json_schema",
357+
"json_schema": {
358+
"schema": {
359+
"type": "object",
360+
"properties": {"answer": {"type": "string"}},
361+
"required": ["answer"],
362+
"additionalProperties": false
363+
}
364+
}
365+
}
366+
}`
367+
368+
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
369+
result := gjson.ParseBytes(output)
370+
genConfig := result.Get("generationConfig")
371+
372+
if got := genConfig.Get("responseMimeType").String(); got != "application/json" {
373+
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
374+
}
375+
schema := genConfig.Get("responseJsonSchema")
376+
if !schema.Exists() {
377+
t.Fatalf("responseJsonSchema missing. Output: %s", output)
378+
}
379+
if got := schema.Get("type").String(); got != "object" {
380+
t.Fatalf("schema type = %q, want object. Output: %s", got, output)
381+
}
382+
// User-supplied generationConfig fields survive the merge.
383+
if got := genConfig.Get("temperature").Float(); got != 0.9 {
384+
t.Fatalf("user temperature = %v, want 0.9. Output: %s", got, output)
385+
}
386+
if got := genConfig.Get("topP").Float(); got != 0.8 {
387+
t.Fatalf("user topP = %v, want 0.8. Output: %s", got, output)
388+
}
389+
}

0 commit comments

Comments
 (0)