From 4926630a60466dc6a116d2a96aa3817e6ce57aca Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 19 Jun 2026 04:12:51 +0800 Subject: [PATCH] feat(translator): support namespace tools in OpenAI response transformations - Added `convertResponsesToolToOpenAIChatTools` and helper methods to handle namespace tools during request conversions. - Enhanced response handling to restore namespace context for function calls using `applyResponsesFunctionCallNamespaceFields` and related utilities. - Updated tests to validate namespace flattening, function call restoration, and non-stream response handling. Closes: #3298 --- .../openai_openai-responses_request.go | 30 +-- .../openai_openai-responses_request_test.go | 51 +++++ .../openai_openai-responses_response.go | 12 +- .../openai_openai-responses_response_test.go | 91 +++++++++ .../openai_openai-responses_tools.go | 177 ++++++++++++++++++ 5 files changed, 328 insertions(+), 33 deletions(-) create mode 100644 internal/translator/openai/openai/responses/openai_openai-responses_tools.go diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 15acf7cd..1cd77c4d 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -230,35 +230,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu var chatCompletionsTools []interface{} tools.ForEach(func(_, tool gjson.Result) bool { - // Built-in tools (e.g. {"type":"web_search"}) are already compatible with the Chat Completions schema. - // Only function tools need structural conversion because Chat Completions nests details under "function". - toolType := tool.Get("type").String() - if toolType != "" && toolType != "function" && tool.IsObject() { - // Almost all providers lack built-in tools, so we just ignore them. - // chatCompletionsTools = append(chatCompletionsTools, tool.Value()) - return true + for _, chatTool := range convertResponsesToolToOpenAIChatTools(tool) { + chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value()) } - - chatTool := []byte(`{"type":"function","function":{}}`) - - // Convert tool structure from responses format to chat completions format - function := []byte(`{"name":"","description":"","parameters":{}}`) - - if name := tool.Get("name"); name.Exists() { - function, _ = sjson.SetBytes(function, "name", name.String()) - } - - if description := tool.Get("description"); description.Exists() { - function, _ = sjson.SetBytes(function, "description", description.String()) - } - - if parameters := tool.Get("parameters"); parameters.Exists() { - function, _ = sjson.SetRawBytes(function, "parameters", []byte(parameters.Raw)) - } - - chatTool, _ = sjson.SetRawBytes(chatTool, "function", function) - chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value()) - return true }) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go index 9dd0e288..c35f0e2b 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -122,3 +122,54 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntil t.Fatalf("messages.3.content = %q, want %q", got, "next") } } + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FlattensNamespaceTools(t *testing.T) { + raw := []byte(`{ + "input": [ + {"role":"user","content":"Use add_numbers."} + ], + "tools": [ + { + "type": "namespace", + "name": "mcp__test_mcp__", + "description": "Tools in the mcp__test_mcp__ namespace.", + "tools": [ + { + "type": "function", + "name": "add_numbers", + "description": "Add two numbers", + "parameters": { + "type": "object", + "properties": { + "a": { "type": "number" }, + "b": { "type": "number" } + }, + "required": ["a", "b"] + } + } + ] + } + ], + "tool_choice": "auto" + }`) + t.Logf("input json:\n%s", prettyJSONForTest(raw)) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 { + t.Fatalf("tools count = %d, want 1; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" { + t.Fatalf("tools.0.type = %q, want function; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.function.name").String(); got != "mcp__test_mcp__add_numbers" { + t.Fatalf("tools.0.function.name = %q, want mcp__test_mcp__add_numbers; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.function.description").String(); got != "Add two numbers" { + t.Fatalf("tools.0.function.description = %q, want Add two numbers; output=%s", got, out) + } + if got := gjson.GetBytes(out, "tools.0.function.parameters.required.0").String(); got != "a" { + t.Fatalf("tools.0.function.parameters.required.0 = %q, want a; output=%s", got, out) + } +} diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index b15feb77..d471683a 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -170,7 +170,7 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte 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, requestRawJSON, name, "") outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item}) } } @@ -226,10 +226,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, if len(rawJSON) == 0 { return [][]byte{} } + requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON) if bytes.Equal(rawJSON, []byte("[DONE]")) { if st.CompletionPending && !st.CompletedEmitted { st.CompletedEmitted = true - return [][]byte{buildResponsesCompletedEvent(st, requestRawJSON, func() int { st.Seq++; return st.Seq })} + return [][]byte{buildResponsesCompletedEvent(st, requestForNamespace, func() int { st.Seq++; return st.Seq })} } return [][]byte{} } @@ -488,7 +489,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, o, _ = sjson.SetBytes(o, "output_index", outputIndex) o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID)) o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID) - o, _ = sjson.SetBytes(o, "item.name", st.FuncNames[key]) + o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, st.FuncNames[key], "item") out = append(out, emitRespEvent("response.output_item.added", o)) } @@ -602,7 +603,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID)) itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args) itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID) - itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[key]) + itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForNamespace, st.FuncNames[key], "item") out = append(out, emitRespEvent("response.output_item.done", itemDone)) st.FuncItemDone[key] = true st.FuncArgsDone[key] = true @@ -622,6 +623,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, // from a non-streaming OpenAI Chat Completions response. func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { root := gjson.ParseBytes(rawJSON) + requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON) // Basic response scaffold resp := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`) @@ -760,7 +762,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co 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, requestForNamespace, name, "") outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) return true }) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go index 4951d91d..636c599e 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -502,3 +502,94 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_OmitsTop t.Fatalf("output text = %q, want %q; response=%s", got, "ping", resp) } } + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"deepseek-v4-flash", + "tools":[ + { + "type":"namespace", + "name":"mcp__test_mcp__", + "tools":[{"type":"function","name":"add_numbers","parameters":{"type":"object","properties":{}}}] + } + ] + }`) + chunks := []string{ + `data: {"id":"chatcmpl_namespace_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_ns","type":"function","function":{"name":"mcp__test_mcp__add_numbers","arguments":""}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl_namespace_stream","object":"chat.completion.chunk","created":1773896263,"model":"model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":3,\"b\":5}"}}]},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`, + } + + var param any + var added gjson.Result + var done gjson.Result + var completed gjson.Result + for _, line := range chunks { + for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) { + event, data := parseOpenAIResponsesSSEEvent(t, chunk) + 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 != "add_numbers" { + t.Fatalf("%s item.name = %q, want add_numbers", tc.label, got) + } + if got := tc.got.Get("item.namespace").String(); got != "mcp__test_mcp__" { + t.Fatalf("%s item.namespace = %q, want mcp__test_mcp__", tc.label, got) + } + } + if !completed.Exists() { + t.Fatal("expected response.completed event") + } + if got := completed.Get("response.output.0.name").String(); got != "add_numbers" { + t.Fatalf("completed output name = %q, want add_numbers", got) + } + if got := completed.Get("response.output.0.namespace").String(); got != "mcp__test_mcp__" { + t.Fatalf("completed output namespace = %q, want mcp__test_mcp__", got) + } +} + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_RestoresNamespaceFunctionCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"deepseek-v4-flash", + "tools":[ + { + "type":"namespace", + "name":"mcp__test_mcp__", + "tools":[{"type":"function","name":"add_numbers","parameters":{"type":"object","properties":{}}}] + } + ] + }`) + raw := []byte(`{"id":"chatcmpl_namespace_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_ns","type":"function","function":{"name":"mcp__test_mcp__add_numbers","arguments":"{\"a\":3,\"b\":5}"}}]},"finish_reason":"tool_calls"}]}`) + + resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil) + data := gjson.ParseBytes(resp) + + if got := data.Get("output.0.name").String(); got != "add_numbers" { + t.Fatalf("non-stream output name = %q, want add_numbers; response=%s", got, resp) + } + if got := data.Get("output.0.namespace").String(); got != "mcp__test_mcp__" { + t.Fatalf("non-stream output namespace = %q, want mcp__test_mcp__; response=%s", got, resp) + } +} diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go new file mode 100644 index 00000000..a382b4a2 --- /dev/null +++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go @@ -0,0 +1,177 @@ +package responses + +import ( + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func convertResponsesToolToOpenAIChatTools(tool gjson.Result) [][]byte { + toolType := strings.TrimSpace(tool.Get("type").String()) + switch toolType { + case "", "function": + if tJSON, ok := convertResponsesFunctionToolToOpenAIChat(tool, ""); ok { + return [][]byte{tJSON} + } + case "namespace": + return convertResponsesNamespaceToolToOpenAIChat(tool) + default: + return nil + } + return nil +} + +func convertResponsesNamespaceToolToOpenAIChat(tool gjson.Result) [][]byte { + namespaceName := strings.TrimSpace(tool.Get("name").String()) + children := tool.Get("tools") + if !children.Exists() || !children.IsArray() { + return nil + } + + var out [][]byte + children.ForEach(func(_, child gjson.Result) bool { + childName := responsesToolName(child) + qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName) + if tJSON, ok := convertResponsesFunctionToolToOpenAIChat(child, qualifiedName); ok { + out = append(out, tJSON) + } + return true + }) + return out +} + +func convertResponsesFunctionToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) { + name := strings.TrimSpace(overrideName) + if name == "" { + name = responsesToolName(tool) + } + if name == "" { + return nil, false + } + + chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{}}}`) + chatTool, _ = sjson.SetBytes(chatTool, "function.name", name) + if description := responsesToolDescription(tool); description != "" { + chatTool, _ = sjson.SetBytes(chatTool, "function.description", description) + } + if parameters := responsesToolParameters(tool); parameters.Exists() { + chatTool, _ = sjson.SetRawBytes(chatTool, "function.parameters", []byte(parameters.Raw)) + } + return chatTool, true +} + +func responsesToolName(tool gjson.Result) string { + if name := strings.TrimSpace(tool.Get("name").String()); name != "" { + return name + } + return strings.TrimSpace(tool.Get("function.name").String()) +} + +func responsesToolDescription(tool gjson.Result) string { + if description := tool.Get("description").String(); description != "" { + return description + } + return tool.Get("function.description").String() +} + +func responsesToolParameters(tool gjson.Result) gjson.Result { + for _, path := range []string{ + "parameters", + "parametersJsonSchema", + "input_schema", + "function.parameters", + "function.parametersJsonSchema", + } { + if parameters := tool.Get(path); parameters.Exists() { + return parameters + } + } + return gjson.Result{} +} + +func qualifyResponsesNamespaceToolName(namespaceName, childName string) string { + childName = strings.TrimSpace(childName) + if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") { + return childName + } + if strings.HasPrefix(childName, namespaceName) { + return childName + } + if strings.HasSuffix(namespaceName, "__") { + return namespaceName + childName + } + 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 pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte { + if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) { + return originalRequestRawJSON + } + if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) { + return requestRawJSON + } + 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 +}