From f8c45c30c5b321a20032ff319e6eb3250560e83b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 27 Aug 2026 18:19:04 +0800 Subject: [PATCH] feat(codex): map claude output_config format to text format - Map Claude `output_config.format` with `json_schema` type to Codex `text.format`. - Preserve custom schema name and strict configuration with appropriate defaults. Closes: #5280 --- .../codex/claude/codex_claude_request.go | 19 +++ .../codex/claude/codex_claude_request_test.go | 108 ++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 906ae6684..03e62d920 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -30,6 +30,7 @@ import ( // 4. Converts tools declarations to the expected format // 5. Adds additional configuration parameters for the Codex API // 6. Maps Claude thinking configuration to Codex reasoning settings +// 7. Maps Claude output_config format to Codex text format // // Parameters: // - modelName: The name of the model to use for the request @@ -390,6 +391,24 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, template, _ = sjson.SetBytes(template, "stream", true) template, _ = sjson.SetBytes(template, "store", false) template, _ = sjson.SetBytes(template, "include", []string{"reasoning.encrypted_content"}) + + // Map Claude output_config.format to Codex Responses text.format. + if format := rootResult.Get("output_config.format"); format.IsObject() && format.Get("type").String() == "json_schema" && format.Get("schema").IsObject() { + name := "cli_proxy_structured_output" + if n := format.Get("name").String(); n != "" { + name = n + } + strict := true + if s := format.Get("strict"); s.Exists() && s.Type == gjson.False { + strict = false + } + translatedFormat := []byte(`{"type":"json_schema","name":"","strict":true,"schema":{}}`) + translatedFormat, _ = sjson.SetBytes(translatedFormat, "name", name) + translatedFormat, _ = sjson.SetBytes(translatedFormat, "strict", strict) + translatedFormat, _ = sjson.SetRawBytes(translatedFormat, "schema", []byte(format.Get("schema").Raw)) + template, _ = sjson.SetRawBytes(template, "text.format", translatedFormat) + } + if toolsResult.IsArray() { template, _ = sjson.SetRawBytes(template, "tools", translatorcommon.JoinRawArray(toolItems)) } diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 9db9c069f..756a88907 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -710,3 +710,111 @@ func validCodexReasoningSignature() string { raw[8] = 1 return base64.URLEncoding.EncodeToString(raw) } + +func TestConvertClaudeRequestToCodex_OutputConfigFormat(t *testing.T) { + t.Run("Valid json_schema format", func(t *testing.T) { + payload := []byte(`{ + "model": "gpt-5.4", + "max_tokens": 128, + "messages": [ + {"role": "user", "content": "Return an object with one string field named answer."} + ], + "output_config": { + "format": { + "type": "json_schema", + "schema": { + "type": "object", + "properties": { + "answer": {"type": "string"} + }, + "required": ["answer"], + "additionalProperties": false + } + } + } + }`) + + translated := ConvertClaudeRequestToCodex("gpt-5.4", payload, false) + root := gjson.ParseBytes(translated) + + if !root.Get("text.format").Exists() { + t.Fatalf("expected text.format in translated payload, got: %s", translated) + } + if got := root.Get("text.format.type").String(); got != "json_schema" { + t.Errorf("expected text.format.type to be 'json_schema', got %q", got) + } + if got := root.Get("text.format.name").String(); got != "cli_proxy_structured_output" { + t.Errorf("expected text.format.name to be 'cli_proxy_structured_output', got %q", got) + } + if got := root.Get("text.format.strict").Bool(); !got { + t.Errorf("expected text.format.strict to be true, got %v", got) + } + if got := root.Get("text.format.schema.properties.answer.type").String(); got != "string" { + t.Errorf("expected schema.properties.answer.type to be 'string', got %q", got) + } + }) + + t.Run("Valid json_schema format with custom name and strict false", func(t *testing.T) { + payload := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "user", "content": "hello"} + ], + "output_config": { + "format": { + "type": "json_schema", + "name": "custom_schema", + "strict": false, + "schema": { + "type": "object" + } + } + } + }`) + + translated := ConvertClaudeRequestToCodex("gpt-5.4", payload, false) + root := gjson.ParseBytes(translated) + + if got := root.Get("text.format.name").String(); got != "custom_schema" { + t.Errorf("expected text.format.name to be 'custom_schema', got %q", got) + } + if got := root.Get("text.format.strict").Bool(); got != false { + t.Errorf("expected text.format.strict to be false, got %v", got) + } + }) + + t.Run("No output_config.format", func(t *testing.T) { + payload := []byte(`{ + "model": "gpt-5.4", + "messages": [ + {"role": "user", "content": "hello"} + ] + }`) + + translated := ConvertClaudeRequestToCodex("gpt-5.4", payload, false) + root := gjson.ParseBytes(translated) + if root.Get("text.format").Exists() { + t.Fatalf("expected no text.format in translated payload, got: %s", translated) + } + }) + + t.Run("output_config with effort only", func(t *testing.T) { + payload := []byte(`{ + "model": "gpt-5.4", + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high"}, + "messages": [ + {"role": "user", "content": "hello"} + ] + }`) + + translated := ConvertClaudeRequestToCodex("gpt-5.4", payload, false) + root := gjson.ParseBytes(translated) + if root.Get("text.format").Exists() { + t.Fatalf("expected no text.format in translated payload, got: %s", translated) + } + if got := root.Get("reasoning.effort").String(); got != "high" { + t.Errorf("expected reasoning.effort to be 'high', got %q", got) + } + }) +}