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
30// - instructions, input[].role==system and input[].role==developer -> separate30// - instructions, input[].role==system and input[].role==developer -> separate
31// top-level system blocks, in source order31// top-level system blocks, in source order
32// - input[].type==message with input_text/output_text -> user/assistant messages32// - input[].type==message with input_text/output_text -> user/assistant messages
33// - function_call -> assistant tool_use33// - function_call/custom_tool_call -> assistant tool_use
34// - function_call_output -> user tool_result34// - function_call_output/custom_tool_call_output -> user tool_result
35// - tools[].parameters -> tools[].input_schema35// - top-level tools and input[].additional_tools -> Claude tools[].input_schema
36// - max_output_tokens -> max_tokens36// - max_output_tokens -> max_tokens
37// - stream passthrough via parameter37// - stream passthrough via parameter
38func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {38func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
@@ -384,23 +384,33 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 345 unmodified lines
384 pendingReasoningParts = append(pendingReasoningParts, thinkingPart)384 pendingReasoningParts = append(pendingReasoningParts, thinkingPart)
385 }385 }
386 386
387 case "function_call":387 case "function_call", "custom_tool_call":
388 // Map to assistant tool_use388 // Map to assistant tool_use. Freeform custom input is wrapped in an
389 // object because Claude tool_use input must be a JSON object.
389 callID := item.Get("call_id").String()390 callID := item.Get("call_id").String()
390 if callID == "" {391 if callID == "" {
391 callID = genToolCallID()392 callID = genToolCallID()
392 }393 }
393 callID = util.SanitizeClaudeToolID(callID)394 callID = util.SanitizeClaudeToolID(callID)
394 name := item.Get("name").String()395 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"
396 401
397 toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)402 toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
398 toolUse, _ = sjson.SetBytes(toolUse, "id", callID)403 toolUse, _ = sjson.SetBytes(toolUse, "id", callID)
399 toolUse, _ = sjson.SetBytes(toolUse, "name", name)404 toolUse, _ = sjson.SetBytes(toolUse, "name", name)
400 if argsStr != "" && gjson.Valid(argsStr) {405 if isCustomToolCall {
401 argsJSON := gjson.Parse(argsStr)406 toolUse, _ = sjson.SetBytes(toolUse, "input.input", item.Get("input").String())
402 if argsJSON.IsObject() {407 } else {
403 toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))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 }
404 }414 }
405 }415 }
406 416
@@ -413,7 +423,7 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 6 unmodified lines
413 raw: asst,423 raw: asst,
414 })424 })
415 425
416 case "function_call_output":426 case "function_call_output", "custom_tool_call_output":
417 flushPendingReasoning()427 flushPendingReasoning()
418 // Map to user tool_result428 // Map to user tool_result
419 callID := item.Get("call_id").String()429 callID := item.Get("call_id").String()
@@ -446,23 +456,29 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte · 26 unmodified lines
446 includedToolNames := map[string]struct{}{}456 includedToolNames := map[string]struct{}{}
447 toolNameMap := map[string]string{}457 toolNameMap := map[string]string{}
448 458
449 // tools mapping: parameters -> input_schema459 // Responses Lite puts tool definitions in input[].additional_tools. Select
450 if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {460 // one winner for each final name, while keeping the original order for the
451 var toolItems [][]byte461 // tools that survive conversion.
452 tools.ForEach(func(_, tool gjson.Result) bool {462 var toolItems [][]byte
453 convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap)463 winners := responsesToolWinners(root)
454 for _, tJSON := range convertedTools {464 for _, descriptor := range responsesToolDescriptors(root) {
455 toolName := gjson.GetBytes(tJSON, "name").String()465 winner, ok := winners[descriptor.name]
456 if toolName != "" {466 if !ok || winner.order != descriptor.order {
457 includedToolNames[toolName] = struct{}{}467 continue
458 }468 }
459 toolItems = append(toolItems, tJSON)469 tJSON, ok := convertResponsesToolDescriptorToClaude(descriptor)
460 }470 if !ok {
461 return true471 continue
462 })
463 if len(toolItems) > 0 {
464 out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(toolItems))
465 }472 }
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))
466 }482 }
467 483
468 // Map tool_choice similar to Chat Completions translator (optional in docs, safe to handle)484 // 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
480 }496 }
481 }497 }
482 case gjson.JSON:498 case gjson.JSON:
483 if toolChoice.Get("type").String() == "function" {499 choiceType := toolChoice.Get("type").String()
500 if choiceType == "function" || choiceType == "custom" {
484 fn := toolChoice.Get("function.name").String()501 fn := toolChoice.Get("function.name").String()
502 if fn == "" {
503 fn = toolChoice.Get("custom.name").String()
504 }
485 if fn == "" {505 if fn == "" {
486 fn = toolChoice.Get("name").String()506 fn = toolChoice.Get("name").String()
487 }507 }
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 }
488 if mappedName := toolNameMap[fn]; mappedName != "" {518 if mappedName := toolNameMap[fn]; mappedName != "" {
489 fn = mappedName519 fn = mappedName
490 }520 }
@@ -703,61 +733,220 @@ func convertResponsesContentPartToClaude(part gjson.Result) []byte { · 212 unmodified lines
703 return nil733 return nil
704}734}
705 735
706func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {736func isOpenAIResponsesApplyPatchCustomTool(toolType string, tool gjson.Result) bool {
707 toolType := strings.TrimSpace(tool.Get("type").String())737 return toolType == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch"
708 switch toolType {738}
709 case "", "function":739
710 if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok {740func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor) ([]byte, bool) {
711 return [][]byte{tJSON}741 overrideName := ""
712 }742 if !descriptor.direct {
713 case "namespace":743 overrideName = descriptor.name
714 return convertResponsesNamespaceToolToClaude(tool, toolNameMap)744 }
745 switch descriptor.toolType {
746 case "function":
747 return convertResponsesFunctionToolToClaude(descriptor.tool, overrideName)
748 case "custom":
749 return convertResponsesCustomToolToClaude(descriptor.tool, overrideName)
715 case "web_search":750 case "web_search":
716 if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok {751 return convertResponsesWebSearchToolToClaude(descriptor.tool)
717 if name := gjson.GetBytes(tJSON, "name").String(); name != "" {
718 toolNameMap[name] = name
719 }
720 return [][]byte{tJSON}
721 }
722 default:752 default:
723 if isOpenAIResponsesApplyPatchCustomTool(toolType, tool) {753 if isUnsupportedOpenAIBuiltinToolType(descriptor.toolType) {
724 return nil754 return nil, false
725 }
726 if isUnsupportedOpenAIBuiltinToolType(toolType) {
727 return nil
728 }755 }
729 if tool.Get("name").String() != "" {756 if descriptor.tool.Get("name").String() == "" {
730 return [][]byte{[]byte(tool.Raw)}757 return nil, false
731 }758 }
759 return []byte(descriptor.tool.Raw), true
732 }760 }
733 return nil
734}761}
735 762
736func isOpenAIResponsesApplyPatchCustomTool(toolType string, tool gjson.Result) bool {763type responsesToolSource struct {
737 return toolType == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch"764 tools gjson.Result
765 priority int // Top-level tools use 0; all additional_tools sources use 1.
738}766}
739 767
740func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte {768func responsesToolSources(root gjson.Result) []responsesToolSource {
741 namespaceName := strings.TrimSpace(tool.Get("name").String())769 var sources []responsesToolSource
742 children := tool.Get("tools")770 appendSource := func(tools gjson.Result, priority int) {
743 if !children.Exists() || !children.IsArray() {771 if tools.Exists() && tools.IsArray() {
744 return nil772 sources = append(sources, responsesToolSource{tools: tools, priority: priority})
773 }
745 }774 }
746 775
747 var out [][]byte776 appendSource(root.Get("tools"), 0)
748 children.ForEach(func(_, child gjson.Result) bool {777 if input := root.Get("input"); input.Exists() && input.IsArray() {
749 childName := responsesToolName(child)778 input.ForEach(func(_, item gjson.Result) bool {
750 qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName)779 if item.Get("type").String() == "additional_tools" {
751 if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok {780 appendSource(item.Get("tools"), 1)
752 out = append(out, tJSON)
753 toolNameMap[qualifiedName] = qualifiedName
754 if childName != "" {
755 toolNameMap[childName] = qualifiedName
756 }781 }
782 return true
783 })
784 }
785 return sources
786}
787
788type 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
799func 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
757 }804 }
758 return true805 descriptors = append(descriptors, responsesToolDescriptor{
759 })806 name: name,
760 return out807 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
872func 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
884func 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
895func 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
931func 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
942func 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
761}950}
762 951
763func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {952func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
@@ -782,6 +971,24 @@ func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string · 18 unmodified lines
782 return tJSON, true971 return tJSON, true
783}972}
784 973
974func 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
785func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {992func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
786 if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {993 if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
787 return nil, false994 return nil, false
@@ -839,7 +1046,7 @@ func qualifyResponsesNamespaceToolName(namespaceName, childName string) string { · 51 unmodified lines
839 if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {1046 if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
840 return childName1047 return childName
841 }1048 }
842 if strings.HasPrefix(childName, namespaceName) {1049 if childName == namespaceName || strings.HasPrefix(childName, namespaceName+"__") {
843 return childName1050 return childName
844 }1051 }
845 if strings.HasSuffix(namespaceName, "__") {1052 if strings.HasSuffix(namespaceName, "__") {
@@ -854,43 +1061,15 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali · 8 unmodified lines
854 return "", ""1061 return "", ""
855 }1062 }
856 1063
857 tools := gjson.GetBytes(requestRawJSON, "tools")1064 root := gjson.ParseBytes(requestRawJSON)
858 if !tools.Exists() || !tools.IsArray() {1065 descriptor, ok := responsesToolWinners(root)[qualifiedName]
1066 if !ok {
859 return qualifiedName, ""1067 return qualifiedName, ""
860 }1068 }
861 1069 if descriptor.toolType == "function" && !descriptor.direct {
862 var bestNamespace string1070 return descriptor.childName, descriptor.namespace
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, ""
892 }1071 }
893 return bestChild, bestNamespace1072 return qualifiedName, ""
894}1073}
895 1074
896func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {1075func 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
441 }441 }
442}442}
443 443
444func 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
499func 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
529func 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
557func 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
585func 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
608func 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
640func 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
663func 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
712func 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
740func 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
444func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) {754func testClaudeResponsesThinkingSignature(t *testing.T) (string, string) {
445 t.Helper()755 t.Helper()
446 channelBlock := []byte{}756 channelBlock := []byte{}
internal/translator/claude/openai/responses/claude_openai-responses_response.go+103−30
@@ -29,6 +29,7 @@ type claudeToResponsesState struct { · 28 unmodified lines
29 // function call bookkeeping for output aggregation29 // function call bookkeeping for output aggregation
30 FuncNames map[int]string // Claude block index -> function name30 FuncNames map[int]string // Claude block index -> function name
31 FuncCallIDs map[int]string // Claude block index -> call id31 FuncCallIDs map[int]string // Claude block index -> call id
32 FuncCustom map[int]bool // Claude block index -> freeform custom tool
32 FuncOutputIndices map[int]int // Claude block index -> Responses output index33 FuncOutputIndices map[int]int // Claude block index -> Responses output index
33 // message text aggregation34 // message text aggregation
34 TextBuf strings.Builder35 TextBuf strings.Builder
@@ -251,6 +252,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 216 unmodified lines
251 FuncArgsBuf: make(map[int]*strings.Builder),252 FuncArgsBuf: make(map[int]*strings.Builder),
252 FuncNames: make(map[int]string),253 FuncNames: make(map[int]string),
253 FuncCallIDs: make(map[int]string),254 FuncCallIDs: make(map[int]string),
255 FuncCustom: make(map[int]bool),
254 FuncOutputIndices: make(map[int]int),256 FuncOutputIndices: make(map[int]int),
255 }257 }
256 }258 }
@@ -262,6 +264,8 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 5 unmodified lines
262 }264 }
263 rawJSON = bytes.TrimSpace(rawJSON[5:])265 rawJSON = bytes.TrimSpace(rawJSON[5:])
264 root := gjson.ParseBytes(rawJSON)266 root := gjson.ParseBytes(rawJSON)
267 requestForToolMetadata := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
268 customToolNames := responsesCustomToolNames(requestForToolMetadata)
265 ev := root.Get("type").String()269 ev := root.Get("type").String()
266 var out [][]byte270 var out [][]byte
267 271
@@ -294,6 +298,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 26 unmodified lines
294 st.FuncArgsBuf = make(map[int]*strings.Builder)298 st.FuncArgsBuf = make(map[int]*strings.Builder)
295 st.FuncNames = make(map[int]string)299 st.FuncNames = make(map[int]string)
296 st.FuncCallIDs = make(map[int]string)300 st.FuncCallIDs = make(map[int]string)
301 st.FuncCustom = make(map[int]bool)
297 st.FuncOutputIndices = make(map[int]int)302 st.FuncOutputIndices = make(map[int]int)
298 st.Usage = claudeResponsesUsageTokens{}303 st.Usage = claudeResponsesUsageTokens{}
299 st.Usage.Merge(msg.Get("usage"))304 st.Usage.Merge(msg.Get("usage"))
@@ -354,18 +359,31 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 54 unmodified lines
354 st.InFuncBlock = true359 st.InFuncBlock = true
355 st.CurrentFCID = cb.Get("id").String()360 st.CurrentFCID = cb.Get("id").String()
356 name := cb.Get("name").String()361 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
357 outputIndex := st.functionOutputIndex(idx)367 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 }
359 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())380 item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
360 item, _ = sjson.SetBytes(item, "output_index", outputIndex)381 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")
364 out = append(out, emitEvent("response.output_item.added", item))382 out = append(out, emitEvent("response.output_item.added", item))
365 if st.FuncArgsBuf[idx] == nil {383 if st.FuncArgsBuf[idx] == nil {
366 st.FuncArgsBuf[idx] = &strings.Builder{}384 st.FuncArgsBuf[idx] = &strings.Builder{}
367 }385 }
368 // record function metadata for aggregation386 // Record function metadata for aggregation.
369 st.FuncCallIDs[idx] = st.CurrentFCID387 st.FuncCallIDs[idx] = st.CurrentFCID
370 st.FuncNames[idx] = name388 st.FuncNames[idx] = name
371 } else if typ == "thinking" || typ == "redacted_thinking" {389 } else if typ == "thinking" || typ == "redacted_thinking" {
@@ -417,6 +435,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 45 unmodified lines
417 st.FuncArgsBuf[idx] = &strings.Builder{}435 st.FuncArgsBuf[idx] = &strings.Builder{}
418 }436 }
419 st.FuncArgsBuf[idx].WriteString(pj.String())437 st.FuncArgsBuf[idx].WriteString(pj.String())
438 if st.FuncCustom[idx] {
439 return [][]byte{}
440 }
420 outputIndex := st.functionOutputIndex(idx)441 outputIndex := st.functionOutputIndex(idx)
421 msg := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)442 msg := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
422 msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())443 msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
@@ -457,25 +478,47 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 34 unmodified lines
457 } else if st.InFuncBlock {478 } else if st.InFuncBlock {
458 outputIndex := st.functionOutputIndex(idx)479 outputIndex := st.functionOutputIndex(idx)
459 args := "{}"480 args := "{}"
481 if st.FuncCustom[idx] {
482 args = ""
483 }
460 if buf := st.FuncArgsBuf[idx]; buf != nil {484 if buf := st.FuncArgsBuf[idx]; buf != nil {
461 if buf.Len() > 0 {485 if buf.Len() > 0 {
462 args = buf.String()486 args = buf.String()
463 }487 }
464 }488 }
465 fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)489 if st.FuncCustom[idx] {
466 fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())490 input := unwrapCustomToolInput(args)
467 fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID))491 inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
468 fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)492 inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
469 fcDone, _ = sjson.SetBytes(fcDone, "arguments", args)493 inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.CurrentFCID))
470 out = append(out, emitEvent("response.function_call_arguments.done", fcDone))494 inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex)
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":""}}`)495 inputDone, _ = sjson.SetBytes(inputDone, "input", input)
472 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())496 out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone))
473 itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)497
474 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))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":""}}`)
475 itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)499 itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
476 itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.CurrentFCID)500 itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
477 itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, pickRequestJSON(originalRequestRawJSON, requestRawJSON), st.FuncNames[idx], "item")501 itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.CurrentFCID))
478 out = append(out, emitEvent("response.output_item.done", itemDone))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 }
479 st.InFuncBlock = false522 st.InFuncBlock = false
480 } else if st.ReasoningActive {523 } else if st.ReasoningActive {
481 full := st.ReasoningBuf.String()524 full := st.ReasoningBuf.String()
@@ -629,6 +672,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 147 unmodified lines
629 }672 }
630 for _, idx := range idxs {673 for _, idx := range idxs {
631 args := "{}"674 args := "{}"
675 if st.FuncCustom[idx] {
676 args = ""
677 }
632 if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {678 if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {
633 args = b.String()679 args = b.String()
634 }680 }
@@ -637,12 +683,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin · 2 unmodified lines
637 if callID == "" && st.CurrentFCID != "" {683 if callID == "" && st.CurrentFCID != "" {
638 callID = st.CurrentFCID684 callID = st.CurrentFCID
639 }685 }
640 item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)686 if st.FuncCustom[idx] {
641 item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))687 item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
642 item, _ = sjson.SetBytes(item, "arguments", args)688 item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
643 item, _ = sjson.SetBytes(item, "call_id", callID)689 item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args))
644 item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "")690 item, _ = sjson.SetBytes(item, "call_id", callID)
645 outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item)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 }
646 }701 }
647 }702 }
648 if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 {703 if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 {
@@ -694,6 +749,9 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 45 unmodified lines
694 }749 }
695 }750 }
696 751
752 reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
753 customToolNames := responsesCustomToolNames(reqBytes)
754
697 // Base OpenAI Responses (non-stream) object755 // Base OpenAI Responses (non-stream) object
698 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}}`)756 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}}`)
699 757
@@ -770,9 +828,17 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 70 unmodified lines
770 activeMessageItem = item828 activeMessageItem = item
771 case "tool_use":829 case "tool_use":
772 activeMessageItem = nil830 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)
774 item.callID = cb.Get("id").String()836 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 }
776 item.name = cb.Get("name").String()842 item.name = cb.Get("name").String()
777 case "thinking", "redacted_thinking":843 case "thinking", "redacted_thinking":
778 activeMessageItem = nil844 activeMessageItem = nil
@@ -797,7 +863,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 18 unmodified lines
797 }863 }
798 }864 }
799 case "input_json_delta":865 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") {
801 if pj := d.Get("partial_json"); pj.Exists() {867 if pj := d.Get("partial_json"); pj.Exists() {
802 item.args.WriteString(pj.String())868 item.args.WriteString(pj.String())
803 }869 }
@@ -839,7 +905,6 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 35 unmodified lines
839 out, _ = sjson.SetBytes(out, "created_at", createdAt)905 out, _ = sjson.SetBytes(out, "created_at", createdAt)
840 906
841 // Inject request echo fields as top-level (similar to streaming variant)907 // Inject request echo fields as top-level (similar to streaming variant)
842 reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
843 if len(reqBytes) > 0 {908 if len(reqBytes) > 0 {
844 req := gjson.ParseBytes(reqBytes)909 req := gjson.ParseBytes(reqBytes)
845 if v := req.Get("instructions"); v.Exists() {910 if v := req.Get("instructions"); v.Exists() {
@@ -923,7 +988,15 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string · 77 unmodified lines
923 if len(outputItem.annotations) > 0 {988 if len(outputItem.annotations) > 0 {
924 item, _ = sjson.SetBytes(item, "content.0.annotations", outputItem.annotations)989 item, _ = sjson.SetBytes(item, "content.0.annotations", outputItem.annotations)
925 }990 }
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 }
927 args := outputItem.args.String()1000 args := outputItem.args.String()
928 if args == "" {1001 if args == "" {
929 args = "{}"1002 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
877 }877 }
878}878}
879 879
880func 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
941func 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
990func 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
1015func 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
880func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) {1053func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) {
881 originalRequest := []byte(`{1054 originalRequest := []byte(`{
882 "model":"gpt-test",1055 "model":"gpt-test",