From 369df975eac7f258dc1f8fd3945c6b9eb3fbdf80 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 30 Sep 2026 12:33:25 +0800 Subject: [PATCH] fix(claude): map and disambiguate tool names in responses translation - Map OpenAI Responses tool identities to valid Claude tool names within the 64-character limit - Resolve naming collisions and truncate long tool names using deterministic SHA-256 hashes - Maintain bidirectional tool mapping across declarations, tool choices, history, and response decoding Closes: #6209 --- .../claude_openai-responses_request.go | 41 ++-- .../claude_openai-responses_request_test.go | 195 +++++++++++++++++ .../claude_openai-responses_tool_names.go | 139 ++++++++++++ ...claude_openai-responses_tool_names_test.go | 199 ++++++++++++++++++ 4 files changed, 560 insertions(+), 14 deletions(-) create mode 100644 internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go create mode 100644 internal/translator/claude/openai/responses/claude_openai-responses_tool_names_test.go diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 8534f406db..1ad73f0a6b 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -190,6 +190,8 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte appendSystemText(formatInstruction, gjson.Result{}) } + names := buildClaudeToolNames(root) + // input array processing var pendingRole string var pendingParts [][]byte @@ -463,7 +465,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) toolUse, _ = sjson.SetBytes(toolUse, "id", callID) - toolUse, _ = sjson.SetBytes(toolUse, "name", util.SanitizeClaudeFunctionName(name)) + toolUse, _ = sjson.SetBytes(toolUse, "name", names.claudeName(name)) if isCustomToolCall { toolUse, _ = sjson.SetBytes(toolUse, "input.input", item.Get("input").String()) } else { @@ -563,12 +565,14 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte if !ok || winner.order != descriptor.order { continue } - tJSON, ok := convertResponsesToolDescriptorToClaude(descriptor) + claudeName := names.claudeName(descriptor.name) + tJSON, ok := convertResponsesToolDescriptorToClaude(descriptor, claudeName) if !ok { continue } toolName := gjson.GetBytes(tJSON, "name").String() if toolName != "" { + includedToolNames[descriptor.name] = struct{}{} includedToolNames[toolName] = struct{}{} } toolItems = append(toolItems, tJSON) @@ -617,7 +621,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } if _, ok := includedToolNames[fn]; ok { toolChoiceJSON := []byte(`{"name":"","type":"tool"}`) - toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", util.SanitizeClaudeFunctionName(fn)) + toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", names.claudeName(fn)) out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON) } } @@ -1255,9 +1259,9 @@ func isOpenAIResponsesApplyPatchCustomTool(toolType string, tool gjson.Result) b return toolType == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch" } -func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor) ([]byte, bool) { - overrideName := "" - if !descriptor.direct { +func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor, claudeName string) ([]byte, bool) { + overrideName := claudeName + if overrideName == "" && !descriptor.direct { overrideName = descriptor.name } switch descriptor.toolType { @@ -1449,9 +1453,14 @@ func responsesToolNameMap(root gjson.Result, acceptedToolNames map[string]struct func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} { names := make(map[string]struct{}) root := gjson.ParseBytes(requestRawJSON) - for name, descriptor := range responsesToolWinners(root) { + winners := responsesToolWinners(root) + toolNames := buildClaudeToolNamesWithWinners(root, winners) + for name, descriptor := range winners { if descriptor.toolType == "custom" { names[name] = struct{}{} + if cName := toolNames.claudeName(name); cName != "" { + names[cName] = struct{}{} + } } } return names @@ -1535,14 +1544,14 @@ func unwrapCustomToolInput(arguments string) string { func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) { name := strings.TrimSpace(overrideName) if name == "" { - name = responsesToolName(tool) + name = util.SanitizeClaudeFunctionName(responsesToolName(tool)) } if name == "" { return nil, false } tJSON := []byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{}}}`) - tJSON, _ = sjson.SetBytes(tJSON, "name", util.SanitizeClaudeFunctionName(name)) + tJSON, _ = sjson.SetBytes(tJSON, "name", name) if d := responsesToolDescription(tool); d != "" { tJSON, _ = sjson.SetBytes(tJSON, "description", d) } @@ -1557,14 +1566,14 @@ func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string func convertResponsesCustomToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) { name := strings.TrimSpace(overrideName) if name == "" { - name = responsesToolName(tool) + name = util.SanitizeClaudeFunctionName(responsesToolName(tool)) } if name == "" { return nil, false } tJSON := []byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}`) - tJSON, _ = sjson.SetBytes(tJSON, "name", util.SanitizeClaudeFunctionName(name)) + tJSON, _ = sjson.SetBytes(tJSON, "name", name) if description := responsesToolDescription(tool); description != "" { tJSON, _ = sjson.SetBytes(tJSON, "description", description) } @@ -1645,14 +1654,18 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali } root := gjson.ParseBytes(requestRawJSON) - descriptor, ok := responsesToolWinners(root)[qualifiedName] + winners := responsesToolWinners(root) + toolNames := buildClaudeToolNamesWithWinners(root, winners) + identity := toolNames.identity(qualifiedName) + + descriptor, ok := winners[identity] if !ok { - return qualifiedName, "" + return identity, "" } if !descriptor.direct { return descriptor.childName, descriptor.namespace } - return qualifiedName, "" + return identity, "" } func isUnsupportedOpenAIBuiltinToolType(toolType string) bool { diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index a8097888b2..fa9e5057f8 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -1,6 +1,7 @@ package responses import ( + "context" "encoding/base64" "fmt" "strings" @@ -2603,3 +2604,197 @@ func TestConvertOpenAIResponsesRequestToClaude_StringInput(t *testing.T) { t.Fatalf("unexpected content for complex input, got %q", got) } } + +func TestConvertOpenAIResponsesRequestToClaude_LongToolNamesUniqueAndReversible(t *testing.T) { + reqJSON := []byte(`{ + "model": "claude-sonnet-5-5", + "input": "test", + "stream": false, + "tools": [{ + "type": "namespace", + "name": "mcp__example_apps__acme_inventory_service", + "tools": [ + {"type": "function", "name": "acme_inventory_service_get_item_prices", "description": "prices", "parameters": {"type": "object", "properties": {}}}, + {"type": "function", "name": "acme_inventory_service_get_item_metrics", "description": "metrics", "parameters": {"type": "object", "properties": {}}} + ] + }] + }`) + + out := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-5-5", reqJSON, false) + tools := gjson.GetBytes(out, "tools").Array() + if len(tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(tools)) + } + + name0 := tools[0].Get("name").String() + name1 := tools[1].Get("name").String() + + if len(name0) > 64 || len(name1) > 64 { + t.Fatalf("tool names must not exceed 64 chars, got %d and %d", len(name0), len(name1)) + } + if name0 == name1 { + t.Fatalf("tool names must be unique, got duplicate name %q for both tools", name0) + } + + child0, ns0 := splitResponsesQualifiedFunctionCallFromRequest(reqJSON, name0) + if child0 != "acme_inventory_service_get_item_prices" || ns0 != "mcp__example_apps__acme_inventory_service" { + t.Fatalf("splitResponsesQualifiedFunctionCallFromRequest(%q) = (%q, %q), want (%q, %q)", + name0, child0, ns0, "acme_inventory_service_get_item_prices", "mcp__example_apps__acme_inventory_service") + } + + child1, ns1 := splitResponsesQualifiedFunctionCallFromRequest(reqJSON, name1) + if child1 != "acme_inventory_service_get_item_metrics" || ns1 != "mcp__example_apps__acme_inventory_service" { + t.Fatalf("splitResponsesQualifiedFunctionCallFromRequest(%q) = (%q, %q), want (%q, %q)", + name1, child1, ns1, "acme_inventory_service_get_item_metrics", "mcp__example_apps__acme_inventory_service") + } + + // Verify non-stream response restores the original tool name and namespace + claudeResp := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_test_123","usage":{"input_tokens":10,"output_tokens":5}}}`, + fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_test_0","name":%q,"input":{}}}`, name0), + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"item_id\":\"42\"}"}}`, + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"message_stop"}`, + }, "\n")) + translatedResp := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-sonnet-5-5", reqJSON, nil, claudeResp, nil) + outputItem := gjson.GetBytes(translatedResp, "output.0") + if outputItem.Get("type").String() != "function_call" { + t.Fatalf("output.0.type = %q, want function_call", outputItem.Get("type").String()) + } + if outputItem.Get("name").String() != "acme_inventory_service_get_item_prices" { + t.Fatalf("output.0.name = %q, want acme_inventory_service_get_item_prices", outputItem.Get("name").String()) + } + if outputItem.Get("namespace").String() != "mcp__example_apps__acme_inventory_service" { + t.Fatalf("output.0.namespace = %q, want mcp__example_apps__acme_inventory_service", outputItem.Get("namespace").String()) + } + + // Verify stream response restores the original tool name and namespace + streamChunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_stream_123","usage":{"input_tokens":10,"output_tokens":5}}}`), + []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_test_1","name":%q,"input":{}}}`, name1)), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"metric\":\"latency\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_stop"}`), + } + var param any + var streamAdded, streamDone, streamCompleted gjson.Result + for _, chunk := range streamChunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-sonnet-5-5", reqJSON, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + switch event { + case "response.output_item.added": + if data.Get("item.type").String() == "function_call" { + streamAdded = data + } + case "response.output_item.done": + if data.Get("item.type").String() == "function_call" { + streamDone = data + } + case "response.completed": + streamCompleted = data + } + } + } + if !streamAdded.Exists() || streamAdded.Get("item.name").String() != "acme_inventory_service_get_item_metrics" || streamAdded.Get("item.namespace").String() != "mcp__example_apps__acme_inventory_service" { + t.Fatalf("stream added event mismatch: name=%q, namespace=%q", streamAdded.Get("item.name").String(), streamAdded.Get("item.namespace").String()) + } + if !streamDone.Exists() || streamDone.Get("item.name").String() != "acme_inventory_service_get_item_metrics" || streamDone.Get("item.namespace").String() != "mcp__example_apps__acme_inventory_service" { + t.Fatalf("stream done event mismatch: name=%q, namespace=%q", streamDone.Get("item.name").String(), streamDone.Get("item.namespace").String()) + } + if !streamCompleted.Exists() || streamCompleted.Get("response.output.0.name").String() != "acme_inventory_service_get_item_metrics" || streamCompleted.Get("response.output.0.namespace").String() != "mcp__example_apps__acme_inventory_service" { + t.Fatalf("stream completed event mismatch: name=%q, namespace=%q", streamCompleted.Get("response.output.0.name").String(), streamCompleted.Get("response.output.0.namespace").String()) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_SanitizationCollisions(t *testing.T) { + reqJSON := []byte(`{ + "model": "claude-sonnet-5-5", + "input": "test", + "stream": false, + "tools": [ + {"type": "function", "name": "a_b", "description": "ab", "parameters": {"type": "object", "properties": {}}}, + {"type": "function", "name": "a.b", "description": "a.b", "parameters": {"type": "object", "properties": {}}} + ] + }`) + + out := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-5-5", reqJSON, false) + tools := gjson.GetBytes(out, "tools").Array() + if len(tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(tools)) + } + + name0 := tools[0].Get("name").String() + name1 := tools[1].Get("name").String() + + if name0 == name1 { + t.Fatalf("tool names must not collide, got %q for both", name0) + } + if name0 != "a_b" { + t.Fatalf("tool 0 name = %q, want a_b", name0) + } + if !strings.HasPrefix(name1, "a_b_") { + t.Fatalf("tool 1 name = %q, want a_b_", name1) + } + + child0, ns0 := splitResponsesQualifiedFunctionCallFromRequest(reqJSON, name0) + if child0 != "a_b" || ns0 != "" { + t.Fatalf("splitResponsesQualifiedFunctionCallFromRequest(%q) = (%q, %q), want (a_b, \"\")", name0, child0, ns0) + } + + child1, ns1 := splitResponsesQualifiedFunctionCallFromRequest(reqJSON, name1) + if child1 != "a.b" || ns1 != "" { + t.Fatalf("splitResponsesQualifiedFunctionCallFromRequest(%q) = (%q, %q), want (a.b, \"\")", name1, child1, ns1) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_LongToolChoiceAndHistory(t *testing.T) { + reqJSON := []byte(`{ + "model": "claude-sonnet-5-5", + "input": [ + {"type": "message", "role": "user", "content": "check item"}, + {"type": "function_call", "call_id": "call_123", "namespace": "mcp__example_apps__acme_inventory_service", "name": "acme_inventory_service_get_item_prices", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_123", "output": "{\"price\": 100}"} + ], + "stream": false, + "tool_choice": { + "type": "function", + "namespace": "mcp__example_apps__acme_inventory_service", + "name": "acme_inventory_service_get_item_prices" + }, + "tools": [{ + "type": "namespace", + "name": "mcp__example_apps__acme_inventory_service", + "tools": [ + {"type": "function", "name": "acme_inventory_service_get_item_prices", "description": "prices", "parameters": {"type": "object", "properties": {}}}, + {"type": "function", "name": "acme_inventory_service_get_item_metrics", "description": "metrics", "parameters": {"type": "object", "properties": {}}} + ] + }] + }`) + + out := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-5-5", reqJSON, false) + + // Tool definition name for prices + declaredPricesName := gjson.GetBytes(out, "tools.0.name").String() + + // tool_choice should have the exact same Claude name as the declared tool + toolChoiceName := gjson.GetBytes(out, "tool_choice.name").String() + if toolChoiceName != declaredPricesName { + t.Fatalf("tool_choice.name = %q, want %q", toolChoiceName, declaredPricesName) + } + + // In messages history, the assistant tool_use block should have the exact same Claude name + messages := gjson.GetBytes(out, "messages").Array() + var foundHistoryToolUseName string + for _, msg := range messages { + if msg.Get("role").String() == "assistant" { + for _, part := range msg.Get("content").Array() { + if part.Get("type").String() == "tool_use" { + foundHistoryToolUseName = part.Get("name").String() + } + } + } + } + if foundHistoryToolUseName != declaredPricesName { + t.Fatalf("history tool_use.name = %q, want %q", foundHistoryToolUseName, declaredPricesName) + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go new file mode 100644 index 0000000000..b7a6b33248 --- /dev/null +++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go @@ -0,0 +1,139 @@ +package responses + +import ( + "crypto/sha256" + "encoding/hex" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + "github.com/tidwall/gjson" +) + +var claudeToolNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +// claudeToolNames maps qualified Responses tool identities to unique Claude +// tool names for one request, and back. The request and response translators +// build it from the same Responses JSON, so both sides agree. +type claudeToolNames struct { + toClaude map[string]string + fromClaude map[string]string +} + +func buildClaudeToolNames(root gjson.Result) claudeToolNames { + return buildClaudeToolNamesWithWinners(root, responsesToolWinners(root)) +} + +func buildClaudeToolNamesWithWinners(root gjson.Result, winners map[string]responsesToolDescriptor) claudeToolNames { + m := claudeToolNames{toClaude: map[string]string{}, fromClaude: map[string]string{}} + taken := map[string]bool{} + var declared []string + for _, d := range responsesToolDescriptors(root) { + if w, ok := winners[d.name]; !ok || w.order != d.order { + continue + } + switch d.toolType { + case "function", "custom": + declared = append(declared, d.name) + default: + m.assign(d.name, d.name, taken) + } + } + m.allocate(declared, taken) + m.allocate(responsesHistoryToolIdentities(root), taken) + return m +} + +// claudeName returns the Claude name for a qualified Responses identity. +func (m claudeToolNames) claudeName(identity string) string { + if name, ok := m.toClaude[identity]; ok { + return name + } + return util.SanitizeClaudeFunctionName(identity) +} + +// identity returns the qualified Responses identity for a Claude name. +// Unknown names are returned unchanged. +func (m claudeToolNames) identity(claudeName string) string { + if identity, ok := m.fromClaude[claudeName]; ok { + return identity + } + return claudeName +} + +func (m claudeToolNames) assign(identity, name string, taken map[string]bool) { + m.toClaude[identity] = name + m.fromClaude[name] = identity + taken[name] = true +} + +func (m claudeToolNames) allocate(identities []string, taken map[string]bool) { + var changed []string + seen := map[string]bool{} + for _, id := range identities { + if _, done := m.toClaude[id]; done || id == "" || seen[id] { + continue + } + seen[id] = true + if claudeToolNamePattern.MatchString(id) && !taken[id] { + m.assign(id, id, taken) + continue + } + changed = append(changed, id) + } + count := map[string]int{} + for _, id := range changed { + count[util.SanitizeClaudeFunctionName(id)]++ + } + var hashed []string + for _, id := range changed { + if base := util.SanitizeClaudeFunctionName(id); count[base] == 1 && !taken[base] { + m.assign(id, base, taken) + } else { + hashed = append(hashed, id) + } + } + sort.Strings(hashed) + for _, id := range hashed { + base := util.SanitizeClaudeFunctionName(id) + if len(base) > 53 { + base = base[:53] + } + for n := 0; ; n++ { + seed := id + if n > 0 { + seed += "\x00" + strconv.Itoa(n) + } + sum := sha256.Sum256([]byte(seed)) + if name := base + "_" + hex.EncodeToString(sum[:])[:10]; !taken[name] { + m.assign(id, name, taken) + break + } + } + } +} + +// responsesHistoryToolIdentities lists qualified names of function_call and +// custom_tool_call items in input, in order. +func responsesHistoryToolIdentities(root gjson.Result) []string { + var ids []string + input := root.Get("input") + if input.Exists() && input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + switch item.Get("type").String() { + case "function_call", "custom_tool_call": + name := item.Get("name").String() + if ns := strings.TrimSpace(item.Get("namespace").String()); ns != "" { + name = qualifyResponsesNamespaceToolName(ns, name) + } + if name != "" { + ids = append(ids, name) + } + } + return true + }) + } + return ids +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_names_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names_test.go new file mode 100644 index 0000000000..8ecfc34bf1 --- /dev/null +++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names_test.go @@ -0,0 +1,199 @@ +package responses + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestBuildClaudeToolNames_RoundTripAndStability(t *testing.T) { + reqJSON := []byte(`{ + "tools": [ + {"type": "function", "name": "normal_tool"}, + {"type": "function", "name": "very_long_tool_name_that_exceeds_sixty_four_characters_and_needs_truncation_alpha"}, + {"type": "function", "name": "very_long_tool_name_that_exceeds_sixty_four_characters_and_needs_truncation_beta"}, + {"type": "custom", "name": "custom.tool.with.dots"}, + {"type": "namespace", "name": "mcp__my_server", "tools": [ + {"type": "function", "name": "get_something"} + ]} + ], + "input": [ + {"type": "function_call", "name": "history_only_tool_name_that_is_also_very_long_exceeding_sixty_four_characters"} + ] + }`) + + root := gjson.ParseBytes(reqJSON) + names := buildClaudeToolNames(root) + + // Verify all declared tools are unique and match pattern + seenClaudeNames := map[string]string{} + for id, claudeName := range names.toClaude { + if !claudeToolNamePattern.MatchString(claudeName) { + t.Fatalf("claude name %q for identity %q does not match pattern", claudeName, id) + } + if len(claudeName) > 64 { + t.Fatalf("claude name %q length = %d > 64", claudeName, len(claudeName)) + } + if existingID, exists := seenClaudeNames[claudeName]; exists && existingID != id { + t.Fatalf("claude name %q collided between %q and %q", claudeName, existingID, id) + } + seenClaudeNames[claudeName] = id + + // Verify reversibility + revID := names.identity(claudeName) + if revID != id { + t.Fatalf("identity(%q) = %q, want %q", claudeName, revID, id) + } + } + + // Normal tool should keep its name + if names.claudeName("normal_tool") != "normal_tool" { + t.Fatalf("claudeName(normal_tool) = %q, want normal_tool", names.claudeName("normal_tool")) + } + + // Namespace child qualified name + qualified := "mcp__my_server__get_something" + if names.claudeName(qualified) != qualified { + t.Fatalf("claudeName(%q) = %q, want %q", qualified, names.claudeName(qualified), qualified) + } + + // Alpha and Beta long tools must not have the same claude name + alphaID := "very_long_tool_name_that_exceeds_sixty_four_characters_and_needs_truncation_alpha" + betaID := "very_long_tool_name_that_exceeds_sixty_four_characters_and_needs_truncation_beta" + if names.claudeName(alphaID) == names.claudeName(betaID) { + t.Fatalf("alpha and beta long tools received same name: %q", names.claudeName(alphaID)) + } +} + +func TestBuildClaudeToolNames_DeclarationOrderInvariance(t *testing.T) { + tool1 := "mcp__example_apps__acme_inventory_service__acme_inventory_service_get_item_prices" + tool2 := "mcp__example_apps__acme_inventory_service__acme_inventory_service_get_item_metrics" + + reqA := []byte(`{ + "tools": [ + {"type": "function", "name": "mcp__example_apps__acme_inventory_service__acme_inventory_service_get_item_prices"}, + {"type": "function", "name": "mcp__example_apps__acme_inventory_service__acme_inventory_service_get_item_metrics"} + ] + }`) + reqB := []byte(`{ + "tools": [ + {"type": "function", "name": "mcp__example_apps__acme_inventory_service__acme_inventory_service_get_item_metrics"}, + {"type": "function", "name": "mcp__example_apps__acme_inventory_service__acme_inventory_service_get_item_prices"} + ] + }`) + + namesA := buildClaudeToolNames(gjson.ParseBytes(reqA)) + namesB := buildClaudeToolNames(gjson.ParseBytes(reqB)) + + if namesA.claudeName(tool1) != namesB.claudeName(tool1) { + t.Fatalf("order affected tool1 mapping: %q vs %q", namesA.claudeName(tool1), namesB.claudeName(tool1)) + } + if namesA.claudeName(tool2) != namesB.claudeName(tool2) { + t.Fatalf("order affected tool2 mapping: %q vs %q", namesA.claudeName(tool2), namesB.claudeName(tool2)) + } +} + +func TestBuildClaudeToolNames_SingleLongName(t *testing.T) { + longTool := "a_single_very_long_tool_name_that_exceeds_sixty_four_characters_cleanly" + reqJSON := []byte(`{ + "tools": [ + {"type": "function", "name": "a_single_very_long_tool_name_that_exceeds_sixty_four_characters_cleanly"} + ] + }`) + + names := buildClaudeToolNames(gjson.ParseBytes(reqJSON)) + claudeName := names.claudeName(longTool) + if len(claudeName) > 64 { + t.Fatalf("claudeName length = %d > 64: %q", len(claudeName), claudeName) + } + if !claudeToolNamePattern.MatchString(claudeName) { + t.Fatalf("claudeName %q does not match pattern", claudeName) + } + if names.identity(claudeName) != longTool { + t.Fatalf("identity(%q) = %q, want %q", claudeName, names.identity(claudeName), longTool) + } +} + +func TestBuildClaudeToolNames_CustomToolCollision(t *testing.T) { + tool1 := "custom__long_namespace_path_exceeding_sixty_four_characters_long__operation_query_v1_execute_handler" + tool2 := "custom__long_namespace_path_exceeding_sixty_four_characters_long__operation_query_v1_execute_stream" + + reqJSON := []byte(fmt.Sprintf(`{ + "tools": [ + {"type": "custom", "name": %q}, + {"type": "custom", "name": %q} + ] + }`, tool1, tool2)) + + root := gjson.ParseBytes(reqJSON) + names := buildClaudeToolNames(root) + customMap := responsesCustomToolNames(reqJSON) + + cName1 := names.claudeName(tool1) + cName2 := names.claudeName(tool2) + + if cName1 == cName2 { + t.Fatalf("custom tools collided: %q", cName1) + } + if len(cName1) > 64 || len(cName2) > 64 { + t.Fatalf("custom tool claude names must not exceed 64 chars: %d, %d", len(cName1), len(cName2)) + } + if _, ok := customMap[cName1]; !ok { + t.Fatalf("customMap missing claudeName1 %q", cName1) + } + if _, ok := customMap[cName2]; !ok { + t.Fatalf("customMap missing claudeName2 %q", cName2) + } + if names.identity(cName1) != tool1 { + t.Fatalf("identity(%q) = %q, want %q", cName1, names.identity(cName1), tool1) + } + if names.identity(cName2) != tool2 { + t.Fatalf("identity(%q) = %q, want %q", cName2, names.identity(cName2), tool2) + } + + // Verify non-stream response restores the custom tool call type and original name + claudeResp := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_test_custom","usage":{"input_tokens":10,"output_tokens":5}}}`, + fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom_0","name":%q,"input":{"input":"query_payload"}}}`, cName1), + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"message_stop"}`, + }, "\n")) + translatedResp := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-sonnet-5-5", reqJSON, nil, claudeResp, nil) + outputItem := gjson.GetBytes(translatedResp, "output.0") + if outputItem.Get("type").String() != "custom_tool_call" { + t.Fatalf("output.0.type = %q, want custom_tool_call", outputItem.Get("type").String()) + } + if outputItem.Get("name").String() != tool1 { + t.Fatalf("output.0.name = %q, want %q", outputItem.Get("name").String(), tool1) + } +} + +func TestBuildClaudeToolNames_DeclaredToolsPrecedeHistory(t *testing.T) { + // A declared tool has a name that collides with a history tool + reqJSON := []byte(`{ + "tools": [ + {"type": "function", "name": "tool_x"} + ], + "input": [ + {"type": "function_call", "name": "tool.x"} + ] + }`) + + names := buildClaudeToolNames(gjson.ParseBytes(reqJSON)) + + // tool_x must keep its exact name + if names.claudeName("tool_x") != "tool_x" { + t.Fatalf("declared tool_x was stolen: %q", names.claudeName("tool_x")) + } + + // tool.x (which sanitizes to tool_x) must be disambiguated with hash because tool_x is taken + if names.claudeName("tool.x") == "tool_x" { + t.Fatalf("history tool.x unexpectedly got tool_x instead of disambiguated name") + } + if !strings.HasPrefix(names.claudeName("tool.x"), "tool_x_") { + t.Fatalf("history tool.x = %q, want prefix tool_x_", names.claudeName("tool.x")) + } +}