diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index d37b7156..61f5c1a0 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -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": diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index 97256687..c27cb4b3 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -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) } } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go index addf4de1..9db2e058 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go @@ -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, ¶m) { + 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) + } +}