From ea37d13a9eced4af835cad10bcb030d770a5ba88 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 6 Aug 2026 00:02:12 +0800 Subject: [PATCH] fix(responses): support custom tool calls and namespace collisions in request/response conversion Closes: #4798 --- .../claude_openai-responses_request.go | 399 +++++++++++++----- .../claude_openai-responses_request_test.go | 310 ++++++++++++++ .../claude_openai-responses_response.go | 133 ++++-- .../claude_openai-responses_response_test.go | 173 ++++++++ 4 files changed, 875 insertions(+), 140 deletions(-) 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 cea3b069e..6e506db21 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -30,9 +30,9 @@ var ( // - instructions, input[].role==system and input[].role==developer -> separate // top-level system blocks, in source order // - input[].type==message with input_text/output_text -> user/assistant messages -// - function_call -> assistant tool_use -// - function_call_output -> user tool_result -// - tools[].parameters -> tools[].input_schema +// - function_call/custom_tool_call -> assistant tool_use +// - function_call_output/custom_tool_call_output -> user tool_result +// - top-level tools and input[].additional_tools -> Claude tools[].input_schema // - max_output_tokens -> max_tokens // - stream passthrough via parameter func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { @@ -384,23 +384,33 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte pendingReasoningParts = append(pendingReasoningParts, thinkingPart) } - case "function_call": - // Map to assistant tool_use + case "function_call", "custom_tool_call": + // Map to assistant tool_use. Freeform custom input is wrapped in an + // object because Claude tool_use input must be a JSON object. callID := item.Get("call_id").String() if callID == "" { callID = genToolCallID() } callID = util.SanitizeClaudeToolID(callID) name := item.Get("name").String() - argsStr := item.Get("arguments").String() + if namespaceName := strings.TrimSpace(item.Get("namespace").String()); namespaceName != "" { + // Rebuild the qualified name emitted by the previous Responses turn. + name = qualifyResponsesNamespaceToolName(namespaceName, name) + } + isCustomToolCall := typ == "custom_tool_call" toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) toolUse, _ = sjson.SetBytes(toolUse, "id", callID) toolUse, _ = sjson.SetBytes(toolUse, "name", name) - if argsStr != "" && gjson.Valid(argsStr) { - argsJSON := gjson.Parse(argsStr) - if argsJSON.IsObject() { - toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw)) + if isCustomToolCall { + toolUse, _ = sjson.SetBytes(toolUse, "input.input", item.Get("input").String()) + } else { + argsStr := item.Get("arguments").String() + if argsStr != "" && gjson.Valid(argsStr) { + argsJSON := gjson.Parse(argsStr) + if argsJSON.IsObject() { + toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw)) + } } } @@ -413,7 +423,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte raw: asst, }) - case "function_call_output": + case "function_call_output", "custom_tool_call_output": flushPendingReasoning() // Map to user tool_result callID := item.Get("call_id").String() @@ -446,23 +456,29 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte includedToolNames := map[string]struct{}{} toolNameMap := map[string]string{} - // tools mapping: parameters -> input_schema - if tools := root.Get("tools"); tools.Exists() && tools.IsArray() { - var toolItems [][]byte - tools.ForEach(func(_, tool gjson.Result) bool { - convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap) - for _, tJSON := range convertedTools { - toolName := gjson.GetBytes(tJSON, "name").String() - if toolName != "" { - includedToolNames[toolName] = struct{}{} - } - toolItems = append(toolItems, tJSON) - } - return true - }) - if len(toolItems) > 0 { - out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(toolItems)) + // Responses Lite puts tool definitions in input[].additional_tools. Select + // one winner for each final name, while keeping the original order for the + // tools that survive conversion. + var toolItems [][]byte + winners := responsesToolWinners(root) + for _, descriptor := range responsesToolDescriptors(root) { + winner, ok := winners[descriptor.name] + if !ok || winner.order != descriptor.order { + continue } + tJSON, ok := convertResponsesToolDescriptorToClaude(descriptor) + if !ok { + continue + } + toolName := gjson.GetBytes(tJSON, "name").String() + if toolName != "" { + includedToolNames[toolName] = struct{}{} + } + toolItems = append(toolItems, tJSON) + } + toolNameMap = responsesToolNameMap(root, includedToolNames) + if len(toolItems) > 0 { + out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(toolItems)) } // Map tool_choice similar to Chat Completions translator (optional in docs, safe to handle) @@ -480,11 +496,25 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte } } case gjson.JSON: - if toolChoice.Get("type").String() == "function" { + choiceType := toolChoice.Get("type").String() + if choiceType == "function" || choiceType == "custom" { fn := toolChoice.Get("function.name").String() + if fn == "" { + fn = toolChoice.Get("custom.name").String() + } if fn == "" { fn = toolChoice.Get("name").String() } + namespaceName := toolChoice.Get("namespace").String() + if namespaceName == "" { + namespaceName = toolChoice.Get("function.namespace").String() + } + if namespaceName == "" { + namespaceName = toolChoice.Get("custom.namespace").String() + } + if namespaceName != "" { + fn = qualifyResponsesNamespaceToolName(namespaceName, fn) + } if mappedName := toolNameMap[fn]; mappedName != "" { fn = mappedName } @@ -703,61 +733,220 @@ func convertResponsesContentPartToClaude(part gjson.Result) []byte { return nil } -func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte { - toolType := strings.TrimSpace(tool.Get("type").String()) - switch toolType { - case "", "function": - if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok { - return [][]byte{tJSON} - } - case "namespace": - return convertResponsesNamespaceToolToClaude(tool, toolNameMap) - case "web_search": - if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok { - if name := gjson.GetBytes(tJSON, "name").String(); name != "" { - toolNameMap[name] = name - } - return [][]byte{tJSON} - } - default: - if isOpenAIResponsesApplyPatchCustomTool(toolType, tool) { - return nil - } - if isUnsupportedOpenAIBuiltinToolType(toolType) { - return nil - } - if tool.Get("name").String() != "" { - return [][]byte{[]byte(tool.Raw)} - } - } - return nil -} - func isOpenAIResponsesApplyPatchCustomTool(toolType string, tool gjson.Result) bool { return toolType == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch" } -func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte { - namespaceName := strings.TrimSpace(tool.Get("name").String()) - children := tool.Get("tools") - if !children.Exists() || !children.IsArray() { - return nil +func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor) ([]byte, bool) { + overrideName := "" + if !descriptor.direct { + overrideName = descriptor.name + } + switch descriptor.toolType { + case "function": + return convertResponsesFunctionToolToClaude(descriptor.tool, overrideName) + case "custom": + return convertResponsesCustomToolToClaude(descriptor.tool, overrideName) + case "web_search": + return convertResponsesWebSearchToolToClaude(descriptor.tool) + default: + if isUnsupportedOpenAIBuiltinToolType(descriptor.toolType) { + return nil, false + } + if descriptor.tool.Get("name").String() == "" { + return nil, false + } + return []byte(descriptor.tool.Raw), true + } +} + +type responsesToolSource struct { + tools gjson.Result + priority int // Top-level tools use 0; all additional_tools sources use 1. +} + +func responsesToolSources(root gjson.Result) []responsesToolSource { + var sources []responsesToolSource + appendSource := func(tools gjson.Result, priority int) { + if tools.Exists() && tools.IsArray() { + sources = append(sources, responsesToolSource{tools: tools, priority: priority}) + } } - var out [][]byte - children.ForEach(func(_, child gjson.Result) bool { - childName := responsesToolName(child) - qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName) - if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok { - out = append(out, tJSON) - toolNameMap[qualifiedName] = qualifiedName - if childName != "" { - toolNameMap[childName] = qualifiedName + appendSource(root.Get("tools"), 0) + if input := root.Get("input"); input.Exists() && input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "additional_tools" { + appendSource(item.Get("tools"), 1) } + return true + }) + } + return sources +} + +type responsesToolDescriptor struct { + name string + childName string + namespace string + toolType string + tool gjson.Result + sourcePriority int + direct bool + order int +} + +func responsesToolDescriptors(root gjson.Result) []responsesToolDescriptor { + var descriptors []responsesToolDescriptor + appendDescriptor := func(tool gjson.Result, name, childName, namespaceName, toolType string, sourcePriority int, direct bool) { + if name == "" { + return } - return true - }) - return out + descriptors = append(descriptors, responsesToolDescriptor{ + name: name, + childName: childName, + namespace: namespaceName, + toolType: toolType, + tool: tool, + sourcePriority: sourcePriority, + direct: direct, + order: len(descriptors), + }) + } + appendNamespaceChildren := func(namespaceTool gjson.Result, sourcePriority int) { + namespaceName := strings.TrimSpace(namespaceTool.Get("name").String()) + children := namespaceTool.Get("tools") + if !children.Exists() || !children.IsArray() { + return + } + children.ForEach(func(_, child gjson.Result) bool { + childName := responsesToolName(child) + if childName == "" { + return true + } + qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName) + switch strings.TrimSpace(child.Get("type").String()) { + case "", "function": + appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false) + case "custom": + if !isOpenAIResponsesApplyPatchCustomTool("custom", child) { + appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false) + } + } + return true + }) + } + for _, source := range responsesToolSources(root) { + source.tools.ForEach(func(_, tool gjson.Result) bool { + toolType := strings.TrimSpace(tool.Get("type").String()) + switch toolType { + case "", "function": + appendDescriptor(tool, responsesToolName(tool), "", "", "function", source.priority, true) + case "custom": + if !isOpenAIResponsesApplyPatchCustomTool("custom", tool) { + appendDescriptor(tool, responsesToolName(tool), "", "", "custom", source.priority, true) + } + case "namespace": + appendNamespaceChildren(tool, source.priority) + case "web_search": + if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() { + return true + } + name := strings.TrimSpace(tool.Get("name").String()) + if name == "" { + name = "web_search" + } + appendDescriptor(tool, name, "", "", "web_search", source.priority, true) + default: + if isUnsupportedOpenAIBuiltinToolType(toolType) { + return true + } + appendDescriptor(tool, strings.TrimSpace(tool.Get("name").String()), "", "", toolType, source.priority, true) + } + return true + }) + } + return descriptors +} + +func responsesToolDescriptorPrecedes(left, right responsesToolDescriptor) bool { + // Keep top-level tools ahead of additional_tools, then let direct + // declarations win over namespace children within the same source class. + if left.sourcePriority != right.sourcePriority { + return left.sourcePriority < right.sourcePriority + } + if left.direct != right.direct { + return left.direct + } + return left.order < right.order +} + +func responsesToolWinners(root gjson.Result) map[string]responsesToolDescriptor { + winners := map[string]responsesToolDescriptor{} + for _, descriptor := range responsesToolDescriptors(root) { + current, exists := winners[descriptor.name] + if !exists || responsesToolDescriptorPrecedes(descriptor, current) { + winners[descriptor.name] = descriptor + } + } + return winners +} + +func responsesToolNameMap(root gjson.Result, acceptedToolNames map[string]struct{}) map[string]string { + toolNameMap := map[string]string{} + descriptors := responsesToolDescriptors(root) + winners := responsesToolWinners(root) + + // Direct tool names are canonical aliases and must win over namespace + // child aliases, regardless of declaration order. + for _, descriptor := range descriptors { + winner, ok := winners[descriptor.name] + if !ok || winner.order != descriptor.order || !descriptor.direct { + continue + } + if _, accepted := acceptedToolNames[descriptor.name]; !accepted { + continue + } + toolNameMap[descriptor.name] = descriptor.name + } + + // Namespace aliases fill only names that are not already owned by a + // winning direct function/custom tool. + for _, descriptor := range descriptors { + winner, ok := winners[descriptor.name] + if !ok || winner.order != descriptor.order || descriptor.direct || descriptor.childName == "" { + continue + } + if _, accepted := acceptedToolNames[descriptor.name]; !accepted { + continue + } + if _, exists := toolNameMap[descriptor.childName]; exists { + continue + } + toolNameMap[descriptor.childName] = descriptor.name + } + return toolNameMap +} + +func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} { + names := make(map[string]struct{}) + root := gjson.ParseBytes(requestRawJSON) + for name, descriptor := range responsesToolWinners(root) { + if descriptor.toolType == "custom" { + names[name] = struct{}{} + } + } + return names +} + +func unwrapCustomToolInput(arguments string) string { + if v := gjson.Get(arguments, "input"); v.Exists() { + if v.Type == gjson.String { + return v.String() + } + return v.Raw + } + return arguments } func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) { @@ -782,6 +971,24 @@ func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string return tJSON, true } +func convertResponsesCustomToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) { + name := strings.TrimSpace(overrideName) + if name == "" { + name = 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", name) + if description := responsesToolDescription(tool); description != "" { + tJSON, _ = sjson.SetBytes(tJSON, "description", description) + } + tJSON = common.AttachCacheControl(tJSON, tool) + return tJSON, true +} + func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) { if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() { return nil, false @@ -839,7 +1046,7 @@ func qualifyResponsesNamespaceToolName(namespaceName, childName string) string { if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") { return childName } - if strings.HasPrefix(childName, namespaceName) { + if childName == namespaceName || strings.HasPrefix(childName, namespaceName+"__") { return childName } if strings.HasSuffix(namespaceName, "__") { @@ -854,43 +1061,15 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali return "", "" } - tools := gjson.GetBytes(requestRawJSON, "tools") - if !tools.Exists() || !tools.IsArray() { + root := gjson.ParseBytes(requestRawJSON) + descriptor, ok := responsesToolWinners(root)[qualifiedName] + if !ok { return qualifiedName, "" } - - var bestNamespace string - var bestChild string - tools.ForEach(func(_, tool gjson.Result) bool { - if strings.TrimSpace(tool.Get("type").String()) != "namespace" { - return true - } - namespaceName := strings.TrimSpace(tool.Get("name").String()) - if namespaceName == "" { - return true - } - children := tool.Get("tools") - if !children.Exists() || !children.IsArray() { - return true - } - children.ForEach(func(_, child gjson.Result) bool { - childName := responsesToolName(child) - if childName == "" { - return true - } - if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName { - bestNamespace = namespaceName - bestChild = childName - } - return true - }) - return true - }) - - if bestNamespace == "" || bestChild == "" { - return qualifiedName, "" + if descriptor.toolType == "function" && !descriptor.direct { + return descriptor.childName, descriptor.namespace } - return bestChild, bestNamespace + return qualifiedName, "" } 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 88ede037a..bf75d108e 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 @@ -441,6 +441,316 @@ func TestConvertOpenAIResponsesRequestToClaude_NormalizesRootToolSchemaUnion(t * } } +func TestConvertOpenAIResponsesRequestToClaude_MergesAdditionalToolsAndPrefersTopLevel(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "tools":[ + { + "type":"function", + "name":"exec", + "description":"top-level exec", + "parameters":{"type":"object","properties":{"command":{"type":"string"}}} + }, + { + "type":"namespace", + "name":"collaboration", + "tools":[{"type":"function","name":"spawn","description":"top-level spawn","parameters":{"type":"object","properties":{}}}] + } + ], + "input":[ + { + "type":"additional_tools", + "role":"developer", + "tools":[ + {"type":"custom","name":"exec","description":"additional exec"}, + {"type":"function","name":"wait","parameters":{"type":"object","properties":{}}}, + {"type":"namespace","name":"collaboration","tools":[ + {"type":"function","name":"spawn","parameters":{"type":"object","properties":{}}}, + {"type":"custom","name":"send","description":"send a message"} + ]} + ] + }, + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]} + ] + }`) + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)) + if got := root.Get("tools.#").Int(); got != 4 { + t.Fatalf("tools count = %d, want 4; output=%s", got, root.Raw) + } + if got := root.Get(`tools.#(name=="exec").description`).String(); got != "top-level exec" { + t.Fatalf("exec description = %q, want top-level exec", got) + } + if got := root.Get(`tools.#(name=="wait").name`).String(); got != "wait" { + t.Fatalf("additional function name = %q, want wait", got) + } + if got := root.Get(`tools.#(name=="collaboration__spawn").name`).String(); got != "collaboration__spawn" { + t.Fatalf("namespace function name = %q, want collaboration__spawn", got) + } + custom := root.Get(`tools.#(name=="collaboration__send")`) + if !custom.Exists() { + t.Fatal("missing namespace custom tool") + } + if got := custom.Get("input_schema.properties.input.type").String(); got != "string" { + t.Fatalf("custom input schema type = %q, want string", got) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_DeduplicatesExpandedToolNames(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "tools":[{"type":"function","name":"collaboration__send","description":"top-level send","parameters":{"type":"object","properties":{}}}], + "input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"collaboration","tools":[ + {"type":"function","name":"send","description":"additional send","parameters":{"type":"object","properties":{}}}, + {"type":"function","name":"other","parameters":{"type":"object","properties":{}}} + ]}]}] + }`) + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)) + if got := root.Get("tools.#").Int(); got != 2 { + t.Fatalf("tools count = %d, want 2; output=%s", got, root.Raw) + } + if got := root.Get(`tools.#(name=="collaboration__send").description`).String(); got != "top-level send" { + t.Fatalf("duplicate final name description = %q, want top-level send", got) + } + if !root.Get(`tools.#(name=="collaboration__other")`).Exists() { + t.Fatal("unique namespace child was dropped") + } + customNames := responsesCustomToolNames(raw) + if _, ok := customNames["collaboration__send"]; ok { + t.Fatal("final-name collision should keep the top-level function type") + } + name, namespace := splitResponsesQualifiedFunctionCallFromRequest(raw, "collaboration__send") + if name != "collaboration__send" || namespace != "" { + t.Fatalf("final-name collision namespace = (%q, %q), want (collaboration__send, empty)", name, namespace) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_DirectToolWinsOverEarlierNamespaceCollision(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "tools":[ + {"type":"namespace","name":"n","tools":[{"type":"function","name":"x","parameters":{"type":"object","properties":{}}}]}, + {"type":"custom","name":"n__x"} + ], + "tool_choice":{"type":"custom","name":"n__x"} + }`) + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)) + if got := root.Get("tools.#").Int(); got != 1 { + t.Fatalf("tools count = %d, want 1; output=%s", got, root.Raw) + } + if got := root.Get("tools.0.name").String(); got != "n__x" { + t.Fatalf("winning tool name = %q, want n__x", got) + } + if got := root.Get("tools.0.input_schema.properties.input.type").String(); got != "string" { + t.Fatalf("winning tool schema type = %q, want string for custom tool", got) + } + if got := root.Get("tool_choice.name").String(); got != "n__x" { + t.Fatalf("tool_choice.name = %q, want n__x; output=%s", got, root.Raw) + } + if _, ok := responsesCustomToolNames(raw)["n__x"]; !ok { + t.Fatal("winning direct custom tool was not classified as custom") + } +} + +func TestConvertOpenAIResponsesRequestToClaude_PrefersDirectToolAcrossAdditionalSources(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "input":[ + {"type":"additional_tools","tools":[{"type":"namespace","name":"n","tools":[{"type":"function","name":"x","description":"namespace x","parameters":{"type":"object","properties":{}}}]}]}, + {"type":"additional_tools","tools":[{"type":"custom","name":"n__x","description":"direct x"}]} + ] + }`) + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)) + if got := root.Get("tools.#").Int(); got != 1 { + t.Fatalf("tools count = %d, want 1; output=%s", got, root.Raw) + } + tool := root.Get("tools.0") + if got := tool.Get("name").String(); got != "n__x" { + t.Fatalf("winning tool name = %q, want n__x", got) + } + if got := tool.Get("description").String(); got != "direct x" { + t.Fatalf("winning tool description = %q, want direct x", got) + } + if got := tool.Get("input_schema.properties.input.type").String(); got != "string" { + t.Fatalf("winning tool schema type = %q, want string for custom tool", got) + } + if _, ok := responsesCustomToolNames(raw)["n__x"]; !ok { + t.Fatal("direct custom tool should win classification across additional sources") + } +} + +func TestConvertOpenAIResponsesRequestToClaude_PreservesToolDeclarationOrder(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "tools":[ + {"type":"function","name":"first","parameters":{"type":"object","properties":{}}}, + {"type":"namespace","name":"n","tools":[{"type":"function","name":"middle","parameters":{"type":"object","properties":{}}}]}, + {"type":"function","name":"last","parameters":{"type":"object","properties":{}}} + ] + }`) + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)) + want := []string{"first", "n__middle", "last"} + got := root.Get("tools.#.name").Array() + if len(got) != len(want) { + t.Fatalf("tools count = %d, want %d; output=%s", len(got), len(want), root.Raw) + } + for i, wantName := range want { + if got[i].String() != wantName { + t.Errorf("tools[%d].name = %q, want %q", i, got[i].String(), wantName) + } + } +} + +func TestConvertOpenAIResponsesRequestToClaude_ReplaysCustomToolCallHistory(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "input":[ + {"type":"custom_tool_call","call_id":"call.custom:1","name":"exec","input":"pwd"}, + {"type":"custom_tool_call_output","call_id":"call.custom:1","output":"/workspace"} + ] + }`) + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)) + toolUse := root.Get("messages.0.content.0") + if got := toolUse.Get("type").String(); got != "tool_use" { + t.Fatalf("tool use type = %q, want tool_use; output=%s", got, root.Raw) + } + if got := toolUse.Get("id").String(); got != "call_custom_1" { + t.Fatalf("tool use id = %q, want call_custom_1", got) + } + if got := toolUse.Get("input.input").String(); got != "pwd" { + t.Fatalf("custom tool input = %q, want pwd", got) + } + toolResult := root.Get("messages.1.content.0") + if got := toolResult.Get("type").String(); got != "tool_result" { + t.Fatalf("tool result type = %q, want tool_result", got) + } + if got := toolResult.Get("tool_use_id").String(); got != "call_custom_1" { + t.Fatalf("tool result id = %q, want call_custom_1", got) + } + if got := toolResult.Get("content").String(); got != "/workspace" { + t.Fatalf("tool result content = %q, want /workspace", got) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_ReplaysNamespacedFunctionCallHistory(t *testing.T) { + raw := []byte(`{ + "model":"claude-test", + "input":[ + {"type":"additional_tools","tools":[{"type":"namespace","name":"mcp__node_repl","tools":[{"type":"function","name":"js","parameters":{"type":"object","properties":{}}}]}]}, + {"type":"function_call","call_id":"call.namespace","name":"js","namespace":"mcp__node_repl","arguments":"{\"code\":\"pwd\"}"}, + {"type":"function_call_output","call_id":"call.namespace","output":"ok"} + ] + }`) + + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)) + if !root.Get(`tools.#(name=="mcp__node_repl__js")`).Exists() { + t.Fatal("missing qualified namespace tool declaration") + } + toolUse := root.Get("messages.0.content.0") + if got := toolUse.Get("name").String(); got != "mcp__node_repl__js" { + t.Fatalf("historical tool_use name = %q, want mcp__node_repl__js", got) + } + if got := root.Get("messages.1.content.0.tool_use_id").String(); got != "call_namespace" { + t.Fatalf("historical tool_result id = %q, want call_namespace", got) + } +} + +func TestConvertOpenAIResponsesRequestToClaude_MapsCustomAndNamespacedToolChoice(t *testing.T) { + tests := []struct { + name string + raw string + wantToolName string + }{ + { + name: "custom", + raw: `{ + "model":"claude-test", + "tools":[{"type":"custom","name":"exec"}], + "tool_choice":{"type":"custom","name":"exec"} + }`, + wantToolName: "exec", + }, + { + name: "namespace", + raw: `{ + "model":"claude-test", + "input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"mcp__node_repl","tools":[{"type":"function","name":"js"}]}]}], + "tool_choice":{"type":"function","name":"js","namespace":"mcp__node_repl"} + }`, + wantToolName: "mcp__node_repl__js", + }, + { + name: "top-level-short-name-wins", + raw: `{ + "model":"claude-test", + "tools":[{"type":"function","name":"foo"}], + "input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"mcp__tools","tools":[{"type":"function","name":"foo"}]}]}], + "tool_choice":{"type":"function","name":"foo"} + }`, + wantToolName: "foo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", []byte(tt.raw), false)) + if got := root.Get("tool_choice.type").String(); got != "tool" { + t.Fatalf("tool_choice.type = %q, want tool; output=%s", got, root.Raw) + } + if got := root.Get("tool_choice.name").String(); got != tt.wantToolName { + t.Fatalf("tool_choice.name = %q, want %q", got, tt.wantToolName) + } + }) + } +} + +func TestQualifyResponsesNamespaceToolNameAvoidsPrefixCollision(t *testing.T) { + tests := []struct { + namespace string + child string + want string + }{ + {namespace: "collab", child: "collaboration", want: "collab__collaboration"}, + {namespace: "collab", child: "collab__send", want: "collab__send"}, + {namespace: "collab__", child: "send", want: "collab__send"}, + {namespace: "mcp__node_repl", child: "mcp__node_repl__js", want: "mcp__node_repl__js"}, + } + + for _, tt := range tests { + got := qualifyResponsesNamespaceToolName(tt.namespace, tt.child) + if got != tt.want { + t.Errorf("qualifyResponsesNamespaceToolName(%q, %q) = %q, want %q", tt.namespace, tt.child, got, tt.want) + } + } + + raw := []byte(`{ + "tools":[{"type":"namespace","name":"collab","tools":[{"type":"function","name":"collaboration"}]}] + }`) + root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)) + if got := root.Get("tools.0.name").String(); got != "collab__collaboration" { + t.Fatalf("qualified tool declaration = %q, want collab__collaboration", got) + } +} + +func TestSplitResponsesQualifiedFunctionCallFromAdditionalTools(t *testing.T) { + raw := []byte(`{ + "input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"mcp__node_repl","tools":[{"type":"function","name":"js"}]}]}] + }`) + + name, namespace := splitResponsesQualifiedFunctionCallFromRequest(raw, "mcp__node_repl__js") + if name != "js" { + t.Fatalf("name = %q, want js", name) + } + if namespace != "mcp__node_repl" { + t.Fatalf("namespace = %q, want mcp__node_repl", namespace) + } +} + func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) { t.Helper() channelBlock := []byte{} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index 5657b0da3..9d6ab42ef 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -29,6 +29,7 @@ type claudeToResponsesState struct { // function call bookkeeping for output aggregation FuncNames map[int]string // Claude block index -> function name FuncCallIDs map[int]string // Claude block index -> call id + FuncCustom map[int]bool // Claude block index -> freeform custom tool FuncOutputIndices map[int]int // Claude block index -> Responses output index // message text aggregation TextBuf strings.Builder @@ -251,6 +252,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin FuncArgsBuf: make(map[int]*strings.Builder), FuncNames: make(map[int]string), FuncCallIDs: make(map[int]string), + FuncCustom: make(map[int]bool), FuncOutputIndices: make(map[int]int), } } @@ -262,6 +264,8 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } rawJSON = bytes.TrimSpace(rawJSON[5:]) root := gjson.ParseBytes(rawJSON) + requestForToolMetadata := pickRequestJSON(originalRequestRawJSON, requestRawJSON) + customToolNames := responsesCustomToolNames(requestForToolMetadata) ev := root.Get("type").String() var out [][]byte @@ -294,6 +298,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.FuncArgsBuf = make(map[int]*strings.Builder) st.FuncNames = make(map[int]string) st.FuncCallIDs = make(map[int]string) + st.FuncCustom = make(map[int]bool) st.FuncOutputIndices = make(map[int]int) st.Usage = claudeResponsesUsageTokens{} st.Usage.Merge(msg.Get("usage")) @@ -354,18 +359,31 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.InFuncBlock = true st.CurrentFCID = cb.Get("id").String() name := cb.Get("name").String() + _, isCustomTool := customToolNames[name] + if st.FuncCustom == nil { + st.FuncCustom = make(map[int]bool) + } + st.FuncCustom[idx] = isCustomTool outputIndex := st.functionOutputIndex(idx) - item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) + var item []byte + if isCustomTool { + item = []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`) + item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("ctc_%s", st.CurrentFCID)) + item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID) + item, _ = sjson.SetBytes(item, "item.name", name) + } else { + item = []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) + item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID)) + item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID) + item = applyResponsesFunctionCallNamespaceFields(item, requestForToolMetadata, name, "item") + } item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) item, _ = sjson.SetBytes(item, "output_index", outputIndex) - item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID)) - item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID) - item = applyResponsesFunctionCallNamespaceFields(item, pickRequestJSON(originalRequestRawJSON, requestRawJSON), name, "item") out = append(out, emitEvent("response.output_item.added", item)) if st.FuncArgsBuf[idx] == nil { st.FuncArgsBuf[idx] = &strings.Builder{} } - // record function metadata for aggregation + // Record function metadata for aggregation. st.FuncCallIDs[idx] = st.CurrentFCID st.FuncNames[idx] = name } else if typ == "thinking" || typ == "redacted_thinking" { @@ -417,6 +435,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.FuncArgsBuf[idx] = &strings.Builder{} } st.FuncArgsBuf[idx].WriteString(pj.String()) + if st.FuncCustom[idx] { + return [][]byte{} + } outputIndex := st.functionOutputIndex(idx) msg := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) @@ -457,25 +478,47 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } else if st.InFuncBlock { outputIndex := st.functionOutputIndex(idx) args := "{}" + if st.FuncCustom[idx] { + args = "" + } if buf := st.FuncArgsBuf[idx]; buf != nil { if buf.Len() > 0 { args = buf.String() } } - fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) - fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) - fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID)) - fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex) - fcDone, _ = sjson.SetBytes(fcDone, "arguments", args) - out = append(out, emitEvent("response.function_call_arguments.done", fcDone)) - itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) - itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex) - itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID)) - itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args) - itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID) - itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, pickRequestJSON(originalRequestRawJSON, requestRawJSON), st.FuncNames[idx], "item") - out = append(out, emitEvent("response.output_item.done", itemDone)) + if st.FuncCustom[idx] { + input := unwrapCustomToolInput(args) + inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) + inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) + inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.CurrentFCID)) + inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex) + inputDone, _ = sjson.SetBytes(inputDone, "input", input) + out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone)) + + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.CurrentFCID)) + itemDone, _ = sjson.SetBytes(itemDone, "item.input", input) + itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID) + itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[idx]) + out = append(out, emitEvent("response.output_item.done", itemDone)) + } else { + fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) + fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) + fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID)) + fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex) + fcDone, _ = sjson.SetBytes(fcDone, "arguments", args) + out = append(out, emitEvent("response.function_call_arguments.done", fcDone)) + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID)) + itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args) + itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID) + itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForToolMetadata, st.FuncNames[idx], "item") + out = append(out, emitEvent("response.output_item.done", itemDone)) + } st.InFuncBlock = false } else if st.ReasoningActive { full := st.ReasoningBuf.String() @@ -629,6 +672,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } for _, idx := range idxs { args := "{}" + if st.FuncCustom[idx] { + args = "" + } if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 { args = b.String() } @@ -637,12 +683,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin if callID == "" && st.CurrentFCID != "" { callID = st.CurrentFCID } - item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) - item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID)) - item, _ = sjson.SetBytes(item, "arguments", args) - item, _ = sjson.SetBytes(item, "call_id", callID) - item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "") - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item) + if st.FuncCustom[idx] { + item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) + item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) + item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args)) + item, _ = sjson.SetBytes(item, "call_id", callID) + item, _ = sjson.SetBytes(item, "name", name) + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item) + } else { + item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) + item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID)) + item, _ = sjson.SetBytes(item, "arguments", args) + item, _ = sjson.SetBytes(item, "call_id", callID) + item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "") + outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item) + } } } if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 { @@ -694,6 +749,9 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string } } + reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON) + customToolNames := responsesCustomToolNames(reqBytes) + // Base OpenAI Responses (non-stream) object out := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{},"total_tokens":0}}`) @@ -770,9 +828,17 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string activeMessageItem = item case "tool_use": activeMessageItem = nil - item := newOutputItem("function_call", idx) + itemType := "function_call" + if _, isCustomTool := customToolNames[cb.Get("name").String()]; isCustomTool { + itemType = "custom_tool_call" + } + item := newOutputItem(itemType, idx) item.callID = cb.Get("id").String() - item.id = fmt.Sprintf("fc_%s", item.callID) + if itemType == "custom_tool_call" { + item.id = fmt.Sprintf("ctc_%s", item.callID) + } else { + item.id = fmt.Sprintf("fc_%s", item.callID) + } item.name = cb.Get("name").String() case "thinking", "redacted_thinking": activeMessageItem = nil @@ -797,7 +863,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string } } case "input_json_delta": - if item != nil && item.itemType == "function_call" { + if item != nil && (item.itemType == "function_call" || item.itemType == "custom_tool_call") { if pj := d.Get("partial_json"); pj.Exists() { item.args.WriteString(pj.String()) } @@ -839,7 +905,6 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string out, _ = sjson.SetBytes(out, "created_at", createdAt) // Inject request echo fields as top-level (similar to streaming variant) - reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON) if len(reqBytes) > 0 { req := gjson.ParseBytes(reqBytes) if v := req.Get("instructions"); v.Exists() { @@ -923,7 +988,15 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string if len(outputItem.annotations) > 0 { item, _ = sjson.SetBytes(item, "content.0.annotations", outputItem.annotations) } - case "function_call": + case "function_call", "custom_tool_call": + if outputItem.itemType == "custom_tool_call" { + item = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) + item, _ = sjson.SetBytes(item, "id", outputItem.id) + item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(outputItem.args.String())) + item, _ = sjson.SetBytes(item, "call_id", outputItem.callID) + item, _ = sjson.SetBytes(item, "name", outputItem.name) + break + } args := outputItem.args.String() if args == "" { args = "{}" diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go index d080d894b..d581aa198 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go @@ -877,6 +877,179 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ReportsCacheTokens(t *t } } +func TestConvertClaudeResponseToOpenAIResponses_RestoresAdditionalCustomToolCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-test", + "input":[{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"exec"}]}] + }`) + chunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_custom","usage":{"input_tokens":1,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom","name":"exec","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var added, inputDone, done, completed gjson.Result + functionEvents := 0 + for _, chunk := range chunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + switch event { + case "response.output_item.added": + if data.Get("item.type").String() == "custom_tool_call" { + added = data + } + case "response.custom_tool_call_input.done": + inputDone = data + case "response.output_item.done": + if data.Get("item.type").String() == "custom_tool_call" { + done = data + } + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + functionEvents++ + case "response.completed": + completed = data + } + } + } + + if !added.Exists() || !inputDone.Exists() || !done.Exists() || !completed.Exists() { + t.Fatalf("missing custom tool lifecycle events: added=%v input_done=%v done=%v completed=%v", added.Exists(), inputDone.Exists(), done.Exists(), completed.Exists()) + } + if functionEvents != 0 { + t.Fatalf("function call events = %d, want 0", functionEvents) + } + if got := added.Get("item.name").String(); got != "exec" { + t.Fatalf("added name = %q, want exec", got) + } + if got := inputDone.Get("input").String(); got != "pwd" { + t.Fatalf("custom input.done input = %q, want pwd", got) + } + if got := done.Get("item.input").String(); got != "pwd" { + t.Fatalf("done input = %q, want pwd", got) + } + if got := completed.Get("response.output.0.type").String(); got != "custom_tool_call" { + t.Fatalf("completed output type = %q, want custom_tool_call", got) + } + if got := completed.Get("response.output.0.input").String(); got != "pwd" { + t.Fatalf("completed input = %q, want pwd", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_DirectCustomWinsNamespaceCollision(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-test", + "tools":[ + {"type":"namespace","name":"n","tools":[{"type":"function","name":"x"}]}, + {"type":"custom","name":"n__x"} + ] + }`) + streamChunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_collision","usage":{"input_tokens":1,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_collision","name":"n__x","input":{}}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var streamCompleted gjson.Result + for _, chunk := range streamChunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.completed" { + streamCompleted = data + } + } + } + if got := streamCompleted.Get("response.output.0.type").String(); got != "custom_tool_call" { + t.Fatalf("stream output type = %q, want custom_tool_call", got) + } + if got := streamCompleted.Get("response.output.0.input").String(); got != "pwd" { + t.Fatalf("stream output input = %q, want pwd", got) + } + + nonStreamRaw := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_collision_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_collision_nonstream","name":"n__x","input":{}}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`, + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"message_stop"}`, + }, "\n")) + nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, nonStreamRaw, nil)) + if got := nonStream.Get("output.0.type").String(); got != "custom_tool_call" { + t.Fatalf("non-stream output type = %q, want custom_tool_call", got) + } + if got := nonStream.Get("output.0.input").String(); got != "pwd" { + t.Fatalf("non-stream output input = %q, want pwd", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresAdditionalCustomToolCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-test", + "input":[{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"exec"}]}] + }`) + raw := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_custom_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom_nonstream","name":"exec","input":{}}}`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`, + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"message_stop"}`, + }, "\n")) + + root := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil)) + if got := root.Get("output.0.type").String(); got != "custom_tool_call" { + t.Fatalf("non-stream output type = %q, want custom_tool_call; output=%s", got, root.Raw) + } + if got := root.Get("output.0.input").String(); got != "pwd" { + t.Fatalf("non-stream input = %q, want pwd", got) + } + if got := root.Get("output.0.call_id").String(); got != "call_custom_nonstream" { + t.Fatalf("non-stream call_id = %q, want call_custom_nonstream", got) + } +} + +func TestConvertClaudeResponseToOpenAIResponses_CustomToolEmptyInputMatchesNonStream(t *testing.T) { + originalRequest := []byte(`{ + "model":"gpt-test", + "tools":[{"type":"custom","name":"exec"}] + }`) + streamChunks := [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_custom_empty","usage":{"input_tokens":1,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom_empty","name":"exec","input":{}}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_stop"}`), + } + + var param any + var streamCompleted gjson.Result + for _, chunk := range streamChunks { + for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) { + event, data := parseClaudeResponsesSSEEvent(t, output) + if event == "response.completed" { + streamCompleted = data + } + } + } + if got := streamCompleted.Get("response.output.0.input").String(); got != "" { + t.Fatalf("stream empty custom input = %q, want empty string", got) + } + + raw := []byte(strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_custom_empty_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom_empty","name":"exec","input":{}}}`, + `data: {"type":"content_block_stop","index":0}`, + `data: {"type":"message_stop"}`, + }, "\n")) + nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil)) + if got := nonStream.Get("output.0.input").String(); got != "" { + t.Fatalf("non-stream empty custom input = %q, want empty string", got) + } +} + func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) { originalRequest := []byte(`{ "model":"gpt-test",