mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-21 23:23:13 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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_<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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user