From 8bc2eff58a02a92a56ed8ee36aea1cb2f566fba0 Mon Sep 17 00:00:00 2001 From: sususu98 Date: Mon, 18 May 2026 17:47:51 +0800 Subject: [PATCH] fix: shorten claude codex tool call ids --- .../codex/claude/codex_claude_request.go | 23 ++++++- .../codex/claude/codex_claude_request_test.go | 50 +++++++++++++++ .../codex/claude/codex_claude_response.go | 4 +- .../claude/codex_claude_response_test.go | 64 +++++++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index b74f35c90..3a40a5130 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -6,7 +6,9 @@ package claude import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "strconv" "strings" @@ -173,7 +175,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) case "tool_use": flushMessage() functionCallMessage := []byte(`{"type":"function_call"}`) - functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", messageContentResult.Get("id").String()) + functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", shortenCodexCallIDIfNeeded(messageContentResult.Get("id").String())) { name := messageContentResult.Get("name").String() if short, ok := toolNameMap[name]; ok { @@ -188,7 +190,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) case "tool_result": flushMessage() functionCallOutputMessage := []byte(`{"type":"function_call_output"}`) - functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "call_id", messageContentResult.Get("tool_use_id").String()) + functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "call_id", shortenCodexCallIDIfNeeded(messageContentResult.Get("tool_use_id").String())) contentResult := messageContentResult.Get("content") if contentResult.IsArray() { @@ -362,6 +364,23 @@ func isFernetLikeReasoningSignature(signature string) bool { return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0 } +// shortenCodexCallIDIfNeeded keeps Claude tool IDs within the OpenAI Responses +// API call_id limit while preserving a stable, low-collision mapping. +func shortenCodexCallIDIfNeeded(id string) string { + const limit = 64 + if len(id) <= limit { + return id + } + + sum := sha256.Sum256([]byte(id)) + suffix := "_" + hex.EncodeToString(sum[:8]) + prefixLen := limit - len(suffix) + if prefixLen <= 0 { + return suffix[len(suffix)-limit:] + } + return id[:prefixLen] + suffix +} + func isClaudeWebSearchToolType(toolType string) bool { return toolType == "web_search_20250305" || toolType == "web_search_20260209" } diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go index 16bb46c9e..9e2a0a336 100644 --- a/internal/translator/codex/claude/codex_claude_request_test.go +++ b/internal/translator/codex/claude/codex_claude_request_test.go @@ -136,6 +136,56 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) { } } +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 diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 7a40ca4c5..3cf591ee9 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -140,7 +140,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa params.HasReceivedArgumentsDelta = false template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) - template, _ = sjson.SetBytes(template, "content_block.id", util.SanitizeClaudeToolID(itemResult.Get("call_id").String())) + template, _ = sjson.SetBytes(template, "content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(itemResult.Get("call_id").String()))) { name := itemResult.Get("name").String() rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) @@ -350,7 +350,7 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original } toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) - toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(item.Get("call_id").String())) + toolBlock, _ = sjson.SetBytes(toolBlock, "id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(item.Get("call_id").String()))) toolBlock, _ = sjson.SetBytes(toolBlock, "name", name) inputRaw := "{}" if argsStr := item.Get("arguments").String(); argsStr != "" && gjson.Valid(argsStr) { diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index 565e8156b..e08734df3 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -459,6 +459,70 @@ func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessage } } +func TestConvertCodexResponseToClaude_ShortensLongToolUseIDs(t *testing.T) { + longCallID := "call_" + strings.Repeat("a", 62) + if len(longCallID) <= 64 { + t.Fatalf("test setup error: longCallID length = %d, want > 64", len(longCallID)) + } + + t.Run("stream", func(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`) + var param any + + outputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"`+longCallID+`","name":"lookup"}}`), ¶m) + + toolID := "" + 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() == "content_block_start" && data.Get("content_block.type").String() == "tool_use" { + toolID = data.Get("content_block.id").String() + } + } + } + + if toolID == "" { + t.Fatalf("missing stream tool_use block. Outputs=%q", outputs) + } + if len(toolID) > 64 { + t.Fatalf("stream tool_use id length = %d, want <= 64: %q", len(toolID), toolID) + } + if toolID == longCallID { + t.Fatalf("stream tool_use id was not shortened: %q", toolID) + } + }) + + t.Run("nonstream", func(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`) + response := []byte(`{ + "type":"response.completed", + "response":{ + "id":"resp_1", + "model":"gpt-5", + "usage":{"input_tokens":1,"output_tokens":1}, + "output":[{"type":"function_call","call_id":"` + longCallID + `","name":"lookup","arguments":"{}"}] + } + }`) + + out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil) + toolID := gjson.GetBytes(out, "content.0.id").String() + if toolID == "" { + t.Fatalf("missing nonstream tool_use id. Output: %s", string(out)) + } + if len(toolID) > 64 { + t.Fatalf("nonstream tool_use id length = %d, want <= 64: %q", len(toolID), toolID) + } + if toolID == longCallID { + t.Fatalf("nonstream tool_use id was not shortened: %q", toolID) + } + }) +} + func TestConvertCodexResponseToClaude_StreamStopReasonMapping(t *testing.T) { tests := []struct { name string