feat(translator): add response_format conversion in Chat Completions …#4133
feat(translator): add response_format conversion in Chat Completions …#4133ER-EPR wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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.
| 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)) | ||
| } |
There was a problem hiding this comment.
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>
39a2d71 to
dc5cf9e
Compare
…to Gemini
Summary
The Chat Completions → Gemini request translator (
internal/translator/gemini/openai/chat-completions/gemini_openai_request.go) silently dropped OpenAI'sresponse_formatfield. 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→ GeminigenerationConfigconversion, mirroring the behavior the Responses → Gemini translator gained in #bc652c7 (applyOpenAIResponsesTextFormatToGemini, which handles the Responses API'stext.formatfield).generationConfigresponse_format: {type: "json_object"}responseMimeType = "application/json"response_format: {type: "json_schema", json_schema: {schema: {...}}}responseMimeType = "application/json"+responseJsonSchema = <schema>(+ delete staleresponseSchema)Background: two different OpenAI shapes
OpenAI exposes structured output through two different request shapes depending on the API:
text.format→ handled bybc652c7(applyOpenAIResponsesTextFormatToGemini)response_format(with the schema nested underresponse_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-completionsresponse_format→ Responsestext.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.goAdded one function,
applyOpenAIResponseFormatToGemini(out, rawJSON []byte) []byte, called fromConvertOpenAIRequestToGeminibetween then/candidateCount and modalities blocks:Design decision: raw schema passthrough (no
CleanJSONSchemaForGemini)The
json_schema.schemais written togenerationConfig.responseJsonSchemaraw, exactly asbc652c7does. It is deliberately not run throughutil.CleanJSONSchemaForGemini.Rationale:
CleanJSONSchemaForGemini(internal/util/gemini_schema.go) is tuned for Gemini tool-calling schemas (parametersJsonSchema), and would actively harm response schemas:additionalProperties(removeUnsupportedKeywords), but Gemini response structured output requiresadditionalProperties: falseon strict schemas —bc652c7's own test asserts it must be preserved.$defs/$ref/definitions/title/nullable, which response structured output supports.convertEnumValuesToStringsforcestype: "string"on enums, corrupting numeric/boolean enum response schemas.The same chat-completions file does use
CleanJSONSchemaForGeminifor 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, mirroringbc652c7's coverage plus edge cases:TestConvertOpenAIRequestToGemini_ResponseFormatJSONSchemajson_schemasetsresponseMimeType+responseJsonSchema(raw,additionalProperties:falsepreserved), deletes staleresponseSchema, preserves siblingtemperatureTestConvertOpenAIRequestToGemini_ResponseFormatJSONObjectjson_objectsetsresponseMimeType, does not setresponseJsonSchemaTestConvertOpenAIRequestToGemini_ResponseFormatAbsentresponse_format→ noresponseMimeType/responseJsonSchemaset (regression guard)TestConvertOpenAIRequestToGemini_ResponseFormatJSONSchemaNoSchemajson_schemawithout innerschemafield → still setsresponseMimeType, noresponseJsonSchemaTestConvertOpenAIRequestToGemini_ResponseFormatUnknownTypetype(e.g."text") → no-op, sibling fields unaffectedTestConvertOpenAIRequestToGemini_ResponseFormatPreservesUserGenerationConfiggenerationConfigis preserved (merged into, not replaced)All tests pass; no regressions in the existing package suite.
Verification (live, on a running container)
The change was rebuilt into the
cli-proxy-api:latestDocker image (commit39a2d71f) and verified live against a running container athttps://cliproxy.savorcare.com/v1/chat/completionswithgemini-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
responseMimeTypeandresponseJsonSchemaare present, andadditionalProperties: falseis preserved raw — confirming the conversion reaches Gemini. (This bypasses the model entirely; it inspects the translator's output directly, which is possible becauseConvertOpenAIRequestToGeminiis a pure function registered viainit.goas 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_formatvarying:Prompt:
Tell me about the planet Mars.(verified to contain no JSON/object/schema hints)response_formatresponse_format: json_object{"name":"Mars","category":"Planet",...}){?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 byresponse_formatbeing correctly translated toresponseMimeTypeand uploaded to Gemini.3.
json_schemalive testReturned
{"name":"Bob","age":25}— conforms to the schema, with no extra fields (additionalProperties: falseenforced by Gemini).