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
This commit is contained in:
Luis Pater
2026-09-30 12:33:25 +08:00
parent 6004ebdfdb
commit 369df975ea
4 changed files with 560 additions and 14 deletions

View File

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

View File

@@ -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, &param) {
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_<hash>", 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)
}
}

View File

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

View File

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