diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 6728c2ed..370fc612 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -46,21 +46,21 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) rootResult := gjson.ParseBytes(rawJSON) toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON) template, _ = sjson.SetBytes(template, "model", modelName) + inputItems := make([][]byte, 0, 16) // Process system messages and convert them to input content format. systemsResult := rootResult.Get("system") if systemsResult.Exists() { - message := []byte(`{"type":"message","role":"developer","content":[]}`) - contentIndex := 0 + contentItems := make([][]byte, 0, 2) appendSystemText := func(text string) { if text == "" || util.IsClaudeCodeAttributionSystemText(text) { return } - message, _ = sjson.SetBytes(message, fmt.Sprintf("content.%d.type", contentIndex), "input_text") - message, _ = sjson.SetBytes(message, fmt.Sprintf("content.%d.text", contentIndex), text) - contentIndex++ + content := []byte(`{"type":"input_text","text":""}`) + content, _ = sjson.SetBytes(content, "text", text) + contentItems = append(contentItems, content) } if systemsResult.Type == gjson.String { @@ -75,8 +75,10 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) } } - if contentIndex > 0 { - template, _ = sjson.SetRawBytes(template, "input.-1", message) + if len(contentItems) > 0 { + message := []byte(`{"type":"message","role":"developer"}`) + message, _ = sjson.SetRawBytes(message, "content", marshalRawJSONArray(contentItems)) + inputItems = append(inputItems, message) } } @@ -92,27 +94,21 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(messageResult.Get("content")); ok { message := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`) message, _ = sjson.SetBytes(message, "content.0.text", reminderText) - template, _ = sjson.SetRawBytes(template, "input.-1", message) + inputItems = append(inputItems, message) } continue } - newMessage := func() []byte { - msg := []byte(`{"type":"message","role":"","content":[]}`) - msg, _ = sjson.SetBytes(msg, "role", messageRole) - return msg - } - - message := newMessage() - contentIndex := 0 - hasContent := false + messageContentsResult := messageResult.Get("content") + contentItems := make([][]byte, 0, 4) flushMessage := func() { - if hasContent { - template, _ = sjson.SetRawBytes(template, "input.-1", message) - message = newMessage() - contentIndex = 0 - hasContent = false + if len(contentItems) > 0 { + message := []byte(`{"type":"message","role":""}`) + message, _ = sjson.SetBytes(message, "role", messageRole) + message, _ = sjson.SetRawBytes(message, "content", marshalRawJSONArray(contentItems)) + inputItems = append(inputItems, message) + contentItems = contentItems[:0] } } @@ -121,17 +117,16 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) if messageRole == "assistant" { partType = "output_text" } - message, _ = sjson.SetBytes(message, fmt.Sprintf("content.%d.type", contentIndex), partType) - message, _ = sjson.SetBytes(message, fmt.Sprintf("content.%d.text", contentIndex), text) - contentIndex++ - hasContent = true + content := []byte(`{"type":"","text":""}`) + content, _ = sjson.SetBytes(content, "type", partType) + content, _ = sjson.SetBytes(content, "text", text) + contentItems = append(contentItems, content) } appendImageContent := func(dataURL string) { - message, _ = sjson.SetBytes(message, fmt.Sprintf("content.%d.type", contentIndex), "input_image") - message, _ = sjson.SetBytes(message, fmt.Sprintf("content.%d.image_url", contentIndex), dataURL) - contentIndex++ - hasContent = true + content := []byte(`{"type":"input_image","image_url":""}`) + content, _ = sjson.SetBytes(content, "image_url", dataURL) + contentItems = append(contentItems, content) } appendReasoningContent := func(part gjson.Result) { @@ -154,10 +149,9 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) flushMessage() reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`) reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature) - template, _ = sjson.SetRawBytes(template, "input.-1", reasoningItem) + inputItems = append(inputItems, reasoningItem) } - messageContentsResult := messageResult.Get("content") if messageContentsResult.IsArray() { messageContentResults := messageContentsResult.Array() for j := 0; j < len(messageContentResults); j++ { @@ -202,7 +196,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "name", name) } functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "arguments", messageContentResult.Get("input").Raw) - template, _ = sjson.SetRawBytes(template, "input.-1", functionCallMessage) + inputItems = append(inputItems, functionCallMessage) case "tool_result": flushMessage() functionCallOutputMessage := []byte(`{"type":"function_call_output"}`) @@ -210,9 +204,8 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) contentResult := messageContentResult.Get("content") if contentResult.IsArray() { - toolResultContentIndex := 0 - toolResultContent := []byte(`[]`) contentResults := contentResult.Array() + toolResultContentItems := make([][]byte, 0, len(contentResults)) for k := 0; k < len(contentResults); k++ { toolResultContentType := contentResults[k].Get("type").String() if toolResultContentType == "image" { @@ -232,19 +225,19 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) } dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data) - toolResultContent, _ = sjson.SetBytes(toolResultContent, fmt.Sprintf("%d.type", toolResultContentIndex), "input_image") - toolResultContent, _ = sjson.SetBytes(toolResultContent, fmt.Sprintf("%d.image_url", toolResultContentIndex), dataURL) - toolResultContentIndex++ + toolResultContent := []byte(`{"type":"input_image","image_url":""}`) + toolResultContent, _ = sjson.SetBytes(toolResultContent, "image_url", dataURL) + toolResultContentItems = append(toolResultContentItems, toolResultContent) } } } else if toolResultContentType == "text" { - toolResultContent, _ = sjson.SetBytes(toolResultContent, fmt.Sprintf("%d.type", toolResultContentIndex), "input_text") - toolResultContent, _ = sjson.SetBytes(toolResultContent, fmt.Sprintf("%d.text", toolResultContentIndex), contentResults[k].Get("text").String()) - toolResultContentIndex++ + toolResultContent := []byte(`{"type":"input_text","text":""}`) + toolResultContent, _ = sjson.SetBytes(toolResultContent, "text", contentResults[k].Get("text").String()) + toolResultContentItems = append(toolResultContentItems, toolResultContent) } } - if toolResultContentIndex > 0 { - functionCallOutputMessage, _ = sjson.SetRawBytes(functionCallOutputMessage, "output", toolResultContent) + if len(toolResultContentItems) > 0 { + functionCallOutputMessage, _ = sjson.SetRawBytes(functionCallOutputMessage, "output", marshalRawJSONArray(toolResultContentItems)) } else { functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "output", messageContentResult.Get("content").String()) } @@ -252,7 +245,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "output", messageContentResult.Get("content").String()) } - template, _ = sjson.SetRawBytes(template, "input.-1", functionCallOutputMessage) + inputItems = append(inputItems, functionCallOutputMessage) } } flushMessage() @@ -266,16 +259,17 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Convert tools declarations to the expected format for the Codex API. toolsResult := rootResult.Get("tools") + var toolItems [][]byte if toolsResult.IsArray() { - template, _ = sjson.SetRawBytes(template, "tools", []byte(`[]`)) webSearchToolNames := buildClaudeWebSearchToolNameSet(toolsResult) template, _ = sjson.SetRawBytes(template, "tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames)) toolResults := toolsResult.Array() + toolItems = make([][]byte, 0, len(toolResults)) for i := 0; i < len(toolResults); i++ { toolResult := toolResults[i] // Special handling: map Claude web search tool to Codex web_search if isClaudeWebSearchToolType(toolResult.Get("type").String()) { - template, _ = sjson.SetRawBytes(template, "tools.-1", convertClaudeWebSearchToolToCodex(toolResult)) + toolItems = append(toolItems, convertClaudeWebSearchToolToCodex(toolResult)) continue } tool := []byte(toolResult.Raw) @@ -296,7 +290,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.DeleteBytes(tool, "cache_control") tool, _ = sjson.DeleteBytes(tool, "defer_loading") tool, _ = sjson.SetBytes(tool, "strict", false) - template, _ = sjson.SetRawBytes(template, "tools.-1", tool) + toolItems = append(toolItems, tool) } } @@ -350,10 +344,35 @@ 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"}) + if toolsResult.IsArray() { + template, _ = sjson.SetRawBytes(template, "tools", marshalRawJSONArray(toolItems)) + } + template, _ = sjson.SetRawBytes(template, "input", marshalRawJSONArray(inputItems)) return template } +func marshalRawJSONArray(items [][]byte) []byte { + size := 2 + if len(items) > 1 { + size += len(items) - 1 + } + for i := 0; i < len(items); i++ { + size += len(items[i]) + } + + result := make([]byte, 0, size) + result = append(result, '[') + for i := 0; i < len(items); i++ { + if i > 0 { + result = append(result, ',') + } + result = append(result, items[i]...) + } + result = append(result, ']') + return result +} + func codexClaudeTargetAcceptsGrokSignature(modelName string) bool { baseModel := strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(modelName).ModelName)) return strings.Contains(baseModel, "grok") diff --git a/internal/translator/codex/claude/codex_claude_request_benchmark_test.go b/internal/translator/codex/claude/codex_claude_request_benchmark_test.go new file mode 100644 index 00000000..5ac16a8c --- /dev/null +++ b/internal/translator/codex/claude/codex_claude_request_benchmark_test.go @@ -0,0 +1,72 @@ +package claude + +import ( + "strconv" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func BenchmarkConvertClaudeRequestToCodexLargeHistory(b *testing.B) { + for _, turns := range []int{16, 64} { + b.Run(strconv.Itoa(turns)+"_turns", func(b *testing.B) { + request := largeClaudeRequest(turns, 32, 8*1024) + if !gjson.ValidBytes(request) { + b.Fatal("benchmark generated an invalid Claude request") + } + if result := ConvertClaudeRequestToCodex("gpt-5.4", request, false); !gjson.ValidBytes(result) { + b.Fatal("translator generated invalid Codex JSON") + } + b.ReportAllocs() + b.SetBytes(int64(len(request))) + b.ResetTimer() + + for i := 0; i < b.N; i++ { + ConvertClaudeRequestToCodex("gpt-5.4", request, false) + } + }) + } +} + +func largeClaudeRequest(turns, toolCount, payloadSize int) []byte { + payload := strings.Repeat("x", payloadSize) + var request strings.Builder + request.Grow((turns + toolCount) * payloadSize) + request.WriteString(`{"model":"claude-test","system":[{"type":"text","text":"`) + request.WriteString(payload) + request.WriteString(`"}],"messages":[`) + + for i := 0; i < turns; i++ { + if i > 0 { + request.WriteByte(',') + } + request.WriteString(`{"role":"assistant","content":[{"type":"text","text":"`) + request.WriteString(payload) + request.WriteString(`"},{"type":"tool_use","id":"toolu_`) + request.WriteString(strconv.Itoa(i)) + request.WriteString(`","name":"tool_`) + request.WriteString(strconv.Itoa(i % toolCount)) + request.WriteString(`","input":{"value":"`) + request.WriteString(payload) + request.WriteString(`"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_`) + request.WriteString(strconv.Itoa(i)) + request.WriteString(`","content":[{"type":"text","text":"`) + request.WriteString(payload) + request.WriteString(`"}]}]}`) + } + + request.WriteString(`],"tools":[`) + for i := 0; i < toolCount; i++ { + if i > 0 { + request.WriteByte(',') + } + request.WriteString(`{"name":"tool_`) + request.WriteString(strconv.Itoa(i)) + request.WriteString(`","description":"`) + request.WriteString(payload) + request.WriteString(`","input_schema":{"type":"object","properties":{"value":{"type":"string"}}}}`) + } + request.WriteString(`]}`) + return []byte(request.String()) +} diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 6f704da7..446e1376 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -508,6 +508,62 @@ func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t } } +func TestConvertClaudeRequestToCodex_PreservesContentOrderAcrossToolAndReasoningItems(t *testing.T) { + signature := validCodexReasoningSignature() + inputJSON := `{ + "system": "system rules", + "messages": [ + {"role":"assistant","content":[ + {"type":"text","text":"before reasoning"}, + {"type":"thinking","signature":"` + signature + `"}, + {"type":"text","text":"before tool"}, + {"type":"tool_use","id":"toolu_1","name":"lookup","input":{"query":"test"}}, + {"type":"text","text":"after tool"} + ]}, + {"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_1","content":[ + {"type":"text","text":"tool output"}, + {"type":"image","source":{"media_type":"image/png","data":"aW1hZ2U="}} + ]}, + {"type":"text","text":"continue"} + ]} + ], + "tools": [{"name":"lookup","input_schema":{"type":"object"}}] + }` + + result := ConvertClaudeRequestToCodex("gpt-5.4", []byte(inputJSON), false) + inputs := gjson.GetBytes(result, "input").Array() + if len(inputs) != 8 { + t.Fatalf("got %d input items, want 8. Output: %s", len(inputs), result) + } + + wantTypes := []string{"message", "message", "reasoning", "message", "function_call", "message", "function_call_output", "message"} + for i := 0; i < len(wantTypes); i++ { + if got := inputs[i].Get("type").String(); got != wantTypes[i] { + t.Fatalf("input[%d].type = %q, want %q. Output: %s", i, got, wantTypes[i], result) + } + } + + if got := inputs[1].Get("content.0.text").String(); got != "before reasoning" { + t.Fatalf("input[1] text = %q, want before reasoning", got) + } + if got := inputs[3].Get("content.0.text").String(); got != "before tool" { + t.Fatalf("input[3] text = %q, want before tool", got) + } + if got := inputs[5].Get("content.0.text").String(); got != "after tool" { + t.Fatalf("input[5] text = %q, want after tool", got) + } + if got := inputs[6].Get("output.0.type").String(); got != "input_text" { + t.Fatalf("tool result output.0.type = %q, want input_text", got) + } + if got := inputs[6].Get("output.1.image_url").String(); got != "data:image/png;base64,aW1hZ2U=" { + t.Fatalf("tool result image_url = %q, want data URL", got) + } + if got := inputs[7].Get("content.0.text").String(); got != "continue" { + t.Fatalf("input[7] text = %q, want continue", got) + } +} + func TestConvertClaudeRequestToCodex_AssistantGrokSignatureToReasoningItem(t *testing.T) { signature := "HmlYdr2aCAqCYP/m9mr8PS6KOsdMs72FGDigmydR+Jsmuv8KX97yWPlbOwmXJgWn0CbHaCacdQD3+n5EvpgLfPNmafS3kdICBjRuDf4bzHy7uBiUhNVhqPtp/ee1y9q4imPE4LYgD1VZ4J+bp9mTeqA1+nC9Oue58CiNEMV9SVaGenCD+aBnVuSTzQhD32Y+68i6HLJW0Dx6ifaRfb8hxYtA/sPM+/FTvAMW11nRho5a2BBSkpnzfqqAz/e/vGJ77/bygpXM823QA9wL9i0X" payload := []byte(`{"model":"grok-4.5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)