Skip to content

Commit 4926630

Browse files
committed
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: router-for-me#3298
1 parent 893412e commit 4926630

5 files changed

Lines changed: 328 additions & 33 deletions

File tree

internal/translator/openai/openai/responses/openai_openai-responses_request.go

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -230,35 +230,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
230230
var chatCompletionsTools []interface{}
231231

232232
tools.ForEach(func(_, tool gjson.Result) bool {
233-
// Built-in tools (e.g. {"type":"web_search"}) are already compatible with the Chat Completions schema.
234-
// Only function tools need structural conversion because Chat Completions nests details under "function".
235-
toolType := tool.Get("type").String()
236-
if toolType != "" && toolType != "function" && tool.IsObject() {
237-
// Almost all providers lack built-in tools, so we just ignore them.
238-
// chatCompletionsTools = append(chatCompletionsTools, tool.Value())
239-
return true
233+
for _, chatTool := range convertResponsesToolToOpenAIChatTools(tool) {
234+
chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value())
240235
}
241-
242-
chatTool := []byte(`{"type":"function","function":{}}`)
243-
244-
// Convert tool structure from responses format to chat completions format
245-
function := []byte(`{"name":"","description":"","parameters":{}}`)
246-
247-
if name := tool.Get("name"); name.Exists() {
248-
function, _ = sjson.SetBytes(function, "name", name.String())
249-
}
250-
251-
if description := tool.Get("description"); description.Exists() {
252-
function, _ = sjson.SetBytes(function, "description", description.String())
253-
}
254-
255-
if parameters := tool.Get("parameters"); parameters.Exists() {
256-
function, _ = sjson.SetRawBytes(function, "parameters", []byte(parameters.Raw))
257-
}
258-
259-
chatTool, _ = sjson.SetRawBytes(chatTool, "function", function)
260-
chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value())
261-
262236
return true
263237
})
264238

internal/translator/openai/openai/responses/openai_openai-responses_request_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,54 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntil
122122
t.Fatalf("messages.3.content = %q, want %q", got, "next")
123123
}
124124
}
125+
126+
func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FlattensNamespaceTools(t *testing.T) {
127+
raw := []byte(`{
128+
"input": [
129+
{"role":"user","content":"Use add_numbers."}
130+
],
131+
"tools": [
132+
{
133+
"type": "namespace",
134+
"name": "mcp__test_mcp__",
135+
"description": "Tools in the mcp__test_mcp__ namespace.",
136+
"tools": [
137+
{
138+
"type": "function",
139+
"name": "add_numbers",
140+
"description": "Add two numbers",
141+
"parameters": {
142+
"type": "object",
143+
"properties": {
144+
"a": { "type": "number" },
145+
"b": { "type": "number" }
146+
},
147+
"required": ["a", "b"]
148+
}
149+
}
150+
]
151+
}
152+
],
153+
"tool_choice": "auto"
154+
}`)
155+
t.Logf("input json:\n%s", prettyJSONForTest(raw))
156+
157+
out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
158+
t.Logf("output json:\n%s", prettyJSONForTest(out))
159+
160+
if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
161+
t.Fatalf("tools count = %d, want 1; output=%s", got, out)
162+
}
163+
if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
164+
t.Fatalf("tools.0.type = %q, want function; output=%s", got, out)
165+
}
166+
if got := gjson.GetBytes(out, "tools.0.function.name").String(); got != "mcp__test_mcp__add_numbers" {
167+
t.Fatalf("tools.0.function.name = %q, want mcp__test_mcp__add_numbers; output=%s", got, out)
168+
}
169+
if got := gjson.GetBytes(out, "tools.0.function.description").String(); got != "Add two numbers" {
170+
t.Fatalf("tools.0.function.description = %q, want Add two numbers; output=%s", got, out)
171+
}
172+
if got := gjson.GetBytes(out, "tools.0.function.parameters.required.0").String(); got != "a" {
173+
t.Fatalf("tools.0.function.parameters.required.0 = %q, want a; output=%s", got, out)
174+
}
175+
}

internal/translator/openai/openai/responses/openai_openai-responses_response.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte
170170
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
171171
item, _ = sjson.SetBytes(item, "arguments", args)
172172
item, _ = sjson.SetBytes(item, "call_id", callID)
173-
item, _ = sjson.SetBytes(item, "name", name)
173+
item = applyResponsesFunctionCallNamespaceFields(item, requestRawJSON, name, "")
174174
outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
175175
}
176176
}
@@ -226,10 +226,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
226226
if len(rawJSON) == 0 {
227227
return [][]byte{}
228228
}
229+
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
229230
if bytes.Equal(rawJSON, []byte("[DONE]")) {
230231
if st.CompletionPending && !st.CompletedEmitted {
231232
st.CompletedEmitted = true
232-
return [][]byte{buildResponsesCompletedEvent(st, requestRawJSON, func() int { st.Seq++; return st.Seq })}
233+
return [][]byte{buildResponsesCompletedEvent(st, requestForNamespace, func() int { st.Seq++; return st.Seq })}
233234
}
234235
return [][]byte{}
235236
}
@@ -488,7 +489,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
488489
o, _ = sjson.SetBytes(o, "output_index", outputIndex)
489490
o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", effectiveCallID))
490491
o, _ = sjson.SetBytes(o, "item.call_id", effectiveCallID)
491-
o, _ = sjson.SetBytes(o, "item.name", st.FuncNames[key])
492+
o = applyResponsesFunctionCallNamespaceFields(o, requestForNamespace, st.FuncNames[key], "item")
492493
out = append(out, emitRespEvent("response.output_item.added", o))
493494
}
494495

@@ -602,7 +603,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
602603
itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
603604
itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args)
604605
itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
605-
itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[key])
606+
itemDone = applyResponsesFunctionCallNamespaceFields(itemDone, requestForNamespace, st.FuncNames[key], "item")
606607
out = append(out, emitRespEvent("response.output_item.done", itemDone))
607608
st.FuncItemDone[key] = true
608609
st.FuncArgsDone[key] = true
@@ -622,6 +623,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
622623
// from a non-streaming OpenAI Chat Completions response.
623624
func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
624625
root := gjson.ParseBytes(rawJSON)
626+
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
625627

626628
// Basic response scaffold
627629
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
760762
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
761763
item, _ = sjson.SetBytes(item, "arguments", args)
762764
item, _ = sjson.SetBytes(item, "call_id", callID)
763-
item, _ = sjson.SetBytes(item, "name", name)
765+
item = applyResponsesFunctionCallNamespaceFields(item, requestForNamespace, name, "")
764766
outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item)
765767
return true
766768
})

internal/translator/openai/openai/responses/openai_openai-responses_response_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,94 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_OmitsTop
502502
t.Fatalf("output text = %q, want %q; response=%s", got, "ping", resp)
503503
}
504504
}
505+
506+
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t *testing.T) {
507+
originalRequest := []byte(`{
508+
"model":"deepseek-v4-flash",
509+
"tools":[
510+
{
511+
"type":"namespace",
512+
"name":"mcp__test_mcp__",
513+
"tools":[{"type":"function","name":"add_numbers","parameters":{"type":"object","properties":{}}}]
514+
}
515+
]
516+
}`)
517+
chunks := []string{
518+
`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}]}`,
519+
`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"}]}`,
520+
`data: [DONE]`,
521+
}
522+
523+
var param any
524+
var added gjson.Result
525+
var done gjson.Result
526+
var completed gjson.Result
527+
for _, line := range chunks {
528+
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), &param) {
529+
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
530+
switch event {
531+
case "response.output_item.added":
532+
if data.Get("item.type").String() == "function_call" {
533+
added = data
534+
}
535+
case "response.output_item.done":
536+
if data.Get("item.type").String() == "function_call" {
537+
done = data
538+
}
539+
case "response.completed":
540+
completed = data
541+
}
542+
}
543+
}
544+
545+
for _, tc := range []struct {
546+
label string
547+
got gjson.Result
548+
}{
549+
{"added", added},
550+
{"done", done},
551+
} {
552+
if !tc.got.Exists() {
553+
t.Fatalf("expected function_call %s event", tc.label)
554+
}
555+
if got := tc.got.Get("item.name").String(); got != "add_numbers" {
556+
t.Fatalf("%s item.name = %q, want add_numbers", tc.label, got)
557+
}
558+
if got := tc.got.Get("item.namespace").String(); got != "mcp__test_mcp__" {
559+
t.Fatalf("%s item.namespace = %q, want mcp__test_mcp__", tc.label, got)
560+
}
561+
}
562+
if !completed.Exists() {
563+
t.Fatal("expected response.completed event")
564+
}
565+
if got := completed.Get("response.output.0.name").String(); got != "add_numbers" {
566+
t.Fatalf("completed output name = %q, want add_numbers", got)
567+
}
568+
if got := completed.Get("response.output.0.namespace").String(); got != "mcp__test_mcp__" {
569+
t.Fatalf("completed output namespace = %q, want mcp__test_mcp__", got)
570+
}
571+
}
572+
573+
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_RestoresNamespaceFunctionCall(t *testing.T) {
574+
originalRequest := []byte(`{
575+
"model":"deepseek-v4-flash",
576+
"tools":[
577+
{
578+
"type":"namespace",
579+
"name":"mcp__test_mcp__",
580+
"tools":[{"type":"function","name":"add_numbers","parameters":{"type":"object","properties":{}}}]
581+
}
582+
]
583+
}`)
584+
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"}]}`)
585+
586+
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil)
587+
data := gjson.ParseBytes(resp)
588+
589+
if got := data.Get("output.0.name").String(); got != "add_numbers" {
590+
t.Fatalf("non-stream output name = %q, want add_numbers; response=%s", got, resp)
591+
}
592+
if got := data.Get("output.0.namespace").String(); got != "mcp__test_mcp__" {
593+
t.Fatalf("non-stream output namespace = %q, want mcp__test_mcp__; response=%s", got, resp)
594+
}
595+
}

0 commit comments

Comments
 (0)