diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index 239c3e4d1..1e168f099 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -40,6 +40,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) template := []byte(`{"model":"","instructions":"","input":[]}`) rootResult := gjson.ParseBytes(rawJSON) + toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON) template, _ = sjson.SetBytes(template, "model", modelName) // Process system messages and convert them to input content format. @@ -174,8 +175,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", messageContentResult.Get("id").String()) { name := messageContentResult.Get("name").String() - toolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON) - if short, ok := toolMap[name]; ok { + if short, ok := toolNameMap[name]; ok { name = short } else { name = shortenNameIfNeeded(name) @@ -249,23 +249,14 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) toolsResult := rootResult.Get("tools") if toolsResult.IsArray() { template, _ = sjson.SetRawBytes(template, "tools", []byte(`[]`)) - template, _ = sjson.SetBytes(template, "tool_choice", `auto`) + webSearchToolNames := buildClaudeWebSearchToolNameSet(toolsResult) + template, _ = sjson.SetRawBytes(template, "tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames)) toolResults := toolsResult.Array() - // Build short name map from declared tools - var names []string - for i := 0; i < len(toolResults); i++ { - n := toolResults[i].Get("name").String() - if n != "" { - names = append(names, n) - } - } - shortMap := buildShortNameMap(names) for i := 0; i < len(toolResults); i++ { toolResult := toolResults[i] // Special handling: map Claude web search tool to Codex web_search - if toolResult.Get("type").String() == "web_search_20250305" { - // Replace the tool content entirely with {"type":"web_search"} - template, _ = sjson.SetRawBytes(template, "tools.-1", []byte(`{"type":"web_search"}`)) + if isClaudeWebSearchToolType(toolResult.Get("type").String()) { + template, _ = sjson.SetRawBytes(template, "tools.-1", convertClaudeWebSearchToolToCodex(toolResult)) continue } tool := []byte(toolResult.Raw) @@ -273,7 +264,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Apply shortened name if needed if v := toolResult.Get("name"); v.Exists() { name := v.String() - if short, ok := shortMap[name]; ok { + if short, ok := toolNameMap[name]; ok { name = short } else { name = shortenNameIfNeeded(name) @@ -370,6 +361,81 @@ func isFernetLikeReasoningSignature(signature string) bool { return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0 } +func isClaudeWebSearchToolType(toolType string) bool { + return toolType == "web_search_20250305" || toolType == "web_search_20260209" +} + +func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} { + names := map[string]struct{}{} + if !tools.IsArray() { + return names + } + + tools.ForEach(func(_, tool gjson.Result) bool { + toolType := tool.Get("type").String() + if !isClaudeWebSearchToolType(toolType) { + return true + } + + if name := tool.Get("name").String(); name != "" { + names[name] = struct{}{} + } + return true + }) + + return names +} + +func convertClaudeToolChoiceToCodex(toolChoice gjson.Result, toolNameMap map[string]string, webSearchToolNames map[string]struct{}) []byte { + if !toolChoice.Exists() || toolChoice.Type == gjson.Null { + return []byte(`"auto"`) + } + + choiceType := toolChoice.Get("type").String() + if choiceType == "" && toolChoice.Type == gjson.String { + choiceType = toolChoice.String() + } + + switch choiceType { + case "auto", "": + return []byte(`"auto"`) + case "any": + return []byte(`"required"`) + case "none": + return []byte(`"none"`) + case "tool": + name := toolChoice.Get("name").String() + if _, ok := webSearchToolNames[name]; ok { + return []byte(`{"type":"web_search"}`) + } + if short, ok := toolNameMap[name]; ok { + name = short + } else { + name = shortenNameIfNeeded(name) + } + if name == "" { + return []byte(`"auto"`) + } + + choice := []byte(`{"type":"function","name":""}`) + choice, _ = sjson.SetBytes(choice, "name", name) + return choice + default: + return []byte(`"auto"`) + } +} + +func convertClaudeWebSearchToolToCodex(tool gjson.Result) []byte { + out := []byte(`{"type":"web_search"}`) + if allowedDomains := tool.Get("allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() { + out, _ = sjson.SetRawBytes(out, "filters.allowed_domains", []byte(allowedDomains.Raw)) + } + if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() { + out, _ = sjson.SetRawBytes(out, "user_location", []byte(userLocation.Raw)) + } + return out +} + // shortenNameIfNeeded applies a simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { const limit = 64 diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 85d10267f..16bb46c9e 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -136,6 +136,140 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) { } } +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 := `{ diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index e48a56f8b..a401a1b7e 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -68,7 +68,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa params := (*param).(*ConvertCodexResponseToClaudeParams) if params.ThinkingBlockOpen && params.ThinkingStopPending { switch rootResult.Get("type").String() { - case "response.content_part.added", "response.completed": + case "response.content_part.added", "response.completed", "response.incomplete": output = append(output, finalizeCodexThinkingBlock(params)...) } } @@ -117,18 +117,12 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa params.BlockIndex++ output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) - } else if typeStr == "response.completed" { + } else if typeStr == "response.completed" || typeStr == "response.incomplete" { template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) - p := params.HasToolCall - stopReason := rootResult.Get("response.stop_reason").String() - if p { - template, _ = sjson.SetBytes(template, "delta.stop_reason", "tool_use") - } else if stopReason == "max_tokens" || stopReason == "stop" { - template, _ = sjson.SetBytes(template, "delta.stop_reason", stopReason) - } else { - template, _ = sjson.SetBytes(template, "delta.stop_reason", "end_turn") - } - inputTokens, outputTokens, cachedTokens := extractResponsesUsage(rootResult.Get("response.usage")) + responseData := rootResult.Get("response") + template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasToolCall)) + template = setClaudeStopSequence(template, "delta.stop_sequence", responseData) + inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage")) template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens) template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens) if cachedTokens > 0 { @@ -259,7 +253,8 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) rootResult := gjson.ParseBytes(rawJSON) - if rootResult.Get("type").String() != "response.completed" { + typeStr := rootResult.Get("type").String() + if typeStr != "response.completed" && typeStr != "response.incomplete" { return []byte{} } @@ -371,18 +366,57 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original }) } + out, _ = sjson.SetBytes(out, "stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), hasToolCall)) + out = setClaudeStopSequence(out, "stop_sequence", responseData) + + return out +} + +func codexStopReason(responseData gjson.Result) string { if stopReason := responseData.Get("stop_reason"); stopReason.Exists() && stopReason.String() != "" { - out, _ = sjson.SetBytes(out, "stop_reason", stopReason.String()) - } else if hasToolCall { - out, _ = sjson.SetBytes(out, "stop_reason", "tool_use") - } else { - out, _ = sjson.SetBytes(out, "stop_reason", "end_turn") + if stopReason.String() == "stop" && codexStopSequence(responseData).String() != "" { + return "stop_sequence" + } + return stopReason.String() + } + if reason := responseData.Get("incomplete_details.reason"); reason.Exists() && reason.String() != "" { + return reason.String() + } + if codexStopSequence(responseData).String() != "" { + return "stop_sequence" + } + return "" +} + +func mapCodexStopReasonToClaude(stopReason string, hasToolCall bool) string { + if hasToolCall { + return "tool_use" } - if stopSequence := responseData.Get("stop_sequence"); stopSequence.Exists() && stopSequence.String() != "" { - out, _ = sjson.SetRawBytes(out, "stop_sequence", []byte(stopSequence.Raw)) + switch stopReason { + case "", "stop", "completed": + return "end_turn" + case "max_tokens", "max_output_tokens": + return "max_tokens" + case "tool_use", "tool_calls", "function_call": + return "tool_use" + case "end_turn", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded": + return stopReason + case "content_filter": + return "refusal" + default: + return "end_turn" } +} +func codexStopSequence(responseData gjson.Result) gjson.Result { + return responseData.Get("stop_sequence") +} + +func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) []byte { + if stopSequence := codexStopSequence(responseData); stopSequence.Exists() && stopSequence.String() != "" { + out, _ = sjson.SetRawBytes(out, path, []byte(stopSequence.Raw)) + } return out } diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index bbd71da08..565e8156b 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -458,3 +458,207 @@ func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessage t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs) } } + +func TestConvertCodexResponseToClaude_StreamStopReasonMapping(t *testing.T) { + tests := []struct { + name string + chunks [][]byte + wantReason string + }{ + { + name: "Stop maps to end_turn", + chunks: [][]byte{ + []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + }, + wantReason: "end_turn", + }, + { + name: "Incomplete max output maps to max_tokens", + chunks: [][]byte{ + []byte("data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + }, + wantReason: "max_tokens", + }, + { + name: "Tool call wins over stop", + chunks: [][]byte{ + []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"lookup\"}}"), + []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + }, + wantReason: "tool_use", + }, + { + name: "Content filter maps to Claude refusal", + chunks: [][]byte{ + []byte("data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"content_filter\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + }, + wantReason: "refusal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`) + var param any + var outputs [][]byte + + for _, chunk := range tt.chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + got, ok := findClaudeStreamStopReason(outputs) + if !ok { + t.Fatalf("did not find message_delta stop_reason; outputs=%q", outputs) + } + if got != tt.wantReason { + t.Fatalf("stop_reason = %q, want %q. Outputs=%q", got, tt.wantReason, outputs) + } + }) + } +} + +func TestConvertCodexResponseToClaude_StreamStopSequenceMapping(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + var param any + + outputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"stop_sequence\":\"\\nEND\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), ¶m) + messageDelta, ok := findClaudeStreamMessageDelta(outputs) + if !ok { + t.Fatalf("did not find message_delta; outputs=%q", outputs) + } + if got := messageDelta.Get("delta.stop_reason").String(); got != "stop_sequence" { + t.Fatalf("stop_reason = %q, want stop_sequence. Outputs=%q", got, outputs) + } + if got := messageDelta.Get("delta.stop_sequence").String(); got != "\nEND" { + t.Fatalf("stop_sequence = %q, want newline END. Outputs=%q", got, outputs) + } +} + +func TestConvertCodexResponseToClaudeNonStream_StopReasonMapping(t *testing.T) { + tests := []struct { + name string + response []byte + wantReason string + }{ + { + name: "Stop maps to end_turn", + response: []byte(`{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[] + } + }`), + wantReason: "end_turn", + }, + { + name: "Incomplete max output maps to max_tokens", + response: []byte(`{ + "type":"response.incomplete", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "incomplete_details":{"reason":"max_output_tokens"}, + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[] + } + }`), + wantReason: "max_tokens", + }, + { + name: "Tool call wins over stop", + response: []byte(`{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"}] + } + }`), + wantReason: "tool_use", + }, + { + name: "Content filter maps to Claude refusal", + response: []byte(`{ + "type":"response.incomplete", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "incomplete_details":{"reason":"content_filter"}, + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[] + } + }`), + wantReason: "refusal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`) + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, tt.response, nil) + parsed := gjson.ParseBytes(out) + + if got := parsed.Get("stop_reason").String(); got != tt.wantReason { + t.Fatalf("stop_reason = %q, want %q. Output: %s", got, tt.wantReason, string(out)) + } + }) + } +} + +func TestConvertCodexResponseToClaudeNonStream_StopSequenceMapping(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"messages":[]}`) + response := []byte(`{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "stop_reason":"stop", + "stop_sequence":"\nEND", + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[] + } + }`) + + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil) + parsed := gjson.ParseBytes(out) + + if got := parsed.Get("stop_reason").String(); got != "stop_sequence" { + t.Fatalf("stop_reason = %q, want stop_sequence. Output: %s", got, string(out)) + } + if got := parsed.Get("stop_sequence").String(); got != "\nEND" { + t.Fatalf("stop_sequence = %q, want newline END. Output: %s", got, string(out)) + } +} + +func findClaudeStreamStopReason(outputs [][]byte) (string, bool) { + messageDelta, ok := findClaudeStreamMessageDelta(outputs) + if !ok { + return "", false + } + return messageDelta.Get("delta.stop_reason").String(), true +} + +func findClaudeStreamMessageDelta(outputs [][]byte) (gjson.Result, bool) { + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if data.Get("type").String() == "message_delta" { + return data, true + } + } + } + return gjson.Result{}, false +}