fix(responses): support custom tool calls and namespace collisions in request/response conversion

Closes: #4798
This commit is contained in:
Luis Pater
2026-08-06 00:02:12 +08:00
parent 533b69e3e0
commit ea37d13a9e
4 changed files with 875 additions and 140 deletions

View File

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

View File

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

View File

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

View File

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