fix: shorten claude codex tool call ids

This commit is contained in:
sususu98
2026-05-18 17:47:51 +08:00
parent 24602055a8
commit 8bc2eff58a
4 changed files with 137 additions and 4 deletions

View File

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

View File

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

View File

@@ -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) {

View File

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