fix: align claude codex translation

This commit is contained in:
sususu98
2026-04-29 18:47:03 +08:00
parent 2ea8f77efb
commit 1c0c426b85
4 changed files with 454 additions and 36 deletions

View File

@@ -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,83 @@ 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
}
names["web_search"] = struct{}{}
names[toolType] = struct{}{}
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

View File

@@ -136,6 +136,118 @@ 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_AssistantThinkingSignatureToReasoningItem(t *testing.T) {
signature := validCodexReasoningSignature()
inputJSON := `{

View File

@@ -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
}

View File

@@ -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, &param)...)
}
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}}}"), &param)
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
}