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
This commit is contained in:
Luis Pater
2026-06-19 04:12:51 +08:00
parent 893412e965
commit 4926630a60
5 changed files with 328 additions and 33 deletions
@@ -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
})
@@ -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)
}
}
@@ -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
})
@@ -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), &param) {
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)
}
}
@@ -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
}