Files
Luis Pater 9f1e890419 Merge pull request #4366 from Johnnybyzhang/fix/claude-codex-allocation-fix
perf(translator): 降低 Claude → Codex 请求转换的内存分配
2026-07-19 04:29:52 +08:00

675 lines
22 KiB
Go

package claude
import (
"encoding/base64"
"strings"
"testing"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func TestConvertClaudeRequestToCodex_SystemMessageScenarios(t *testing.T) {
tests := []struct {
name string
inputJSON string
wantHasDeveloper bool
wantTexts []string
}{
{
name: "No system field",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasDeveloper: false,
},
{
name: "Empty string system field",
inputJSON: `{
"model": "claude-3-opus",
"system": "",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasDeveloper: false,
},
{
name: "String system field",
inputJSON: `{
"model": "claude-3-opus",
"system": "Be helpful",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasDeveloper: true,
wantTexts: []string{"Be helpful"},
},
{
name: "Message system role does not become developer",
inputJSON: `{
"model": "claude-3-opus",
"messages": [
{"role": "system", "content": "Follow the project instructions"},
{"role": "user", "content": "hello"}
]
}`,
wantHasDeveloper: false,
},
{
name: "Array system field with filtered billing header",
inputJSON: `{
"model": "claude-3-opus",
"system": [
{"type": "text", "text": "x-anthropic-billing-header: tenant-123"},
{"type": "text", "text": "Block 1"},
{"type": "text", "text": "Block 2"}
],
"messages": [{"role": "user", "content": "hello"}]
}`,
wantHasDeveloper: true,
wantTexts: []string{"Block 1", "Block 2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
resultJSON := gjson.ParseBytes(result)
inputs := resultJSON.Get("input").Array()
hasDeveloper := len(inputs) > 0 && inputs[0].Get("role").String() == "developer"
if hasDeveloper != tt.wantHasDeveloper {
t.Fatalf("got hasDeveloper = %v, want %v. Output: %s", hasDeveloper, tt.wantHasDeveloper, resultJSON.Get("input").Raw)
}
if !tt.wantHasDeveloper {
return
}
content := inputs[0].Get("content").Array()
if len(content) != len(tt.wantTexts) {
t.Fatalf("got %d system content items, want %d. Content: %s", len(content), len(tt.wantTexts), inputs[0].Get("content").Raw)
}
for i, wantText := range tt.wantTexts {
if gotType := content[i].Get("type").String(); gotType != "input_text" {
t.Fatalf("content[%d] type = %q, want %q", i, gotType, "input_text")
}
if gotText := content[i].Get("text").String(); gotText != wantText {
t.Fatalf("content[%d] text = %q, want %q", i, gotText, wantText)
}
}
})
}
}
func TestConvertClaudeRequestToCodex_MessageSystemRoleWrapsAsUserReminder(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"system": [{"type": "text", "text": "Top-level rules"}],
"messages": [
{"role": "user", "content": "hello"},
{"role": "system", "content": "Follow the project instructions"},
{"role": "assistant", "content": [{"type": "text", "text": "ok"}]},
{"role": "system", "content": [{"type": "text", "text": "Use the current repo"}]}
]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
inputs := gjson.GetBytes(result, "input").Array()
if len(inputs) != 5 {
t.Fatalf("got %d input items, want 5: %s", len(inputs), gjson.GetBytes(result, "input").Raw)
}
if got := inputs[0].Get("role").String(); got != "developer" {
t.Fatalf("top-level system role = %q, want developer", got)
}
if got := inputs[2].Get("role").String(); got != "user" {
t.Fatalf("message-level system role = %q, want user", got)
}
if got := inputs[2].Get("content.0.text").String(); got != "<system-reminder>\nFollow the project instructions\n</system-reminder>" {
t.Fatalf("unexpected first reminder text: %q", got)
}
if got := inputs[4].Get("role").String(); got != "user" {
t.Fatalf("array message-level system role = %q, want user", got)
}
if got := inputs[4].Get("content.0.text").String(); got != "<system-reminder>\nUse the current repo\n</system-reminder>" {
t.Fatalf("unexpected second reminder text: %q", got)
}
}
func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) {
tests := []struct {
name string
inputJSON string
wantParallelToolCalls bool
}{
{
name: "Default to true when tool_choice.disable_parallel_tool_use is absent",
inputJSON: `{
"model": "claude-3-opus",
"messages": [{"role": "user", "content": "hello"}]
}`,
wantParallelToolCalls: true,
},
{
name: "Disable parallel tool calls when client opts out",
inputJSON: `{
"model": "claude-3-opus",
"tool_choice": {"disable_parallel_tool_use": true},
"messages": [{"role": "user", "content": "hello"}]
}`,
wantParallelToolCalls: false,
},
{
name: "Keep parallel tool calls enabled when client explicitly allows them",
inputJSON: `{
"model": "claude-3-opus",
"tool_choice": {"disable_parallel_tool_use": false},
"messages": [{"role": "user", "content": "hello"}]
}`,
wantParallelToolCalls: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("parallel_tool_calls").Bool(); got != tt.wantParallelToolCalls {
t.Fatalf("parallel_tool_calls = %v, want %v. Output: %s", got, tt.wantParallelToolCalls, string(result))
}
})
}
}
func TestConvertClaudeRequestToCodex_ServiceTier(t *testing.T) {
tests := []struct {
name string
serviceTierJSON string
speedJSON string
want string
wantExists bool
}{
{
name: "Priority passes through",
serviceTierJSON: `"priority"`,
want: "priority",
wantExists: true,
},
{
name: "Fast tier normalizes to priority",
serviceTierJSON: `"fast"`,
want: "priority",
wantExists: true,
},
{
name: "Unsupported tier is omitted",
serviceTierJSON: `"default"`,
},
{
name: "Non-string tier is omitted",
serviceTierJSON: `true`,
},
{
name: "Fast speed maps to priority",
speedJSON: `"fast"`,
want: "priority",
wantExists: true,
},
{
name: "Standard speed is omitted",
speedJSON: `"standard"`,
},
{
name: "Non-string speed is omitted",
speedJSON: `true`,
},
{
name: "Fast speed overrides unsupported Anthropic tier",
serviceTierJSON: `"auto"`,
speedJSON: `"fast"`,
want: "priority",
wantExists: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputJSON := []byte(`{
"model": "gpt-5.4",
"messages": [{"role": "user", "content": "Reply with OK"}]
}`)
if tt.serviceTierJSON != "" {
inputJSON, _ = sjson.SetRawBytes(inputJSON, "service_tier", []byte(tt.serviceTierJSON))
}
if tt.speedJSON != "" {
inputJSON, _ = sjson.SetRawBytes(inputJSON, "speed", []byte(tt.speedJSON))
}
result := ConvertClaudeRequestToCodex("gpt-5.4", inputJSON, false)
serviceTierResult := gjson.GetBytes(result, "service_tier")
if serviceTierResult.Exists() != tt.wantExists {
t.Fatalf("service_tier exists = %v, want %v. Output: %s", serviceTierResult.Exists(), tt.wantExists, string(result))
}
if !tt.wantExists {
return
}
if got := serviceTierResult.String(); got != tt.want {
t.Fatalf("service_tier = %q, want %q. Output: %s", got, tt.want, string(result))
}
})
}
}
func TestConvertClaudeRequestToCodex_ShortenLongToolUseIDs(t *testing.T) {
longID := "toolu_" + strings.Repeat("a", 62)
if len(longID) <= 64 {
t.Fatalf("test setup error: longID length = %d, want > 64", len(longID))
}
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{"role": "user", "content": [{"type":"text","text":"run pwd"}]},
{"role": "assistant", "content": [
{"type":"tool_use","id":"` + longID + `","name":"Bash","input":{"cmd":"pwd"}}
]},
{"role": "user", "content": [
{"type":"tool_result","tool_use_id":"` + longID + `","content":"ok"}
]}
]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
inputs := gjson.GetBytes(result, "input").Array()
var callID string
var outputCallID string
for _, item := range inputs {
switch item.Get("type").String() {
case "function_call":
callID = item.Get("call_id").String()
case "function_call_output":
outputCallID = item.Get("call_id").String()
}
}
if callID == "" {
t.Fatalf("missing function_call item. Output: %s", string(result))
}
if outputCallID == "" {
t.Fatalf("missing function_call_output item. Output: %s", string(result))
}
if callID != outputCallID {
t.Fatalf("call_id mismatch: function_call=%q function_call_output=%q. Output: %s", callID, outputCallID, string(result))
}
if len(callID) > 64 {
t.Fatalf("call_id length = %d, want <= 64: %q", len(callID), callID)
}
if callID == longID {
t.Fatalf("long call_id was not shortened: %q", callID)
}
}
func TestConvertClaudeRequestToCodex_ToolChoiceModeMapping(t *testing.T) {
tests := []struct {
name string
claudeToolChoice string
wantCodexToolChoice string
}{
{
name: "Any requires at least one tool",
claudeToolChoice: `{"type":"any"}`,
wantCodexToolChoice: "required",
},
{
name: "None disables tools",
claudeToolChoice: `{"type":"none"}`,
wantCodexToolChoice: "none",
},
{
name: "Auto stays auto",
claudeToolChoice: `{"type":"auto"}`,
wantCodexToolChoice: "auto",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"tools": [
{"name": "lookup", "description": "Lookup", "input_schema": {"type":"object","properties":{}}}
],
"tool_choice": ` + tt.claudeToolChoice + `,
"messages": [{"role": "user", "content": "hello"}]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tool_choice").String(); got != tt.wantCodexToolChoice {
t.Fatalf("tool_choice = %q, want %q. Output: %s", got, tt.wantCodexToolChoice, string(result))
}
})
}
}
func TestConvertClaudeRequestToCodex_ToolChoiceSpecificFunctionUsesConvertedName(t *testing.T) {
longName := "mcp__server_with_a_very_long_name_that_exceeds_sixty_four_characters__search"
inputJSON := `{
"model": "claude-3-opus",
"tools": [
{"name": "` + longName + `", "description": "Search", "input_schema": {"type":"object","properties":{}}}
],
"tool_choice": {"type":"tool","name":"` + longName + `"},
"messages": [{"role": "user", "content": "hello"}]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
}
toolName := resultJSON.Get("tools.0.name").String()
choiceName := resultJSON.Get("tool_choice.name").String()
if choiceName != toolName {
t.Fatalf("tool_choice.name = %q, want converted tool name %q. Output: %s", choiceName, toolName, string(result))
}
if choiceName == longName {
t.Fatalf("tool_choice.name should use shortened Codex tool name. Output: %s", string(result))
}
}
func TestConvertClaudeRequestToCodex_WebSearchToolMapping(t *testing.T) {
inputJSON := `{
"model": "claude-3-opus",
"tools": [
{
"type": "web_search_20260209",
"name": "web_search",
"allowed_domains": ["example.com"],
"blocked_domains": ["blocked.example"],
"user_location": {
"type": "approximate",
"city": "Beijing",
"country": "CN",
"timezone": "Asia/Shanghai"
}
}
],
"tool_choice": {"type":"tool","name":"web_search"},
"messages": [{"role": "user", "content": "hello"}]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tools.0.type").String(); got != "web_search" {
t.Fatalf("tools.0.type = %q, want web_search. Output: %s", got, string(result))
}
if got := resultJSON.Get("tools.0.filters.allowed_domains.0").String(); got != "example.com" {
t.Fatalf("tools.0.filters.allowed_domains.0 = %q, want example.com. Output: %s", got, string(result))
}
if resultJSON.Get("tools.0.blocked_domains").Exists() {
t.Fatalf("tools.0.blocked_domains should not be forwarded to Codex. Output: %s", string(result))
}
if got := resultJSON.Get("tools.0.user_location.city").String(); got != "Beijing" {
t.Fatalf("tools.0.user_location.city = %q, want Beijing. Output: %s", got, string(result))
}
if got := resultJSON.Get("tool_choice.type").String(); got != "web_search" {
t.Fatalf("tool_choice.type = %q, want web_search. Output: %s", got, string(result))
}
}
func TestConvertClaudeRequestToCodex_WebSearchToolChoiceUsesDeclaredTypedToolName(t *testing.T) {
inputJSON := `{
"model": "claude-opus-4-7",
"tools": [
{"type": "web_search_20250305", "name": "browser_search"},
{"name": "web_search", "description": "Local search", "input_schema": {"type":"object","properties":{}}}
],
"tool_choice": {"type":"tool","name":"web_search"},
"messages": [{"role": "user", "content": "hello"}]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
}
if got := resultJSON.Get("tool_choice.name").String(); got != "web_search" {
t.Fatalf("tool_choice.name = %q, want web_search. Output: %s", got, string(result))
}
}
func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) {
signature := validCodexReasoningSignature()
inputJSON := `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "visible summary must not be replayed",
"signature": "` + signature + `"
},
{
"type": "text",
"text": "visible answer"
}
]
},
{
"role": "user",
"content": "continue"
}
]
}`
result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
resultJSON := gjson.ParseBytes(result)
inputs := resultJSON.Get("input").Array()
if len(inputs) != 3 {
t.Fatalf("got %d input items, want 3. Output: %s", len(inputs), string(result))
}
reasoning := inputs[0]
if got := reasoning.Get("type").String(); got != "reasoning" {
t.Fatalf("first input type = %q, want reasoning. Output: %s", got, string(result))
}
if got := reasoning.Get("encrypted_content").String(); got != signature {
t.Fatalf("encrypted_content = %q, want %q", got, signature)
}
if got := reasoning.Get("summary").Raw; got != "[]" {
t.Fatalf("summary = %s, want []", got)
}
if got := reasoning.Get("content").Raw; got != "null" {
t.Fatalf("content = %s, want null", got)
}
assistantMessage := inputs[1]
if got := assistantMessage.Get("role").String(); got != "assistant" {
t.Fatalf("second input role = %q, want assistant. Output: %s", got, string(result))
}
if got := assistantMessage.Get("content.0.type").String(); got != "output_text" {
t.Fatalf("assistant content type = %q, want output_text", got)
}
if got := assistantMessage.Get("content.0.text").String(); got != "visible answer" {
t.Fatalf("assistant text = %q, want visible answer", got)
}
if strings.Contains(string(result), "visible summary must not be replayed") {
t.Fatalf("thinking text should not be replayed into Codex input. Output: %s", string(result))
}
}
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"}]}`)
payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
out := ConvertClaudeRequestToCodex("grok-4.5", payload, false)
reasoning := gjson.GetBytes(out, "input.0")
if reasoning.Get("type").String() != "reasoning" {
t.Fatalf("input.0 type = %q, want reasoning; output=%s", reasoning.Get("type").String(), out)
}
if got := reasoning.Get("encrypted_content").String(); got != signature {
t.Fatalf("encrypted_content = %q, want Grok signature", got)
}
}
func TestConvertClaudeRequestToCodex_IgnoresGrokSignatureForNonGrokTargets(t *testing.T) {
signature := "HmlYdr2aCAqCYP/m9mr8PS6KOsdMs72FGDigmydR+Jsmuv8KX97yWPlbOwmXJgWn0CbHaCacdQD3+n5EvpgLfPNmafS3kdICBjRuDf4bzHy7uBiUhNVhqPtp/ee1y9q4imPE4LYgD1VZ4J+bp9mTeqA1+nC9Oue58CiNEMV9SVaGenCD+aBnVuSTzQhD32Y+68i6HLJW0Dx6ifaRfb8hxYtA/sPM+/FTvAMW11nRho5a2BBSkpnzfqqAz/e/vGJ77/bygpXM823QA9wL9i0X"
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
for _, modelName := range []string{"gpt-5.4", "claude-sonnet-4-6"} {
t.Run(modelName, func(t *testing.T) {
out := ConvertClaudeRequestToCodex(modelName, payload, false)
if got := countRequestInputItemsByType(out, "reasoning"); got != 0 {
t.Fatalf("got %d reasoning items for non-Grok target, want 0; output=%s", got, out)
}
})
}
}
func TestConvertClaudeRequestToCodex_IgnoresNonCodexThinkingSignatures(t *testing.T) {
tests := []struct {
name string
inputJSON string
}{
{
name: "Ignore user thinking even with Codex-shaped signature",
inputJSON: `{
"model": "claude-3-opus",
"messages": [
{
"role": "user",
"content": [
{
"type": "thinking",
"thinking": "user supplied thinking",
"signature": "` + validCodexReasoningSignature() + `"
},
{
"type": "text",
"text": "hello"
}
]
}
]
}`,
},
{
name: "Ignore Anthropic native signature",
inputJSON: `{
"model": "claude-3-opus",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "anthropic thinking",
"signature": "Eo8Canthropic-state"
},
{
"type": "text",
"text": "visible answer"
}
]
}
]
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
if got := countRequestInputItemsByType(result, "reasoning"); got != 0 {
t.Fatalf("got %d reasoning items, want 0. Output: %s", got, string(result))
}
})
}
}
func countRequestInputItemsByType(result []byte, itemType string) int {
count := 0
gjson.GetBytes(result, "input").ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == itemType {
count++
}
return true
})
return count
}
func validCodexReasoningSignature() string {
raw := make([]byte, 1+8+16+16+32)
raw[0] = 0x80
raw[8] = 1
return base64.URLEncoding.EncodeToString(raw)
}