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