Skip to content

feat(translator): add response_format conversion in Chat Completions …#4133

Open
ER-EPR wants to merge 1 commit into
router-for-me:devfrom
ER-EPR:feat/chat-completions-response-format-gemini
Open

feat(translator): add response_format conversion in Chat Completions …#4133
ER-EPR wants to merge 1 commit into
router-for-me:devfrom
ER-EPR:feat/chat-completions-response-format-gemini

Conversation

@ER-EPR

@ER-EPR ER-EPR commented Jul 7, 2026

Copy link
Copy Markdown

…to Gemini

Summary

The Chat Completions → Gemini request translator (internal/translator/gemini/openai/chat-completions/gemini_openai_request.go) silently dropped OpenAI's response_format field. When a Chat Completions client requested JSON structured output, Gemini received no structured-output directive and returned free-form text.

This PR adds response_format → Gemini generationConfig conversion, mirroring the behavior the Responses → Gemini translator gained in #bc652c7 (applyOpenAIResponsesTextFormatToGemini, which handles the Responses API's text.format field).

OpenAI Chat Completions Gemini generationConfig
response_format: {type: "json_object"} responseMimeType = "application/json"
response_format: {type: "json_schema", json_schema: {schema: {...}}} responseMimeType = "application/json" + responseJsonSchema = <schema> (+ delete stale responseSchema)

Background: two different OpenAI shapes

OpenAI exposes structured output through two different request shapes depending on the API:

  • Responses API: text.format → handled by bc652c7 (applyOpenAIResponsesTextFormatToGemini)
  • Chat Completions API: response_format (with the schema nested under response_format.json_schema.schema) → not handled (this PR fills the gap)

The Codex translator (internal/translator/codex/openai/chat-completions/codex_openai_request.go:252) already bridges chat-completions response_format → Responses text.format, confirming the structural difference. This PR brings the Gemini chat-completions path to parity with the Responses path.

Changes

One file changed + its test file. No new files, no shared helpers, no responses-side changes.

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

Added one function, applyOpenAIResponseFormatToGemini(out, rawJSON []byte) []byte, called from ConvertOpenAIRequestToGemini between the n/candidateCount and modalities blocks:

// applyOpenAIResponseFormatToGemini maps OpenAI Chat Completions response_format to Gemini
// generationConfig structured-output fields. The schema is passed through raw (not cleaned)
// to match the Responses-side translator (bc652c7): Gemini response structured output requires
// additionalProperties:false on strict schemas and supports $defs/$ref, which the tool-calling
// schema cleaner (util.CleanJSONSchemaForGemini) would strip.
func applyOpenAIResponseFormatToGemini(out []byte, rawJSON []byte) []byte {
        rf := gjson.GetBytes(rawJSON, "response_format")
        if !rf.Exists() {
                return out
        }

        formatType := strings.ToLower(strings.TrimSpace(rf.Get("type").String()))
        switch formatType {
        case "json_object":
                if !gjson.GetBytes(out, "generationConfig").Exists() {
                        out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(`{}`))
                }
                out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
        case "json_schema":
                if !gjson.GetBytes(out, "generationConfig").Exists() {
                        out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(`{}`))
                }
                out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
                out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema")
                if schema := rf.Get("json_schema.schema"); schema.Exists() {
                        out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
                }
        }

        return out
}

Design decision: raw schema passthrough (no CleanJSONSchemaForGemini)

The json_schema.schema is written to generationConfig.responseJsonSchema raw, exactly as bc652c7 does. It is deliberately not run through util.CleanJSONSchemaForGemini.

Rationale: CleanJSONSchemaForGemini (internal/util/gemini_schema.go) is tuned for Gemini tool-calling schemas (parametersJsonSchema), and would actively harm response schemas:

  • It deletes additionalProperties (removeUnsupportedKeywords), but Gemini response structured output requires additionalProperties: false on strict schemas — bc652c7's own test asserts it must be preserved.
  • It strips $defs/$ref/definitions/title/nullable, which response structured output supports.
  • convertEnumValuesToStrings forces type: "string" on enums, corrupting numeric/boolean enum response schemas.

The same chat-completions file does use CleanJSONSchemaForGemini for tool parameter schemas (line ~382) — that is a different context (tool-calling) and correctly remains as-is. The two schema contexts face different Gemini constraints, so the tool-calling cleaner is the wrong tool for response schemas. Raw passthrough also keeps the two OpenAI→Gemini structured-output paths (Responses and Chat Completions) behaviorally identical.

Tests

Added 6 tests to internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go, mirroring bc652c7's coverage plus edge cases:

Test Verifies
TestConvertOpenAIRequestToGemini_ResponseFormatJSONSchema json_schema sets responseMimeType + responseJsonSchema (raw, additionalProperties:false preserved), deletes stale responseSchema, preserves sibling temperature
TestConvertOpenAIRequestToGemini_ResponseFormatJSONObject json_object sets responseMimeType, does not set responseJsonSchema
TestConvertOpenAIRequestToGemini_ResponseFormatAbsent Absent response_format → no responseMimeType/responseJsonSchema set (regression guard)
TestConvertOpenAIRequestToGemini_ResponseFormatJSONSchemaNoSchema json_schema without inner schema field → still sets responseMimeType, no responseJsonSchema
TestConvertOpenAIRequestToGemini_ResponseFormatUnknownType Unknown type (e.g. "text") → no-op, sibling fields unaffected
TestConvertOpenAIRequestToGemini_ResponseFormatPreservesUserGenerationConfig User-supplied generationConfig is preserved (merged into, not replaced)

All tests pass; no regressions in the existing package suite.

$ go test ./internal/translator/gemini/openai/chat-completions/
ok      github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions

Verification (live, on a running container)

The change was rebuilt into the cli-proxy-api:latest Docker image (commit 39a2d71f) and verified live against a running container at https://cliproxy.savorcare.com/v1/chat/completions with gemini-2.5-flash.

1. Upstream payload inspection (direct translator output)

To confirm the conversion is actually uploaded to Gemini (not just that the model happens to return JSON), the registered translator function was invoked directly with a sample request and its output inspected — this is the exact payload cliproxy sends to Gemini:

{
  "contents": [{"role":"user","parts":[{"text":"Describe a person named Bob who is 25."}]}],
  "model": "gemini-2.5-flash",
  "generationConfig": {
    "responseMimeType": "application/json",
    "responseJsonSchema": {
      "type": "object",
      "properties": {"name": {"type":"string"}, "age": {"type":"integer"}},
      "required": ["name","age"],
      "additionalProperties": false
    }
  },
  "safetySettings": [...]
}

Both responseMimeType and responseJsonSchema are present, and additionalProperties: false is preserved raw — confirming the conversion reaches Gemini. (This bypasses the model entirely; it inspects the translator's output directly, which is possible because ConvertOpenAIRequestToGemini is a pure function registered via init.go as the OpenAI→Gemini request translator.)

2. Controlled A/B test (same prompt, with vs without response_format)

To rule out "the model returns JSON on its own," the same JSON-free prompt was sent twice with only response_format varying:

Prompt: Tell me about the planet Mars. (verified to contain no JSON/object/schema hints)

No response_format response_format: json_object
Returned content Long Markdown prose ("Mars, often called the "Red Planet"...") Structured JSON object ({"name":"Mars","category":"Planet",...})
Valid JSON? ❌ No (free prose) ✅ Yes
Starts with {? ❌ No ✅ Yes

The only variable between A and B is response_format. Since A returns prose and B returns a JSON object, the structured output can only be driven by response_format being correctly translated to responseMimeType and uploaded to Gemini.

3. json_schema live test

curl -X POST .../v1/chat/completions -d '{
  "model": "gemini-2.5-flash",
  "messages": [{"role":"user","content":"Describe a person named Bob who is 25 years old."}],
  "response_format": {"type":"json_schema","json_schema":{"name":"person","strict":true,"schema":{
    "type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},
    "required":["name","age"],"additionalProperties":false}}}}'

Returned {"name":"Bob","age":25} — conforms to the schema, with no extra fields (additionalProperties: false enforced by Gemini).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for mapping OpenAI's response_format (both json_object and json_schema) to Gemini's structured-output fields (responseMimeType and responseJsonSchema) in the request translator, along with comprehensive unit tests. The reviewer suggested simplifying the mapping logic by removing redundant checks and manual initialization of generationConfig, as sjson.SetBytes automatically handles intermediate path creation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +505 to +517
if !gjson.GetBytes(out, "generationConfig").Exists() {
out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(`{}`))
}
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
case "json_schema":
if !gjson.GetBytes(out, "generationConfig").Exists() {
out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(`{}`))
}
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema")
if schema := rf.Get("json_schema.schema"); schema.Exists() {
out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check for the existence of generationConfig and its manual initialization to {} is redundant. sjson.SetBytes automatically creates intermediate objects and paths if they do not exist. Removing these checks simplifies the code and avoids unnecessary JSON parsing and writing passes, improving performance.

		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 := rf.Get("json_schema.schema"); schema.Exists() {
			out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
		}

…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>
@ER-EPR ER-EPR force-pushed the feat/chat-completions-response-format-gemini branch from 39a2d71 to dc5cf9e Compare July 8, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant