From 354f6582b242f07db5144bc54bc843e160864fba Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:37:37 +0800 Subject: [PATCH] fix(codex): convert system role to developer for codex input --- .../codex_openai-responses_request.go | 28 ++ .../codex_openai-responses_request_test.go | 265 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 internal/translator/codex/openai/responses/codex_openai-responses_request_test.go 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 fc3e32a3..389c6d31 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go @@ -2,7 +2,9 @@ package responses import ( "bytes" + "fmt" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -20,5 +22,31 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p") rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier") + // Convert role "system" to "developer" in input array to comply with Codex API requirements. + rawJSON = convertSystemRoleToDeveloper(rawJSON) + return rawJSON } + +// convertSystemRoleToDeveloper traverses the input array and converts any message items +// 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") + if !inputResult.IsArray() { + return rawJSON + } + + inputArray := inputResult.Array() + result := rawJSON + + // Directly modify role values for items with "system" role + for i := 0; i < len(inputArray); i++ { + rolePath := fmt.Sprintf("input.%d.role", i) + if gjson.GetBytes(result, rolePath).String() == "system" { + result, _ = sjson.SetBytes(result, rolePath, "developer") + } + } + + return result +} 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 new file mode 100644 index 00000000..ea413238 --- /dev/null +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go @@ -0,0 +1,265 @@ +package responses + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +// TestConvertSystemRoleToDeveloper_BasicConversion tests the basic system -> developer role conversion +func TestConvertSystemRoleToDeveloper_BasicConversion(t *testing.T) { + inputJSON := []byte(`{ + "model": "gpt-5.2", + "input": [ + { + "type": "message", + "role": "system", + "content": [{"type": "input_text", "text": "You are a pirate."}] + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Say hello."}] + } + ] + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Check that system role was converted to developer + firstItemRole := gjson.Get(outputStr, "input.0.role") + if firstItemRole.String() != "developer" { + t.Errorf("Expected role 'developer', got '%s'", firstItemRole.String()) + } + + // Check that user role remains unchanged + secondItemRole := gjson.Get(outputStr, "input.1.role") + if secondItemRole.String() != "user" { + t.Errorf("Expected role 'user', got '%s'", secondItemRole.String()) + } + + // Check content is preserved + firstItemContent := gjson.Get(outputStr, "input.0.content.0.text") + if firstItemContent.String() != "You are a pirate." { + t.Errorf("Expected content 'You are a pirate.', got '%s'", firstItemContent.String()) + } +} + +// TestConvertSystemRoleToDeveloper_MultipleSystemMessages tests conversion with multiple system messages +func TestConvertSystemRoleToDeveloper_MultipleSystemMessages(t *testing.T) { + inputJSON := []byte(`{ + "model": "gpt-5.2", + "input": [ + { + "type": "message", + "role": "system", + "content": [{"type": "input_text", "text": "You are helpful."}] + }, + { + "type": "message", + "role": "system", + "content": [{"type": "input_text", "text": "Be concise."}] + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Hello"}] + } + ] + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Check that both system roles were converted + firstRole := gjson.Get(outputStr, "input.0.role") + if firstRole.String() != "developer" { + t.Errorf("Expected first role 'developer', got '%s'", firstRole.String()) + } + + secondRole := gjson.Get(outputStr, "input.1.role") + if secondRole.String() != "developer" { + t.Errorf("Expected second role 'developer', got '%s'", secondRole.String()) + } + + // Check that user role is unchanged + thirdRole := gjson.Get(outputStr, "input.2.role") + if thirdRole.String() != "user" { + t.Errorf("Expected third role 'user', got '%s'", thirdRole.String()) + } +} + +// TestConvertSystemRoleToDeveloper_NoSystemMessages tests that requests without system messages are unchanged +func TestConvertSystemRoleToDeveloper_NoSystemMessages(t *testing.T) { + inputJSON := []byte(`{ + "model": "gpt-5.2", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Hello"}] + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hi there!"}] + } + ] + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Check that user and assistant roles are unchanged + firstRole := gjson.Get(outputStr, "input.0.role") + if firstRole.String() != "user" { + t.Errorf("Expected role 'user', got '%s'", firstRole.String()) + } + + secondRole := gjson.Get(outputStr, "input.1.role") + if secondRole.String() != "assistant" { + t.Errorf("Expected role 'assistant', got '%s'", secondRole.String()) + } +} + +// TestConvertSystemRoleToDeveloper_EmptyInput tests that empty input arrays are handled correctly +func TestConvertSystemRoleToDeveloper_EmptyInput(t *testing.T) { + inputJSON := []byte(`{ + "model": "gpt-5.2", + "input": [] + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Check that input is still an empty array + inputArray := gjson.Get(outputStr, "input") + if !inputArray.IsArray() { + t.Error("Input should still be an array") + } + if len(inputArray.Array()) != 0 { + t.Errorf("Expected empty array, got %d items", len(inputArray.Array())) + } +} + +// TestConvertSystemRoleToDeveloper_NoInputField tests that requests without input field are unchanged +func TestConvertSystemRoleToDeveloper_NoInputField(t *testing.T) { + inputJSON := []byte(`{ + "model": "gpt-5.2", + "stream": false + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Check that other fields are still set correctly + stream := gjson.Get(outputStr, "stream") + if !stream.Bool() { + t.Error("Stream should be set to true by conversion") + } + + store := gjson.Get(outputStr, "store") + if store.Bool() { + t.Error("Store should be set to false by conversion") + } +} + +// TestConvertOpenAIResponsesRequestToCodex_OriginalIssue tests the exact issue reported by the user +func TestConvertOpenAIResponsesRequestToCodex_OriginalIssue(t *testing.T) { + // This is the exact input that was failing with "System messages are not allowed" + inputJSON := []byte(`{ + "model": "gpt-5.2", + "input": [ + { + "type": "message", + "role": "system", + "content": "You are a pirate. Always respond in pirate speak." + }, + { + "type": "message", + "role": "user", + "content": "Say hello." + } + ], + "stream": false + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Verify system role was converted to developer + firstRole := gjson.Get(outputStr, "input.0.role") + if firstRole.String() != "developer" { + t.Errorf("Expected role 'developer', got '%s'", firstRole.String()) + } + + // Verify stream was set to true (as required by Codex) + stream := gjson.Get(outputStr, "stream") + if !stream.Bool() { + t.Error("Stream should be set to true") + } + + // Verify other required fields for Codex + store := gjson.Get(outputStr, "store") + if store.Bool() { + t.Error("Store should be false") + } + + parallelCalls := gjson.Get(outputStr, "parallel_tool_calls") + if !parallelCalls.Bool() { + t.Error("parallel_tool_calls should be true") + } + + include := gjson.Get(outputStr, "include") + if !include.IsArray() || len(include.Array()) != 1 { + t.Error("include should be an array with one element") + } else if include.Array()[0].String() != "reasoning.encrypted_content" { + t.Errorf("Expected include[0] to be 'reasoning.encrypted_content', got '%s'", include.Array()[0].String()) + } +} + +// TestConvertSystemRoleToDeveloper_AssistantRole tests that assistant role is preserved +func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) { + inputJSON := []byte(`{ + "model": "gpt-5.2", + "input": [ + { + "type": "message", + "role": "system", + "content": [{"type": "input_text", "text": "You are helpful."}] + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Hello"}] + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Hi!"}] + } + ] + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Check system -> developer + firstRole := gjson.Get(outputStr, "input.0.role") + if firstRole.String() != "developer" { + t.Errorf("Expected first role 'developer', got '%s'", firstRole.String()) + } + + // Check user unchanged + secondRole := gjson.Get(outputStr, "input.1.role") + if secondRole.String() != "user" { + t.Errorf("Expected second role 'user', got '%s'", secondRole.String()) + } + + // Check assistant unchanged + thirdRole := gjson.Get(outputStr, "input.2.role") + if thirdRole.String() != "assistant" { + t.Errorf("Expected third role 'assistant', got '%s'", thirdRole.String()) + } +}