diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request.go b/internal/translator/codex/openai/responses/codex_openai-responses_request.go index bcf6ab54..732b3559 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go @@ -16,36 +16,73 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, if inputResult.Type == gjson.String { input, _ := sjson.SetBytes([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`), "0.content.0.text", inputResult.String()) rawJSON, _ = sjson.SetRawBytes(rawJSON, "input", input) + inputResult = gjson.GetBytes(rawJSON, "input") } - rawJSON, _ = sjson.SetBytes(rawJSON, "stream", true) - rawJSON, _ = sjson.SetBytes(rawJSON, "store", false) - rawJSON, _ = sjson.SetBytes(rawJSON, "parallel_tool_calls", true) - rawJSON, _ = sjson.SetBytes(rawJSON, "include", []string{"reasoning.encrypted_content"}) + rawJSON = setCodexRequiredBool(rawJSON, "stream", true) + rawJSON = setCodexRequiredBool(rawJSON, "store", false) + rawJSON = setCodexRequiredBool(rawJSON, "parallel_tool_calls", true) + rawJSON = setCodexRequiredInclude(rawJSON) // Codex Responses rejects token limit fields, so strip them out before forwarding. - rawJSON, _ = sjson.DeleteBytes(rawJSON, "max_output_tokens") - rawJSON, _ = sjson.DeleteBytes(rawJSON, "max_completion_tokens") - rawJSON, _ = sjson.DeleteBytes(rawJSON, "temperature") - rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p") - if v := gjson.GetBytes(rawJSON, "service_tier"); v.Exists() { - if v.String() != "priority" { - rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier") - } + rawJSON = deleteCodexRequestFields(rawJSON, "max_output_tokens", "max_completion_tokens", "temperature", "top_p") + if serviceTier := gjson.GetBytes(rawJSON, "service_tier"); serviceTier.Exists() && serviceTier.String() != "priority" { + rawJSON = deleteCodexRequestFields(rawJSON, "service_tier") } - rawJSON, _ = sjson.DeleteBytes(rawJSON, "truncation") + rawJSON = deleteCodexRequestFields(rawJSON, "truncation") rawJSON = applyResponsesCompactionCompatibility(rawJSON) // Delete the user field as it is not supported by the Codex upstream. - rawJSON, _ = sjson.DeleteBytes(rawJSON, "user") + rawJSON = deleteCodexRequestFields(rawJSON, "user") // Convert role "system" to "developer" in input array to comply with Codex API requirements. - rawJSON = convertSystemRoleToDeveloper(rawJSON) + rawJSON = convertSystemRoleToDeveloperWithInput(rawJSON, inputResult) rawJSON = normalizeCodexBuiltinTools(rawJSON) return rawJSON } +func setCodexRequiredBool(rawJSON []byte, path string, value bool) []byte { + current := gjson.GetBytes(rawJSON, path) + if value && current.Type == gjson.True || !value && current.Type == gjson.False { + return rawJSON + } + + updated, errSet := sjson.SetBytes(rawJSON, path, value) + if errSet != nil { + return rawJSON + } + return updated +} + +func setCodexRequiredInclude(rawJSON []byte) []byte { + current := gjson.GetBytes(rawJSON, "include") + values := current.Array() + if current.IsArray() && len(values) == 1 && values[0].Type == gjson.String && values[0].String() == "reasoning.encrypted_content" { + return rawJSON + } + + updated, errSet := sjson.SetRawBytes(rawJSON, "include", []byte(`["reasoning.encrypted_content"]`)) + if errSet != nil { + return rawJSON + } + return updated +} + +func deleteCodexRequestFields(rawJSON []byte, paths ...string) []byte { + for _, path := range paths { + if !gjson.GetBytes(rawJSON, path).Exists() { + continue + } + + updated, errDelete := sjson.DeleteBytes(rawJSON, path) + if errDelete == nil { + rawJSON = updated + } + } + return rawJSON +} + // applyResponsesCompactionCompatibility handles OpenAI Responses context_management.compaction // for Codex upstream compatibility. // @@ -67,7 +104,10 @@ func applyResponsesCompactionCompatibility(rawJSON []byte) []byte { // with role "system" to role "developer". This is necessary because Codex API does not // accept "system" role in the input array. func convertSystemRoleToDeveloper(rawJSON []byte) []byte { - inputResult := gjson.GetBytes(rawJSON, "input") + return convertSystemRoleToDeveloperWithInput(rawJSON, gjson.GetBytes(rawJSON, "input")) +} + +func convertSystemRoleToDeveloperWithInput(rawJSON []byte, inputResult gjson.Result) []byte { if !inputResult.IsArray() { return rawJSON } @@ -77,6 +117,17 @@ func convertSystemRoleToDeveloper(rawJSON []byte) []byte { return rawJSON } + hasSystemRole := false + for _, item := range inputItems { + if item.IsObject() && item.Get("role").String() == "system" { + hasSystemRole = true + break + } + } + if !hasSystemRole { + return rawJSON + } + changed := false rebuiltInput := make([]json.RawMessage, 0, len(inputItems)) for _, item := range inputItems { diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go index 7b0ebadb..b61efa7b 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go @@ -11,6 +11,7 @@ import ( ) var benchmarkConvertSystemRoleOutput []byte +var benchmarkConvertNormalizedOutput []byte // TestConvertSystemRoleToDeveloper_BasicConversion tests the basic system -> developer role conversion func TestConvertSystemRoleToDeveloper_BasicConversion(t *testing.T) { @@ -225,6 +226,69 @@ func TestConvertOpenAIResponsesRequestToCodex_OriginalIssue(t *testing.T) { } } +func TestConvertOpenAIResponsesRequestToCodexReusesNormalizedPayload(t *testing.T) { + inputJSON := []byte(`{"model":"gpt-5.6","stream":true,"store":false,"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"service_tier":"priority","input":[{"type":"message","role":"user","content":"hello"}]}`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true) + + if &output[0] != &inputJSON[0] { + t.Fatal("normalized request payload was copied") + } + if string(output) != string(inputJSON) { + t.Fatalf("normalized request changed:\n got: %s\nwant: %s", output, inputJSON) + } +} + +func TestConvertOpenAIResponsesRequestToCodexNormalizesRequiredFields(t *testing.T) { + inputJSON := []byte(`{ + "model":"gpt-5.6", + "stream":"true", + "store":true, + "parallel_tool_calls":false, + "include":["file_search_call.results","reasoning.encrypted_content"], + "max_output_tokens":4096, + "max_completion_tokens":4096, + "temperature":0.2, + "top_p":0.9, + "service_tier":"standard", + "truncation":"auto", + "user":"request-owner", + "input":[{"type":"message","role":"system","content":"hello"}] + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true) + + if stream := gjson.GetBytes(output, "stream"); stream.Type != gjson.True { + t.Fatalf("stream = %s, want true", stream.Raw) + } + if store := gjson.GetBytes(output, "store"); store.Type != gjson.False { + t.Fatalf("store = %s, want false", store.Raw) + } + if parallel := gjson.GetBytes(output, "parallel_tool_calls"); parallel.Type != gjson.True { + t.Fatalf("parallel_tool_calls = %s, want true", parallel.Raw) + } + include := gjson.GetBytes(output, "include").Array() + if len(include) != 1 || include[0].Type != gjson.String || include[0].String() != "reasoning.encrypted_content" { + t.Fatalf("include = %s, want reasoning.encrypted_content only", gjson.GetBytes(output, "include").Raw) + } + if role := gjson.GetBytes(output, "input.0.role").String(); role != "developer" { + t.Fatalf("input.0.role = %q, want developer", role) + } + for _, path := range []string{ + "max_output_tokens", + "max_completion_tokens", + "temperature", + "top_p", + "service_tier", + "truncation", + "user", + } { + if gjson.GetBytes(output, path).Exists() { + t.Fatalf("%s should be removed: %s", path, output) + } + } +} + // TestConvertSystemRoleToDeveloper_AssistantRole tests that assistant role is preserved func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) { inputJSON := []byte(`{ @@ -428,6 +492,40 @@ func BenchmarkConvertSystemRoleToDeveloperLargeInput(b *testing.B) { } } +func BenchmarkConvertOpenAIResponsesRequestToCodexNormalizedPayload(b *testing.B) { + cases := []struct { + name string + inputJSON []byte + }{ + {name: "1KiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(1 << 10)}, + {name: "1MiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(1 << 20)}, + {name: "8MiB", inputJSON: makeNormalizedResponsesRequestForBenchmark(8 << 20)}, + } + + for _, testCase := range cases { + b.Run(testCase.name, func(b *testing.B) { + b.ReportAllocs() + b.SetBytes(int64(len(testCase.inputJSON))) + b.ResetTimer() + + var output []byte + for b.Loop() { + output = ConvertOpenAIResponsesRequestToCodex("gpt-5.6", testCase.inputJSON, true) + } + benchmarkConvertNormalizedOutput = output + }) + } +} + +func makeNormalizedResponsesRequestForBenchmark(contentBytes int) []byte { + var builder strings.Builder + builder.Grow(contentBytes + 256) + builder.WriteString(`{"model":"gpt-5.6","stream":true,"store":false,"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"input":[{"type":"message","role":"user","content":"`) + builder.WriteString(strings.Repeat("x", contentBytes)) + builder.WriteString(`"}]}`) + return []byte(builder.String()) +} + func makeLargeResponsesInputForBenchmark(inputCount int, systemEvery int) []byte { var builder strings.Builder builder.Grow(inputCount * 96)