feat(translator): add namespace and function call mapping for Claude responses

- Introduced `applyResponsesFunctionCallNamespaceFields` to manage name and namespace settings in response items.
- Added `splitResponsesQualifiedFunctionCallFromRequest` for handling qualified names and matching them with namespaces.
- Updated response generation logic to preserve namespace and function call structure in multiple response pathways.
- Expanded unit tests to validate namespace and function call restoration in both stream and non-stream scenarios.
This commit is contained in:
Luis Pater
2026-06-17 00:21:43 +08:00
parent 30dc2e7f34
commit f49d1798d1
3 changed files with 166 additions and 4 deletions
@@ -622,6 +622,51 @@ func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
return namespaceName + "__" + childName
}
func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, qualifiedName string) (name, namespace string) {
qualifiedName = strings.TrimSpace(qualifiedName)
if qualifiedName == "" {
return "", ""
}
tools := gjson.GetBytes(requestRawJSON, "tools")
if !tools.Exists() || !tools.IsArray() {
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, ""
}
return bestChild, bestNamespace
}
func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
switch toolType {
case "image_generation", "file_search", "code_interpreter", "computer_use_preview":
@@ -89,6 +89,23 @@ func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
return nil
}
func applyResponsesFunctionCallNamespaceFields(item []byte, requestRawJSON []byte, qualifiedName string, itemPath string) []byte {
name, namespace := splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON, qualifiedName)
namePath := "name"
namespacePath := "namespace"
if itemPath != "" {
namePath = itemPath + ".name"
namespacePath = itemPath + ".namespace"
}
item, _ = sjson.SetBytes(item, namePath, name)
if namespace != "" {
item, _ = sjson.SetBytes(item, namespacePath, namespace)
} else {
item, _ = sjson.DeleteBytes(item, namespacePath)
}
return item
}
func emitEvent(event string, payload []byte) []byte {
return translatorcommon.SSEEventData(event, payload)
}
@@ -236,7 +253,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
item, _ = sjson.SetBytes(item, "output_index", idx)
item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID))
item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID)
item, _ = sjson.SetBytes(item, "item.name", name)
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{}
@@ -350,7 +367,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
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, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[idx])
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, pickRequestJSON(originalRequestRawJSON, requestRawJSON), st.FuncNames[idx], "item")
out = append(out, emitEvent("response.output_item.done", itemDone))
st.InFuncBlock = false
} else if st.ReasoningActive {
@@ -512,7 +529,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
item, _ = sjson.SetBytes(item, "arguments", args)
item, _ = sjson.SetBytes(item, "call_id", callID)
item, _ = sjson.SetBytes(item, "name", name)
item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "")
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
}
}
@@ -794,7 +811,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", st.id))
item, _ = sjson.SetBytes(item, "arguments", args)
item, _ = sjson.SetBytes(item, "call_id", st.id)
item, _ = sjson.SetBytes(item, "name", st.name)
item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, st.name, "")
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
}
}
@@ -246,3 +246,103 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ReportsCacheTokens(t *t
t.Fatalf("non-stream usage total_tokens = %d, want %d", got, 22048)
}
}
func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) {
originalRequest := []byte(`{
"model":"gpt-test",
"tools":[
{
"type":"namespace",
"name":"mcp__node_repl",
"tools":[{"type":"function","name":"js","parameters":{"type":"object","properties":{}}}]
}
]
}`)
chunks := [][]byte{
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","usage":{"input_tokens":1,"output_tokens":0}}}`),
[]byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_abc","name":"mcp__node_repl__js","input":{}}}`),
[]byte(`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{"code":"nodeRepl.write('hello')"}"}}`),
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"message_stop"}`),
}
var param any
var added gjson.Result
var done gjson.Result
var completed gjson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, &param) {
event, data := parseClaudeResponsesSSEEvent(t, output)
switch event {
case "response.output_item.added":
if data.Get("item.type").String() == "function_call" {
added = data
}
case "response.output_item.done":
if data.Get("item.type").String() == "function_call" {
done = data
}
case "response.completed":
completed = data
}
}
}
for _, tc := range []struct {
label string
got gjson.Result
}{
{"added", added},
{"done", done},
} {
if !tc.got.Exists() {
t.Fatalf("expected function_call %s event", tc.label)
}
if got := tc.got.Get("item.name").String(); got != "js" {
t.Fatalf("%s item.name = %q, want js", tc.label, got)
}
if got := tc.got.Get("item.namespace").String(); got != "mcp__node_repl" {
t.Fatalf("%s item.namespace = %q, want mcp__node_repl", tc.label, got)
}
}
if !completed.Exists() {
t.Fatal("expected response.completed event")
}
if got := completed.Get("response.output.0.name").String(); got != "js" {
t.Fatalf("completed output name = %q, want js", got)
}
if got := completed.Get("response.output.0.namespace").String(); got != "mcp__node_repl" {
t.Fatalf("completed output namespace = %q, want mcp__node_repl", got)
}
}
func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresNamespaceFunctionCall(t *testing.T) {
originalRequest := []byte(`{
"model":"gpt-test",
"tools":[
{
"type":"namespace",
"name":"mcp__node_repl",
"tools":[{"type":"function","name":"js","parameters":{"type":"object","properties":{}}}]
}
]
}`)
raw := []byte(strings.Join([]string{
`data: {"type":"message_start","message":{"id":"msg_nonstream","usage":{"input_tokens":1,"output_tokens":0}}}`,
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_abc","name":"mcp__node_repl__js","input":{}}}`,
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"code\":\"nodeRepl.write('hello')\"}"}}`,
`data: {"type":"content_block_stop","index":1}`,
`data: {"type":"message_stop"}`,
}, "\n"))
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil)
root := gjson.ParseBytes(out)
if got := root.Get("output.0.name").String(); got != "js" {
t.Fatalf("non-stream output name = %q, want js", got)
}
if got := root.Get("output.0.namespace").String(); got != "mcp__node_repl" {
t.Fatalf("non-stream output namespace = %q, want mcp__node_repl", got)
}
}