History

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

Luis Pater committed 3 months agov7.2.120

Commit ea37d131 parent 533b69e4 files · +870 −135Browse files

Unified · Split

4 files
  1. internal/translator/claude/openai/responses/claude_openai-responses_request.go +284 −105
  2. internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +310 −0
  3. internal/translator/claude/openai/responses/claude_openai-responses_response.go +103 −30
  4. internal/translator/claude/openai/responses/claude_openai-responses_response_test.go +173 −0
internal/translator/claude/openai/responses/claude_openai-responses_request.go+284−105
@@ -30,9 +30,9 @@ var ( · 29 unmodified lines
3030 // - instructions, input[].role==system and input[].role==developer -> separate
3131 // top-level system blocks, in source order
3232 // - input[].type==message with input_text/output_text -> user/assistant messages
33−// - function_call -> assistant tool_use
34−// - function_call_output -> user tool_result
35−// - tools[].parameters -> tools[].input_schema
33+// - function_call/custom_tool_call -> assistant tool_use
34+// - function_call_output/custom_tool_call_output -> user tool_result
35+// - top-level tools and input[].additional_tools -> Claude tools[].input_schema
3636 // - max_output_tokens -> max_tokens
3737 // - stream passthrough via parameter
3838 func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
@@ -384,23 +384,33 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 345 unmodified lines
384384 pendingReasoningParts = append(pendingReasoningParts, thinkingPart)
385385 }
386386
387− case "function_call":
388− // Map to assistant tool_use
387+ case "function_call", "custom_tool_call":
388+ // Map to assistant tool_use. Freeform custom input is wrapped in an
389+ // object because Claude tool_use input must be a JSON object.
389390 callID := item.Get("call_id").String()
390391 if callID == "" {
391392 callID = genToolCallID()
392393 }
393394 callID = util.SanitizeClaudeToolID(callID)
394395 name := item.Get("name").String()
395− argsStr := item.Get("arguments").String()
396+ if namespaceName := strings.TrimSpace(item.Get("namespace").String()); namespaceName != "" {
397+ // Rebuild the qualified name emitted by the previous Responses turn.
398+ name = qualifyResponsesNamespaceToolName(namespaceName, name)
399+ }
400+ isCustomToolCall := typ == "custom_tool_call"
396401
397402 toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
398403 toolUse, _ = sjson.SetBytes(toolUse, "id", callID)
399404 toolUse, _ = sjson.SetBytes(toolUse, "name", name)
400− if argsStr != "" && gjson.Valid(argsStr) {
401− argsJSON := gjson.Parse(argsStr)
402− if argsJSON.IsObject() {
403− toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
405+ if isCustomToolCall {
406+ toolUse, _ = sjson.SetBytes(toolUse, "input.input", item.Get("input").String())
407+ } else {
408+ argsStr := item.Get("arguments").String()
409+ if argsStr != "" && gjson.Valid(argsStr) {
410+ argsJSON := gjson.Parse(argsStr)
411+ if argsJSON.IsObject() {
412+ toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
413+ }
404414 }
405415 }
406416
@@ -413,7 +423,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 6 unmodified lines
413423 raw: asst,
414424 })
415425
416− case "function_call_output":
426+ case "function_call_output", "custom_tool_call_output":
417427 flushPendingReasoning()
418428 // Map to user tool_result
419429 callID := item.Get("call_id").String()
@@ -446,23 +456,29 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 26 unmodified lines
446456 includedToolNames := map[string]struct{}{}
447457 toolNameMap := map[string]string{}
448458
449− // tools mapping: parameters -> input_schema
450− if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
451− var toolItems [][]byte
452− tools.ForEach(func(_, tool gjson.Result) bool {
453− convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap)
454− for _, tJSON := range convertedTools {
455− toolName := gjson.GetBytes(tJSON, "name").String()
456− if toolName != "" {
457− includedToolNames[toolName] = struct{}{}
458− }
459− toolItems = append(toolItems, tJSON)
460− }
461− return true
462− })
463− if len(toolItems) > 0 {
464− out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(toolItems))
459+ // Responses Lite puts tool definitions in input[].additional_tools. Select
460+ // one winner for each final name, while keeping the original order for the
461+ // tools that survive conversion.
462+ var toolItems [][]byte
463+ winners := responsesToolWinners(root)
464+ for _, descriptor := range responsesToolDescriptors(root) {
465+ winner, ok := winners[descriptor.name]
466+ if !ok || winner.order != descriptor.order {
467+ continue
468+ }
469+ tJSON, ok := convertResponsesToolDescriptorToClaude(descriptor)
470+ if !ok {
471+ continue
465472 }
473+ toolName := gjson.GetBytes(tJSON, "name").String()
474+ if toolName != "" {
475+ includedToolNames[toolName] = struct{}{}
476+ }
477+ toolItems = append(toolItems, tJSON)
478+ }
479+ toolNameMap = responsesToolNameMap(root, includedToolNames)
480+ if len(toolItems) > 0 {
481+ out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(toolItems))
466482 }
467483
468484 // Map tool_choice similar to Chat Completions translator (optional in docs, safe to handle)
@@ -480,11 +496,25 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 11 unmodified lines
480496 }
481497 }
482498 case gjson.JSON:
483− if toolChoice.Get("type").String() == "function" {
499+ choiceType := toolChoice.Get("type").String()
500+ if choiceType == "function" || choiceType == "custom" {
484501 fn := toolChoice.Get("function.name").String()
502+ if fn == "" {
503+ fn = toolChoice.Get("custom.name").String()
504+ }
485505 if fn == "" {
486506 fn = toolChoice.Get("name").String()
487507 }
508+ namespaceName := toolChoice.Get("namespace").String()
509+ if namespaceName == "" {
510+ namespaceName = toolChoice.Get("function.namespace").String()
511+ }
512+ if namespaceName == "" {
513+ namespaceName = toolChoice.Get("custom.namespace").String()
514+ }
515+ if namespaceName != "" {
516+ fn = qualifyResponsesNamespaceToolName(namespaceName, fn)
517+ }
488518 if mappedName := toolNameMap[fn]; mappedName != "" {
489519 fn = mappedName
490520 }
@@ -703,61 +733,220 @@ func convertResponsesContentPartToClaude(part gjson.Result) []byte { · 212 unmodified lines
703733 return nil
704734 }
705735
706−func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {
707− toolType := strings.TrimSpace(tool.Get("type").String())
708− switch toolType {
709− case "", "function":
710− if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok {
711− return [][]byte{tJSON}
712− }
713− case "namespace":
714− return convertResponsesNamespaceToolToClaude(tool, toolNameMap)
736+func isOpenAIResponsesApplyPatchCustomTool(toolType string, tool gjson.Result) bool {
737+ return toolType == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch"
738+}
739+
740+func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor) ([]byte, bool) {
741+ overrideName := ""
742+ if !descriptor.direct {
743+ overrideName = descriptor.name
744+ }
745+ switch descriptor.toolType {
746+ case "function":
747+ return convertResponsesFunctionToolToClaude(descriptor.tool, overrideName)
748+ case "custom":
749+ return convertResponsesCustomToolToClaude(descriptor.tool, overrideName)
715750 case "web_search":
716− if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok {
717− if name := gjson.GetBytes(tJSON, "name").String(); name != "" {
718− toolNameMap[name] = name
719− }
720− return [][]byte{tJSON}
721− }
751+ return convertResponsesWebSearchToolToClaude(descriptor.tool)
722752 default:
723− if isOpenAIResponsesApplyPatchCustomTool(toolType, tool) {
724− return nil
725− }
726− if isUnsupportedOpenAIBuiltinToolType(toolType) {
727− return nil
753+ if isUnsupportedOpenAIBuiltinToolType(descriptor.toolType) {
754+ return nil, false
728755 }
729− if tool.Get("name").String() != "" {
730− return [][]byte{[]byte(tool.Raw)}
756+ if descriptor.tool.Get("name").String() == "" {
757+ return nil, false
731758 }
759+ return []byte(descriptor.tool.Raw), true
732760 }
733− return nil
734761 }
735762
736−func isOpenAIResponsesApplyPatchCustomTool(toolType string, tool gjson.Result) bool {
737− return toolType == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch"
763+type responsesToolSource struct {
764+ tools gjson.Result
765+ priority int // Top-level tools use 0; all additional_tools sources use 1.
738766 }
739767
740−func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte {
741− namespaceName := strings.TrimSpace(tool.Get("name").String())
742− children := tool.Get("tools")
743− if !children.Exists() || !children.IsArray() {
744− return nil
768+func responsesToolSources(root gjson.Result) []responsesToolSource {
769+ var sources []responsesToolSource
770+ appendSource := func(tools gjson.Result, priority int) {
771+ if tools.Exists() && tools.IsArray() {
772+ sources = append(sources, responsesToolSource{tools: tools, priority: priority})
773+ }
745774 }
746775
747− var out [][]byte
748− children.ForEach(func(_, child gjson.Result) bool {
749− childName := responsesToolName(child)
750− qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName)
751− if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok {
752− out = append(out, tJSON)
753− toolNameMap[qualifiedName] = qualifiedName
754− if childName != "" {
755− toolNameMap[childName] = qualifiedName
776+ appendSource(root.Get("tools"), 0)
777+ if input := root.Get("input"); input.Exists() && input.IsArray() {
778+ input.ForEach(func(_, item gjson.Result) bool {
779+ if item.Get("type").String() == "additional_tools" {
780+ appendSource(item.Get("tools"), 1)
756781 }
782+ return true
783+ })
784+ }
785+ return sources
786+}
787+
788+type responsesToolDescriptor struct {
789+ name string
790+ childName string
791+ namespace string
792+ toolType string
793+ tool gjson.Result
794+ sourcePriority int
795+ direct bool
796+ order int
797+}
798+
799+func responsesToolDescriptors(root gjson.Result) []responsesToolDescriptor {
800+ var descriptors []responsesToolDescriptor
801+ appendDescriptor := func(tool gjson.Result, name, childName, namespaceName, toolType string, sourcePriority int, direct bool) {
802+ if name == "" {
803+ return
757804 }
758− return true
759− })
760− return out
805+ descriptors = append(descriptors, responsesToolDescriptor{
806+ name: name,
807+ childName: childName,
808+ namespace: namespaceName,
809+ toolType: toolType,
810+ tool: tool,
811+ sourcePriority: sourcePriority,
812+ direct: direct,
813+ order: len(descriptors),
814+ })
815+ }
816+ appendNamespaceChildren := func(namespaceTool gjson.Result, sourcePriority int) {
817+ namespaceName := strings.TrimSpace(namespaceTool.Get("name").String())
818+ children := namespaceTool.Get("tools")
819+ if !children.Exists() || !children.IsArray() {
820+ return
821+ }
822+ children.ForEach(func(_, child gjson.Result) bool {
823+ childName := responsesToolName(child)
824+ if childName == "" {
825+ return true
826+ }
827+ qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName)
828+ switch strings.TrimSpace(child.Get("type").String()) {
829+ case "", "function":
830+ appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false)
831+ case "custom":
832+ if !isOpenAIResponsesApplyPatchCustomTool("custom", child) {
833+ appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false)
834+ }
835+ }
836+ return true
837+ })
838+ }
839+ for _, source := range responsesToolSources(root) {
840+ source.tools.ForEach(func(_, tool gjson.Result) bool {
841+ toolType := strings.TrimSpace(tool.Get("type").String())
842+ switch toolType {
843+ case "", "function":
844+ appendDescriptor(tool, responsesToolName(tool), "", "", "function", source.priority, true)
845+ case "custom":
846+ if !isOpenAIResponsesApplyPatchCustomTool("custom", tool) {
847+ appendDescriptor(tool, responsesToolName(tool), "", "", "custom", source.priority, true)
848+ }
849+ case "namespace":
850+ appendNamespaceChildren(tool, source.priority)
851+ case "web_search":
852+ if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
853+ return true
854+ }
855+ name := strings.TrimSpace(tool.Get("name").String())
856+ if name == "" {
857+ name = "web_search"
858+ }
859+ appendDescriptor(tool, name, "", "", "web_search", source.priority, true)
860+ default:
861+ if isUnsupportedOpenAIBuiltinToolType(toolType) {
862+ return true
863+ }
864+ appendDescriptor(tool, strings.TrimSpace(tool.Get("name").String()), "", "", toolType, source.priority, true)
865+ }
866+ return true
867+ })
868+ }
869+ return descriptors
870+}
871+
872+func responsesToolDescriptorPrecedes(left, right responsesToolDescriptor) bool {
873+ // Keep top-level tools ahead of additional_tools, then let direct
874+ // declarations win over namespace children within the same source class.
875+ if left.sourcePriority != right.sourcePriority {
876+ return left.sourcePriority < right.sourcePriority
877+ }
878+ if left.direct != right.direct {
879+ return left.direct
880+ }
881+ return left.order < right.order
882+}
883+
884+func responsesToolWinners(root gjson.Result) map[string]responsesToolDescriptor {
885+ winners := map[string]responsesToolDescriptor{}
886+ for _, descriptor := range responsesToolDescriptors(root) {
887+ current, exists := winners[descriptor.name]
888+ if !exists || responsesToolDescriptorPrecedes(descriptor, current) {
889+ winners[descriptor.name] = descriptor
890+ }
891+ }
892+ return winners
893+}
894+
895+func responsesToolNameMap(root gjson.Result, acceptedToolNames map[string]struct{}) map[string]string {
896+ toolNameMap := map[string]string{}
897+ descriptors := responsesToolDescriptors(root)
898+ winners := responsesToolWinners(root)
899+
900+ // Direct tool names are canonical aliases and must win over namespace
901+ // child aliases, regardless of declaration order.
902+ for _, descriptor := range descriptors {
903+ winner, ok := winners[descriptor.name]
904+ if !ok || winner.order != descriptor.order || !descriptor.direct {
905+ continue
906+ }
907+ if _, accepted := acceptedToolNames[descriptor.name]; !accepted {
908+ continue
909+ }
910+ toolNameMap[descriptor.name] = descriptor.name
911+ }
912+
913+ // Namespace aliases fill only names that are not already owned by a
914+ // winning direct function/custom tool.
915+ for _, descriptor := range descriptors {
916+ winner, ok := winners[descriptor.name]
917+ if !ok || winner.order != descriptor.order || descriptor.direct || descriptor.childName == "" {
918+ continue
919+ }
920+ if _, accepted := acceptedToolNames[descriptor.name]; !accepted {
921+ continue
922+ }
923+ if _, exists := toolNameMap[descriptor.childName]; exists {
924+ continue
925+ }
926+ toolNameMap[descriptor.childName] = descriptor.name
927+ }
928+ return toolNameMap
929+}
930+
931+func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} {
932+ names := make(map[string]struct{})
933+ root := gjson.ParseBytes(requestRawJSON)
934+ for name, descriptor := range responsesToolWinners(root) {
935+ if descriptor.toolType == "custom" {
936+ names[name] = struct{}{}
937+ }
938+ }
939+ return names
940+}
941+
942+func unwrapCustomToolInput(arguments string) string {
943+ if v := gjson.Get(arguments, "input"); v.Exists() {
944+ if v.Type == gjson.String {
945+ return v.String()
946+ }
947+ return v.Raw
948+ }
949+ return arguments
761950 }
762951
763952 func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
@@ -782,6 +971,24 @@ func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string · 18 unmodified lines
782971 return tJSON, true
783972 }
784973
974+func convertResponsesCustomToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
975+ name := strings.TrimSpace(overrideName)
976+ if name == "" {
977+ name = responsesToolName(tool)
978+ }
979+ if name == "" {
980+ return nil, false
981+ }
982+
983+ tJSON := []byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}`)
984+ tJSON, _ = sjson.SetBytes(tJSON, "name", name)
985+ if description := responsesToolDescription(tool); description != "" {
986+ tJSON, _ = sjson.SetBytes(tJSON, "description", description)
987+ }
988+ tJSON = common.AttachCacheControl(tJSON, tool)
989+ return tJSON, true
990+}
991+
785992 func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
786993 if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
787994 return nil, false
@@ -839,7 +1046,7 @@ func qualifyResponsesNamespaceToolName(namespaceName, childName string) string { · 51 unmodified lines
8391046 if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
8401047 return childName
8411048 }
842− if strings.HasPrefix(childName, namespaceName) {
1049+ if childName == namespaceName || strings.HasPrefix(childName, namespaceName+"__") {
8431050 return childName
8441051 }
8451052 if strings.HasSuffix(namespaceName, "__") {
@@ -854,43 +1061,15 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali · 8 unmodified lines
8541061 return "", ""
8551062 }
8561063
857− tools := gjson.GetBytes(requestRawJSON, "tools")
858− if !tools.Exists() || !tools.IsArray() {
1064+ root := gjson.ParseBytes(requestRawJSON)
1065+ descriptor, ok := responsesToolWinners(root)[qualifiedName]
1066+ if !ok {
8591067 return qualifiedName, ""
8601068 }
861−
862− var bestNamespace string
863− var bestChild string
864− tools.ForEach(func(_, tool gjson.Result) bool {
865− if strings.TrimSpace(tool.Get("type").String()) != "namespace" {
866− return true
867− }
868− namespaceName := strings.TrimSpace(tool.Get("name").String())
869− if namespaceName == "" {
870− return true
871− }
872− children := tool.Get("tools")
873− if !children.Exists() || !children.IsArray() {
874− return true
875− }
876− children.ForEach(func(_, child gjson.Result) bool {
877− childName := responsesToolName(child)
878− if childName == "" {
879− return true
880− }
881− if qualifyResponsesNamespaceToolName(namespaceName, childName) == qualifiedName {
882− bestNamespace = namespaceName
883− bestChild = childName
884− }
885− return true
886− })
887− return true
888− })
889−
890− if bestNamespace == "" || bestChild == "" {
891− return qualifiedName, ""
1069+ if descriptor.toolType == "function" && !descriptor.direct {
1070+ return descriptor.childName, descriptor.namespace
8921071 }
893− return bestChild, bestNamespace
1072+ return qualifiedName, ""
8941073 }
8951074
8961075 func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
internal/translator/claude/openai/responses/claude_openai-responses_request_test.go+310−0
@@ -441,6 +441,316 @@ func TestConvertOpenAIResponsesRequestToClaude_NormalizesRootToolSchemaUnion(t * · 440 unmodified lines
441441 }
442442 }
443443
444+func TestConvertOpenAIResponsesRequestToClaude_MergesAdditionalToolsAndPrefersTopLevel(t *testing.T) {
445+ raw := []byte(`{
446+ "model":"claude-test",
447+ "tools":[
448+ {
449+ "type":"function",
450+ "name":"exec",
451+ "description":"top-level exec",
452+ "parameters":{"type":"object","properties":{"command":{"type":"string"}}}
453+ },
454+ {
455+ "type":"namespace",
456+ "name":"collaboration",
457+ "tools":[{"type":"function","name":"spawn","description":"top-level spawn","parameters":{"type":"object","properties":{}}}]
458+ }
459+ ],
460+ "input":[
461+ {
462+ "type":"additional_tools",
463+ "role":"developer",
464+ "tools":[
465+ {"type":"custom","name":"exec","description":"additional exec"},
466+ {"type":"function","name":"wait","parameters":{"type":"object","properties":{}}},
467+ {"type":"namespace","name":"collaboration","tools":[
468+ {"type":"function","name":"spawn","parameters":{"type":"object","properties":{}}},
469+ {"type":"custom","name":"send","description":"send a message"}
470+ ]}
471+ ]
472+ },
473+ {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}
474+ ]
475+ }`)
476+
477+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false))
478+ if got := root.Get("tools.#").Int(); got != 4 {
479+ t.Fatalf("tools count = %d, want 4; output=%s", got, root.Raw)
480+ }
481+ if got := root.Get(`tools.#(name=="exec").description`).String(); got != "top-level exec" {
482+ t.Fatalf("exec description = %q, want top-level exec", got)
483+ }
484+ if got := root.Get(`tools.#(name=="wait").name`).String(); got != "wait" {
485+ t.Fatalf("additional function name = %q, want wait", got)
486+ }
487+ if got := root.Get(`tools.#(name=="collaboration__spawn").name`).String(); got != "collaboration__spawn" {
488+ t.Fatalf("namespace function name = %q, want collaboration__spawn", got)
489+ }
490+ custom := root.Get(`tools.#(name=="collaboration__send")`)
491+ if !custom.Exists() {
492+ t.Fatal("missing namespace custom tool")
493+ }
494+ if got := custom.Get("input_schema.properties.input.type").String(); got != "string" {
495+ t.Fatalf("custom input schema type = %q, want string", got)
496+ }
497+}
498+
499+func TestConvertOpenAIResponsesRequestToClaude_DeduplicatesExpandedToolNames(t *testing.T) {
500+ raw := []byte(`{
501+ "model":"claude-test",
502+ "tools":[{"type":"function","name":"collaboration__send","description":"top-level send","parameters":{"type":"object","properties":{}}}],
503+ "input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"collaboration","tools":[
504+ {"type":"function","name":"send","description":"additional send","parameters":{"type":"object","properties":{}}},
505+ {"type":"function","name":"other","parameters":{"type":"object","properties":{}}}
506+ ]}]}]
507+ }`)
508+
509+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false))
510+ if got := root.Get("tools.#").Int(); got != 2 {
511+ t.Fatalf("tools count = %d, want 2; output=%s", got, root.Raw)
512+ }
513+ if got := root.Get(`tools.#(name=="collaboration__send").description`).String(); got != "top-level send" {
514+ t.Fatalf("duplicate final name description = %q, want top-level send", got)
515+ }
516+ if !root.Get(`tools.#(name=="collaboration__other")`).Exists() {
517+ t.Fatal("unique namespace child was dropped")
518+ }
519+ customNames := responsesCustomToolNames(raw)
520+ if _, ok := customNames["collaboration__send"]; ok {
521+ t.Fatal("final-name collision should keep the top-level function type")
522+ }
523+ name, namespace := splitResponsesQualifiedFunctionCallFromRequest(raw, "collaboration__send")
524+ if name != "collaboration__send" || namespace != "" {
525+ t.Fatalf("final-name collision namespace = (%q, %q), want (collaboration__send, empty)", name, namespace)
526+ }
527+}
528+
529+func TestConvertOpenAIResponsesRequestToClaude_DirectToolWinsOverEarlierNamespaceCollision(t *testing.T) {
530+ raw := []byte(`{
531+ "model":"claude-test",
532+ "tools":[
533+ {"type":"namespace","name":"n","tools":[{"type":"function","name":"x","parameters":{"type":"object","properties":{}}}]},
534+ {"type":"custom","name":"n__x"}
535+ ],
536+ "tool_choice":{"type":"custom","name":"n__x"}
537+ }`)
538+
539+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false))
540+ if got := root.Get("tools.#").Int(); got != 1 {
541+ t.Fatalf("tools count = %d, want 1; output=%s", got, root.Raw)
542+ }
543+ if got := root.Get("tools.0.name").String(); got != "n__x" {
544+ t.Fatalf("winning tool name = %q, want n__x", got)
545+ }
546+ if got := root.Get("tools.0.input_schema.properties.input.type").String(); got != "string" {
547+ t.Fatalf("winning tool schema type = %q, want string for custom tool", got)
548+ }
549+ if got := root.Get("tool_choice.name").String(); got != "n__x" {
550+ t.Fatalf("tool_choice.name = %q, want n__x; output=%s", got, root.Raw)
551+ }
552+ if _, ok := responsesCustomToolNames(raw)["n__x"]; !ok {
553+ t.Fatal("winning direct custom tool was not classified as custom")
554+ }
555+}
556+
557+func TestConvertOpenAIResponsesRequestToClaude_PrefersDirectToolAcrossAdditionalSources(t *testing.T) {
558+ raw := []byte(`{
559+ "model":"claude-test",
560+ "input":[
561+ {"type":"additional_tools","tools":[{"type":"namespace","name":"n","tools":[{"type":"function","name":"x","description":"namespace x","parameters":{"type":"object","properties":{}}}]}]},
562+ {"type":"additional_tools","tools":[{"type":"custom","name":"n__x","description":"direct x"}]}
563+ ]
564+ }`)
565+
566+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false))
567+ if got := root.Get("tools.#").Int(); got != 1 {
568+ t.Fatalf("tools count = %d, want 1; output=%s", got, root.Raw)
569+ }
570+ tool := root.Get("tools.0")
571+ if got := tool.Get("name").String(); got != "n__x" {
572+ t.Fatalf("winning tool name = %q, want n__x", got)
573+ }
574+ if got := tool.Get("description").String(); got != "direct x" {
575+ t.Fatalf("winning tool description = %q, want direct x", got)
576+ }
577+ if got := tool.Get("input_schema.properties.input.type").String(); got != "string" {
578+ t.Fatalf("winning tool schema type = %q, want string for custom tool", got)
579+ }
580+ if _, ok := responsesCustomToolNames(raw)["n__x"]; !ok {
581+ t.Fatal("direct custom tool should win classification across additional sources")
582+ }
583+}
584+
585+func TestConvertOpenAIResponsesRequestToClaude_PreservesToolDeclarationOrder(t *testing.T) {
586+ raw := []byte(`{
587+ "model":"claude-test",
588+ "tools":[
589+ {"type":"function","name":"first","parameters":{"type":"object","properties":{}}},
590+ {"type":"namespace","name":"n","tools":[{"type":"function","name":"middle","parameters":{"type":"object","properties":{}}}]},
591+ {"type":"function","name":"last","parameters":{"type":"object","properties":{}}}
592+ ]
593+ }`)
594+
595+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false))
596+ want := []string{"first", "n__middle", "last"}
597+ got := root.Get("tools.#.name").Array()
598+ if len(got) != len(want) {
599+ t.Fatalf("tools count = %d, want %d; output=%s", len(got), len(want), root.Raw)
600+ }
601+ for i, wantName := range want {
602+ if got[i].String() != wantName {
603+ t.Errorf("tools[%d].name = %q, want %q", i, got[i].String(), wantName)
604+ }
605+ }
606+}
607+
608+func TestConvertOpenAIResponsesRequestToClaude_ReplaysCustomToolCallHistory(t *testing.T) {
609+ raw := []byte(`{
610+ "model":"claude-test",
611+ "input":[
612+ {"type":"custom_tool_call","call_id":"call.custom:1","name":"exec","input":"pwd"},
613+ {"type":"custom_tool_call_output","call_id":"call.custom:1","output":"/workspace"}
614+ ]
615+ }`)
616+
617+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false))
618+ toolUse := root.Get("messages.0.content.0")
619+ if got := toolUse.Get("type").String(); got != "tool_use" {
620+ t.Fatalf("tool use type = %q, want tool_use; output=%s", got, root.Raw)
621+ }
622+ if got := toolUse.Get("id").String(); got != "call_custom_1" {
623+ t.Fatalf("tool use id = %q, want call_custom_1", got)
624+ }
625+ if got := toolUse.Get("input.input").String(); got != "pwd" {
626+ t.Fatalf("custom tool input = %q, want pwd", got)
627+ }
628+ toolResult := root.Get("messages.1.content.0")
629+ if got := toolResult.Get("type").String(); got != "tool_result" {
630+ t.Fatalf("tool result type = %q, want tool_result", got)
631+ }
632+ if got := toolResult.Get("tool_use_id").String(); got != "call_custom_1" {
633+ t.Fatalf("tool result id = %q, want call_custom_1", got)
634+ }
635+ if got := toolResult.Get("content").String(); got != "/workspace" {
636+ t.Fatalf("tool result content = %q, want /workspace", got)
637+ }
638+}
639+
640+func TestConvertOpenAIResponsesRequestToClaude_ReplaysNamespacedFunctionCallHistory(t *testing.T) {
641+ raw := []byte(`{
642+ "model":"claude-test",
643+ "input":[
644+ {"type":"additional_tools","tools":[{"type":"namespace","name":"mcp__node_repl","tools":[{"type":"function","name":"js","parameters":{"type":"object","properties":{}}}]}]},
645+ {"type":"function_call","call_id":"call.namespace","name":"js","namespace":"mcp__node_repl","arguments":"{\"code\":\"pwd\"}"},
646+ {"type":"function_call_output","call_id":"call.namespace","output":"ok"}
647+ ]
648+ }`)
649+
650+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false))
651+ if !root.Get(`tools.#(name=="mcp__node_repl__js")`).Exists() {
652+ t.Fatal("missing qualified namespace tool declaration")
653+ }
654+ toolUse := root.Get("messages.0.content.0")
655+ if got := toolUse.Get("name").String(); got != "mcp__node_repl__js" {
656+ t.Fatalf("historical tool_use name = %q, want mcp__node_repl__js", got)
657+ }
658+ if got := root.Get("messages.1.content.0.tool_use_id").String(); got != "call_namespace" {
659+ t.Fatalf("historical tool_result id = %q, want call_namespace", got)
660+ }
661+}
662+
663+func TestConvertOpenAIResponsesRequestToClaude_MapsCustomAndNamespacedToolChoice(t *testing.T) {
664+ tests := []struct {
665+ name string
666+ raw string
667+ wantToolName string
668+ }{
669+ {
670+ name: "custom",
671+ raw: `{
672+ "model":"claude-test",
673+ "tools":[{"type":"custom","name":"exec"}],
674+ "tool_choice":{"type":"custom","name":"exec"}
675+ }`,
676+ wantToolName: "exec",
677+ },
678+ {
679+ name: "namespace",
680+ raw: `{
681+ "model":"claude-test",
682+ "input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"mcp__node_repl","tools":[{"type":"function","name":"js"}]}]}],
683+ "tool_choice":{"type":"function","name":"js","namespace":"mcp__node_repl"}
684+ }`,
685+ wantToolName: "mcp__node_repl__js",
686+ },
687+ {
688+ name: "top-level-short-name-wins",
689+ raw: `{
690+ "model":"claude-test",
691+ "tools":[{"type":"function","name":"foo"}],
692+ "input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"mcp__tools","tools":[{"type":"function","name":"foo"}]}]}],
693+ "tool_choice":{"type":"function","name":"foo"}
694+ }`,
695+ wantToolName: "foo",
696+ },
697+ }
698+
699+ for _, tt := range tests {
700+ t.Run(tt.name, func(t *testing.T) {
701+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", []byte(tt.raw), false))
702+ if got := root.Get("tool_choice.type").String(); got != "tool" {
703+ t.Fatalf("tool_choice.type = %q, want tool; output=%s", got, root.Raw)
704+ }
705+ if got := root.Get("tool_choice.name").String(); got != tt.wantToolName {
706+ t.Fatalf("tool_choice.name = %q, want %q", got, tt.wantToolName)
707+ }
708+ })
709+ }
710+}
711+
712+func TestQualifyResponsesNamespaceToolNameAvoidsPrefixCollision(t *testing.T) {
713+ tests := []struct {
714+ namespace string
715+ child string
716+ want string
717+ }{
718+ {namespace: "collab", child: "collaboration", want: "collab__collaboration"},
719+ {namespace: "collab", child: "collab__send", want: "collab__send"},
720+ {namespace: "collab__", child: "send", want: "collab__send"},
721+ {namespace: "mcp__node_repl", child: "mcp__node_repl__js", want: "mcp__node_repl__js"},
722+ }
723+
724+ for _, tt := range tests {
725+ got := qualifyResponsesNamespaceToolName(tt.namespace, tt.child)
726+ if got != tt.want {
727+ t.Errorf("qualifyResponsesNamespaceToolName(%q, %q) = %q, want %q", tt.namespace, tt.child, got, tt.want)
728+ }
729+ }
730+
731+ raw := []byte(`{
732+ "tools":[{"type":"namespace","name":"collab","tools":[{"type":"function","name":"collaboration"}]}]
733+ }`)
734+ root := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false))
735+ if got := root.Get("tools.0.name").String(); got != "collab__collaboration" {
736+ t.Fatalf("qualified tool declaration = %q, want collab__collaboration", got)
737+ }
738+}
739+
740+func TestSplitResponsesQualifiedFunctionCallFromAdditionalTools(t *testing.T) {
741+ raw := []byte(`{
742+ "input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"mcp__node_repl","tools":[{"type":"function","name":"js"}]}]}]
743+ }`)
744+
745+ name, namespace := splitResponsesQualifiedFunctionCallFromRequest(raw, "mcp__node_repl__js")
746+ if name != "js" {
747+ t.Fatalf("name = %q, want js", name)
748+ }
749+ if namespace != "mcp__node_repl" {
750+ t.Fatalf("namespace = %q, want mcp__node_repl", namespace)
751+ }
752+}
753+
444754 func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) {
445755 t.Helper()
446756 channelBlock := []byte{}
internal/translator/claude/openai/responses/claude_openai-responses_response.go+103−30
@@ -29,6 +29,7 @@ type claudeToResponsesState struct { · 28 unmodified lines
2929 // function call bookkeeping for output aggregation
3030 FuncNames map[int]string // Claude block index -> function name
3131 FuncCallIDs map[int]string // Claude block index -> call id
32+ FuncCustom map[int]bool // Claude block index -> freeform custom tool
3233 FuncOutputIndices map[int]int // Claude block index -> Responses output index
3334 // message text aggregation
3435 TextBuf strings.Builder
@@ -251,6 +252,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 216 unmodified lines
251252 FuncArgsBuf: make(map[int]*strings.Builder),
252253 FuncNames: make(map[int]string),
253254 FuncCallIDs: make(map[int]string),
255+ FuncCustom: make(map[int]bool),
254256 FuncOutputIndices: make(map[int]int),
255257 }
256258 }
@@ -262,6 +264,8 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 5 unmodified lines
262264 }
263265 rawJSON = bytes.TrimSpace(rawJSON[5:])
264266 root := gjson.ParseBytes(rawJSON)
267+ requestForToolMetadata := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
268+ customToolNames := responsesCustomToolNames(requestForToolMetadata)
265269 ev := root.Get("type").String()
266270 var out [][]byte
267271
@@ -294,6 +298,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 26 unmodified lines
294298 st.FuncArgsBuf = make(map[int]*strings.Builder)
295299 st.FuncNames = make(map[int]string)
296300 st.FuncCallIDs = make(map[int]string)
301+ st.FuncCustom = make(map[int]bool)
297302 st.FuncOutputIndices = make(map[int]int)
298303 st.Usage = claudeResponsesUsageTokens{}
299304 st.Usage.Merge(msg.Get("usage"))
@@ -354,18 +359,31 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 54 unmodified lines
354359 st.InFuncBlock = true
355360 st.CurrentFCID = cb.Get("id").String()
356361 name := cb.Get("name").String()
362+ _, isCustomTool := customToolNames[name]
363+ if st.FuncCustom == nil {
364+ st.FuncCustom = make(map[int]bool)
365+ }
366+ st.FuncCustom[idx] = isCustomTool
357367 outputIndex := st.functionOutputIndex(idx)
358− 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":""}}`)
368+ var item []byte
369+ if isCustomTool {
370+ 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":""}}`)
371+ item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("ctc_%s", st.CurrentFCID))
372+ item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID)
373+ item, _ = sjson.SetBytes(item, "item.name", name)
374+ } else {
375+ 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":""}}`)
376+ item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
377+ item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID)
378+ item = applyResponsesFunctionCallNamespaceFields(item, requestForToolMetadata, name, "item")
379+ }
359380 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
360381 item, _ = sjson.SetBytes(item, "output_index", outputIndex)
361− item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
362− item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID)
363− item = applyResponsesFunctionCallNamespaceFields(item, pickRequestJSON(originalRequestRawJSON, requestRawJSON), name, "item")
364382 out = append(out, emitEvent("response.output_item.added", item))
365383 if st.FuncArgsBuf[idx] == nil {
366384 st.FuncArgsBuf[idx] = &strings.Builder{}
367385 }
368− // record function metadata for aggregation
386+ // Record function metadata for aggregation.
369387 st.FuncCallIDs[idx] = st.CurrentFCID
370388 st.FuncNames[idx] = name
371389 } else if typ == "thinking" || typ == "redacted_thinking" {
@@ -417,6 +435,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 45 unmodified lines
417435 st.FuncArgsBuf[idx] = &strings.Builder{}
418436 }
419437 st.FuncArgsBuf[idx].WriteString(pj.String())
438+ if st.FuncCustom[idx] {
439+ return [][]byte{}
440+ }
420441 outputIndex := st.functionOutputIndex(idx)
421442 msg := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
422443 msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
@@ -457,25 +478,47 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 34 unmodified lines
457478 } else if st.InFuncBlock {
458479 outputIndex := st.functionOutputIndex(idx)
459480 args := "{}"
481+ if st.FuncCustom[idx] {
482+ args = ""
483+ }
460484 if buf := st.FuncArgsBuf[idx]; buf != nil {
461485 if buf.Len() > 0 {
462486 args = buf.String()
463487 }
464488 }
465− fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
466− fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
467− fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID))
468− fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)
469− fcDone, _ = sjson.SetBytes(fcDone, "arguments", args)
470− out = append(out, emitEvent("response.function_call_arguments.done", fcDone))
471− itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
472− itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
473− itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
474− itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
475− itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
476− itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID)
477− itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, pickRequestJSON(originalRequestRawJSON, requestRawJSON), st.FuncNames[idx], "item")
478− out = append(out, emitEvent("response.output_item.done", itemDone))
489+ if st.FuncCustom[idx] {
490+ input := unwrapCustomToolInput(args)
491+ inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
492+ inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
493+ inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.CurrentFCID))
494+ inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex)
495+ inputDone, _ = sjson.SetBytes(inputDone, "input", input)
496+ out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone))
497+
498+ 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":""}}`)
499+ itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
500+ itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
501+ itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.CurrentFCID))
502+ itemDone, _ = sjson.SetBytes(itemDone, "item.input", input)
503+ itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID)
504+ itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[idx])
505+ out = append(out, emitEvent("response.output_item.done", itemDone))
506+ } else {
507+ fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
508+ fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
509+ fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID))
510+ fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)
511+ fcDone, _ = sjson.SetBytes(fcDone, "arguments", args)
512+ out = append(out, emitEvent("response.function_call_arguments.done", fcDone))
513+ itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
514+ itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
515+ itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
516+ itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
517+ itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
518+ itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID)
519+ itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForToolMetadata, st.FuncNames[idx], "item")
520+ out = append(out, emitEvent("response.output_item.done", itemDone))
521+ }
479522 st.InFuncBlock = false
480523 } else if st.ReasoningActive {
481524 full := st.ReasoningBuf.String()
@@ -629,6 +672,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 147 unmodified lines
629672 }
630673 for _, idx := range idxs {
631674 args := "{}"
675+ if st.FuncCustom[idx] {
676+ args = ""
677+ }
632678 if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {
633679 args = b.String()
634680 }
@@ -637,12 +683,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 2 unmodified lines
637683 if callID == "" && st.CurrentFCID != "" {
638684 callID = st.CurrentFCID
639685 }
640− item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
641− item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
642− item, _ = sjson.SetBytes(item, "arguments", args)
643− item, _ = sjson.SetBytes(item, "call_id", callID)
644− item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "")
645− outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item)
686+ if st.FuncCustom[idx] {
687+ item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
688+ item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
689+ item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args))
690+ item, _ = sjson.SetBytes(item, "call_id", callID)
691+ item, _ = sjson.SetBytes(item, "name", name)
692+ outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item)
693+ } else {
694+ item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
695+ item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
696+ item, _ = sjson.SetBytes(item, "arguments", args)
697+ item, _ = sjson.SetBytes(item, "call_id", callID)
698+ item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "")
699+ outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item)
700+ }
646701 }
647702 }
648703 if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 {
@@ -694,6 +749,9 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 45 unmodified lines
694749 }
695750 }
696751
752+ reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
753+ customToolNames := responsesCustomToolNames(reqBytes)
754+
697755 // Base OpenAI Responses (non-stream) object
698756 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}}`)
699757
@@ -770,9 +828,17 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 70 unmodified lines
770828 activeMessageItem = item
771829 case "tool_use":
772830 activeMessageItem = nil
773− item := newOutputItem("function_call", idx)
831+ itemType := "function_call"
832+ if _, isCustomTool := customToolNames[cb.Get("name").String()]; isCustomTool {
833+ itemType = "custom_tool_call"
834+ }
835+ item := newOutputItem(itemType, idx)
774836 item.callID = cb.Get("id").String()
775− item.id = fmt.Sprintf("fc_%s", item.callID)
837+ if itemType == "custom_tool_call" {
838+ item.id = fmt.Sprintf("ctc_%s", item.callID)
839+ } else {
840+ item.id = fmt.Sprintf("fc_%s", item.callID)
841+ }
776842 item.name = cb.Get("name").String()
777843 case "thinking", "redacted_thinking":
778844 activeMessageItem = nil
@@ -797,7 +863,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 18 unmodified lines
797863 }
798864 }
799865 case "input_json_delta":
800− if item != nil && item.itemType == "function_call" {
866+ if item != nil && (item.itemType == "function_call" || item.itemType == "custom_tool_call") {
801867 if pj := d.Get("partial_json"); pj.Exists() {
802868 item.args.WriteString(pj.String())
803869 }
@@ -839,7 +905,6 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 35 unmodified lines
839905 out, _ = sjson.SetBytes(out, "created_at", createdAt)
840906
841907 // Inject request echo fields as top-level (similar to streaming variant)
842− reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
843908 if len(reqBytes) > 0 {
844909 req := gjson.ParseBytes(reqBytes)
845910 if v := req.Get("instructions"); v.Exists() {
@@ -923,7 +988,15 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 77 unmodified lines
923988 if len(outputItem.annotations) > 0 {
924989 item, _ = sjson.SetBytes(item, "content.0.annotations", outputItem.annotations)
925990 }
926− case "function_call":
991+ case "function_call", "custom_tool_call":
992+ if outputItem.itemType == "custom_tool_call" {
993+ item = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
994+ item, _ = sjson.SetBytes(item, "id", outputItem.id)
995+ item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(outputItem.args.String()))
996+ item, _ = sjson.SetBytes(item, "call_id", outputItem.callID)
997+ item, _ = sjson.SetBytes(item, "name", outputItem.name)
998+ break
999+ }
9271000 args := outputItem.args.String()
9281001 if args == "" {
9291002 args = "{}"
internal/translator/claude/openai/responses/claude_openai-responses_response_test.go+173−0
@@ -877,6 +877,179 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ReportsCacheTokens(t *t · 876 unmodified lines
877877 }
878878 }
879879
880+func TestConvertClaudeResponseToOpenAIResponses_RestoresAdditionalCustomToolCall(t *testing.T) {
881+ originalRequest := []byte(`{
882+ "model":"gpt-test",
883+ "input":[{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"exec"}]}]
884+ }`)
885+ chunks := [][]byte{
886+ []byte(`data: {"type":"message_start","message":{"id":"msg_custom","usage":{"input_tokens":1,"output_tokens":0}}}`),
887+ []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom","name":"exec","input":{}}}`),
888+ []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`),
889+ []byte(`data: {"type":"content_block_stop","index":0}`),
890+ []byte(`data: {"type":"message_stop"}`),
891+ }
892+
893+ var param any
894+ var added, inputDone, done, completed gjson.Result
895+ functionEvents := 0
896+ for _, chunk := range chunks {
897+ for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, &param) {
898+ event, data := parseClaudeResponsesSSEEvent(t, output)
899+ switch event {
900+ case "response.output_item.added":
901+ if data.Get("item.type").String() == "custom_tool_call" {
902+ added = data
903+ }
904+ case "response.custom_tool_call_input.done":
905+ inputDone = data
906+ case "response.output_item.done":
907+ if data.Get("item.type").String() == "custom_tool_call" {
908+ done = data
909+ }
910+ case "response.function_call_arguments.delta", "response.function_call_arguments.done":
911+ functionEvents++
912+ case "response.completed":
913+ completed = data
914+ }
915+ }
916+ }
917+
918+ if !added.Exists() || !inputDone.Exists() || !done.Exists() || !completed.Exists() {
919+ t.Fatalf("missing custom tool lifecycle events: added=%v input_done=%v done=%v completed=%v", added.Exists(), inputDone.Exists(), done.Exists(), completed.Exists())
920+ }
921+ if functionEvents != 0 {
922+ t.Fatalf("function call events = %d, want 0", functionEvents)
923+ }
924+ if got := added.Get("item.name").String(); got != "exec" {
925+ t.Fatalf("added name = %q, want exec", got)
926+ }
927+ if got := inputDone.Get("input").String(); got != "pwd" {
928+ t.Fatalf("custom input.done input = %q, want pwd", got)
929+ }
930+ if got := done.Get("item.input").String(); got != "pwd" {
931+ t.Fatalf("done input = %q, want pwd", got)
932+ }
933+ if got := completed.Get("response.output.0.type").String(); got != "custom_tool_call" {
934+ t.Fatalf("completed output type = %q, want custom_tool_call", got)
935+ }
936+ if got := completed.Get("response.output.0.input").String(); got != "pwd" {
937+ t.Fatalf("completed input = %q, want pwd", got)
938+ }
939+}
940+
941+func TestConvertClaudeResponseToOpenAIResponses_DirectCustomWinsNamespaceCollision(t *testing.T) {
942+ originalRequest := []byte(`{
943+ "model":"gpt-test",
944+ "tools":[
945+ {"type":"namespace","name":"n","tools":[{"type":"function","name":"x"}]},
946+ {"type":"custom","name":"n__x"}
947+ ]
948+ }`)
949+ streamChunks := [][]byte{
950+ []byte(`data: {"type":"message_start","message":{"id":"msg_collision","usage":{"input_tokens":1,"output_tokens":0}}}`),
951+ []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_collision","name":"n__x","input":{}}}`),
952+ []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`),
953+ []byte(`data: {"type":"content_block_stop","index":0}`),
954+ []byte(`data: {"type":"message_stop"}`),
955+ }
956+
957+ var param any
958+ var streamCompleted gjson.Result
959+ for _, chunk := range streamChunks {
960+ for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, &param) {
961+ event, data := parseClaudeResponsesSSEEvent(t, output)
962+ if event == "response.completed" {
963+ streamCompleted = data
964+ }
965+ }
966+ }
967+ if got := streamCompleted.Get("response.output.0.type").String(); got != "custom_tool_call" {
968+ t.Fatalf("stream output type = %q, want custom_tool_call", got)
969+ }
970+ if got := streamCompleted.Get("response.output.0.input").String(); got != "pwd" {
971+ t.Fatalf("stream output input = %q, want pwd", got)
972+ }
973+
974+ nonStreamRaw := []byte(strings.Join([]string{
975+ `data: {"type":"message_start","message":{"id":"msg_collision_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`,
976+ `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_collision_nonstream","name":"n__x","input":{}}}`,
977+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`,
978+ `data: {"type":"content_block_stop","index":0}`,
979+ `data: {"type":"message_stop"}`,
980+ }, "\n"))
981+ nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, nonStreamRaw, nil))
982+ if got := nonStream.Get("output.0.type").String(); got != "custom_tool_call" {
983+ t.Fatalf("non-stream output type = %q, want custom_tool_call", got)
984+ }
985+ if got := nonStream.Get("output.0.input").String(); got != "pwd" {
986+ t.Fatalf("non-stream output input = %q, want pwd", got)
987+ }
988+}
989+
990+func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresAdditionalCustomToolCall(t *testing.T) {
991+ originalRequest := []byte(`{
992+ "model":"gpt-test",
993+ "input":[{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"exec"}]}]
994+ }`)
995+ raw := []byte(strings.Join([]string{
996+ `data: {"type":"message_start","message":{"id":"msg_custom_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`,
997+ `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom_nonstream","name":"exec","input":{}}}`,
998+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`,
999+ `data: {"type":"content_block_stop","index":0}`,
1000+ `data: {"type":"message_stop"}`,
1001+ }, "\n"))
1002+
1003+ root := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil))
1004+ if got := root.Get("output.0.type").String(); got != "custom_tool_call" {
1005+ t.Fatalf("non-stream output type = %q, want custom_tool_call; output=%s", got, root.Raw)
1006+ }
1007+ if got := root.Get("output.0.input").String(); got != "pwd" {
1008+ t.Fatalf("non-stream input = %q, want pwd", got)
1009+ }
1010+ if got := root.Get("output.0.call_id").String(); got != "call_custom_nonstream" {
1011+ t.Fatalf("non-stream call_id = %q, want call_custom_nonstream", got)
1012+ }
1013+}
1014+
1015+func TestConvertClaudeResponseToOpenAIResponses_CustomToolEmptyInputMatchesNonStream(t *testing.T) {
1016+ originalRequest := []byte(`{
1017+ "model":"gpt-test",
1018+ "tools":[{"type":"custom","name":"exec"}]
1019+ }`)
1020+ streamChunks := [][]byte{
1021+ []byte(`data: {"type":"message_start","message":{"id":"msg_custom_empty","usage":{"input_tokens":1,"output_tokens":0}}}`),
1022+ []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom_empty","name":"exec","input":{}}}`),
1023+ []byte(`data: {"type":"content_block_stop","index":0}`),
1024+ []byte(`data: {"type":"message_stop"}`),
1025+ }
1026+
1027+ var param any
1028+ var streamCompleted gjson.Result
1029+ for _, chunk := range streamChunks {
1030+ for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, &param) {
1031+ event, data := parseClaudeResponsesSSEEvent(t, output)
1032+ if event == "response.completed" {
1033+ streamCompleted = data
1034+ }
1035+ }
1036+ }
1037+ if got := streamCompleted.Get("response.output.0.input").String(); got != "" {
1038+ t.Fatalf("stream empty custom input = %q, want empty string", got)
1039+ }
1040+
1041+ raw := []byte(strings.Join([]string{
1042+ `data: {"type":"message_start","message":{"id":"msg_custom_empty_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`,
1043+ `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_custom_empty","name":"exec","input":{}}}`,
1044+ `data: {"type":"content_block_stop","index":0}`,
1045+ `data: {"type":"message_stop"}`,
1046+ }, "\n"))
1047+ nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil))
1048+ if got := nonStream.Get("output.0.input").String(); got != "" {
1049+ t.Fatalf("non-stream empty custom input = %q, want empty string", got)
1050+ }
1051+}
1052+
8801053 func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) {
8811054 originalRequest := []byte(`{
8821055 "model":"gpt-test",