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
This commit is contained in:
Luis Pater
2026-08-27 18:19:04 +08:00
parent 8ee9add75d
commit f8c45c30c5
2 changed files with 127 additions and 0 deletions

View File

@@ -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))
}

View File

@@ -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)
}
})
}