From f6b7ce1bd61d888f00881f32dc4d094d1bfbeb44 Mon Sep 17 00:00:00 2001 From: Cristi An Date: Wed, 4 Nov 2026 09:46:48 +0200 Subject: [PATCH] feat(openresponses): accept namespace tools Codex and other Responses clients send each MCP server's tools as one {"type": "namespace", "name": ..., "tools": [...]} entry and route a returned call by its (namespace, name) pair. convertORToolsToOpenAIFormat kept only function tools, so the model never saw an MCP tool. Flatten each namespace member to ns_{namespace}___{name}, the form llama.cpp's server uses, for the model and the grammar. Earlier function_call items that carry a namespace are flattened the same way, both in the input and when a stored response is replayed. Returned function_call items are split back into namespace and name where outputs leave: buildORResponse and normalizeORStreamEvent, which covers streaming, background, WebSocket and stored responses. The name decodes by itself, so no per-request state is needed; a namespace must not contain "___". Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Cristi An --- .../http/endpoints/openresponses/responses.go | 81 +++++++++++++++++-- .../openresponses/responses_convert_test.go | 50 ++++++++++++ .../openresponses/responses_stream_test.go | 54 +++++++++++++ core/schema/openresponses.go | 4 + docs/content/features/text-generation.md | 2 +- swagger/docs.go | 11 +++ swagger/swagger.json | 11 +++ swagger/swagger.yaml | 10 +++ 8 files changed, 216 insertions(+), 7 deletions(-) diff --git a/core/http/endpoints/openresponses/responses.go b/core/http/endpoints/openresponses/responses.go index 88b0c8c28538..50c14e6c3b8f 100644 --- a/core/http/endpoints/openresponses/responses.go +++ b/core/http/endpoints/openresponses/responses.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -407,6 +408,8 @@ func convertORReasoningItemToMessage(itemMap map[string]any) (schema.Message, er func convertORFunctionCallItemToMessage(itemMap map[string]any) (schema.Message, error) { callID, _ := itemMap["call_id"].(string) name, _ := itemMap["name"].(string) + namespace, _ := itemMap["namespace"].(string) + name = responsesToolName(namespace, name) arguments, _ := itemMap["arguments"].(string) if callID == "" { callID = fmt.Sprintf("call_%s", name) @@ -480,7 +483,7 @@ func convertORItemToMessage(item *schema.ORItemField, responseID string) (schema Index: 0, ID: callID, Type: "function", - FunctionCall: schema.FunctionCall{Name: item.Name, Arguments: item.Arguments}, + FunctionCall: schema.FunctionCall{Name: responsesToolName(item.Namespace, item.Name), Arguments: item.Arguments}, }}, }, nil default: @@ -633,7 +636,7 @@ func convertOROutputItemsToMessages(outputItems []schema.ORItemField) ([]schema. Index: 0, ID: item.CallID, Type: "function", - FunctionCall: schema.FunctionCall{Name: item.Name, Arguments: item.Arguments}, + FunctionCall: schema.FunctionCall{Name: responsesToolName(item.Namespace, item.Name), Arguments: item.Arguments}, }}, } if msg.ToolCalls[0].ID == "" { @@ -845,7 +848,7 @@ func convertORToolsToFunctions(input *schema.OpenResponsesRequest, cfg *config.M } var funcs functions.Functions - for _, tool := range input.Tools { + for _, tool := range flattenNamespaceTools(input.Tools) { if tool.Type == "function" { // Skip if not in allowed list (when allowed_tools is specified) if len(allowedSet) > 0 && !allowedSet[tool.Name] { @@ -2791,11 +2794,13 @@ func sendSSEEvent(c echo.Context, event *schema.ORStreamEvent) { fmt.Fprintf(c.Response().Writer, "event: %s\ndata: %s\n\n", event.Type, string(data)) } -// normalizeORStreamEvent ensures required fields like Summary are never null. +// normalizeORStreamEvent ensures required fields like Summary are never null +// and gives a call to a namespace tool its namespace back. func normalizeORStreamEvent(event *schema.ORStreamEvent) { if event.Item != nil && event.Item.Summary == nil { event.Item.Summary = []schema.ORContentPart{} } + restoreToolNamespace(event.Item) } // getTopLogprobs returns the top_logprobs value, defaulting to 0 if nil @@ -2883,6 +2888,7 @@ func buildORResponse(responseID string, createdAt int64, completedAt *int64, sta if outputItems[i].Summary == nil { outputItems[i].Summary = []schema.ORContentPart{} } + restoreToolNamespace(&outputItems[i]) } // Ensure tools is never null - always an array @@ -3042,12 +3048,75 @@ func sendOpenResponsesError(c echo.Context, statusCode int, errorType, message, return c.JSON(statusCode, errorResp) } +// Clients such as Codex group tools in "namespace" entries (one per MCP +// server) and route a returned call by its (namespace, name) pair. The model +// only sees functions, so namespace members are flattened to +// "ns_{namespace}___{name}", the form llama.cpp's server uses, and split back +// on the calls returned. A namespace must not contain "___". +const ( + responsesNamespacePrefix = "ns_" + responsesNamespaceSeparator = "___" +) + +// responsesToolName flattens a namespaced tool name. It returns name unchanged +// when namespace is empty. +func responsesToolName(namespace, name string) string { + if namespace == "" { + return name + } + return responsesNamespacePrefix + namespace + responsesNamespaceSeparator + name +} + +// splitResponsesToolName splits a flattened tool name into (namespace, name). +// Any other name comes back with an empty namespace. +func splitResponsesToolName(name string) (string, string) { + rest, ok := strings.CutPrefix(name, responsesNamespacePrefix) + if !ok { + return "", name + } + namespace, tool, ok := strings.Cut(rest, responsesNamespaceSeparator) + if !ok || namespace == "" || tool == "" { + return "", name + } + return namespace, tool +} + +// flattenNamespaceTools replaces each namespace entry with its function +// members under their flattened names. +func flattenNamespaceTools(tools []schema.ORFunctionTool) []schema.ORFunctionTool { + result := make([]schema.ORFunctionTool, 0, len(tools)) + for _, t := range tools { + if t.Type != "namespace" { + result = append(result, t) + continue + } + for _, member := range t.Tools { + if t.Name == "" || member.Type != "function" || member.Name == "" { + continue + } + member.Name = responsesToolName(t.Name, member.Name) + result = append(result, member) + } + } + return result +} + +// restoreToolNamespace splits a flattened name on a function_call item back +// into its namespace and name. +func restoreToolNamespace(item *schema.ORItemField) { + if item == nil || item.Type != "function_call" || item.Namespace != "" { + return + } + item.Namespace, item.Name = splitResponsesToolName(item.Name) +} + // convertORToolsToOpenAIFormat converts only tools that have an equivalent in // the OpenAI-compatible function-tool representation. Native Responses tools -// such as web_search and namespace must not be rewritten as functions. +// such as web_search must not be rewritten as functions; namespace entries +// contribute their function members under flattened names. func convertORToolsToOpenAIFormat(orTools []schema.ORFunctionTool) []functions.Tool { result := make([]functions.Tool, 0, len(orTools)) - for _, t := range orTools { + for _, t := range flattenNamespaceTools(orTools) { if t.Type != "function" { continue } diff --git a/core/http/endpoints/openresponses/responses_convert_test.go b/core/http/endpoints/openresponses/responses_convert_test.go index 5326f008deb0..cb5a9450abcf 100644 --- a/core/http/endpoints/openresponses/responses_convert_test.go +++ b/core/http/endpoints/openresponses/responses_convert_test.go @@ -110,6 +110,16 @@ var _ = Describe("convertORInputToMessages", func() { Expect(msgs[i].StringContent).To(Equal(expected.text)) } }) + + It("replays a namespaced function_call under the name the model was offered", func() { + msgs := convertRequest(outputItems(schema.ORItemField{ + Type: "function_call", CallID: "call_1", Namespace: "mcp__docs", Name: "search", Arguments: `{"q":"x"}`, + })) + Expect(msgs).To(HaveLen(1)) + Expect(msgs[0].ToolCalls).To(HaveLen(1)) + Expect(msgs[0].ToolCalls[0].FunctionCall.Name).To(Equal("ns_mcp__docs___search")) + Expect(msgs[0].ToolCalls[0].FunctionCall.Arguments).To(Equal(`{"q":"x"}`)) + }) }) It("does not treat a non-message item (no content key) as a message", func() { @@ -137,6 +147,46 @@ var _ = Describe("convertORToolsToOpenAIFormat", func() { Expect(converted[0].Function.Name).To(Equal("example_function")) Expect(converted[0].Function.Parameters).To(Equal(map[string]any{"type": "object"})) }) + + It("flattens namespace members to ns_{namespace}___{name}", func() { + var tools []schema.ORFunctionTool + Expect(json.Unmarshal([]byte(`[ + {"type": "function", "name": "exec_command"}, + {"type": "namespace", "name": "mcp__docs", "tools": [ + {"type": "function", "name": "search", "description": "Search the docs", "parameters": {"type": "object"}}, + {"type": "function", "name": "fetch"} + ]} + ]`), &tools)).To(Succeed()) + + converted := convertORToolsToOpenAIFormat(tools) + + Expect(converted).To(HaveLen(3)) + Expect(converted[0].Function.Name).To(Equal("exec_command")) + Expect(converted[1].Function.Name).To(Equal("ns_mcp__docs___search")) + Expect(converted[1].Function.Description).To(Equal("Search the docs")) + Expect(converted[1].Function.Parameters).To(Equal(map[string]any{"type": "object"})) + Expect(converted[2].Function.Name).To(Equal("ns_mcp__docs___fetch")) + }) +}) + +var _ = Describe("splitResponsesToolName", func() { + DescribeTable("splits only flattened namespace names", func(flat, wantNamespace, wantName string) { + namespace, name := splitResponsesToolName(flat) + Expect(namespace).To(Equal(wantNamespace)) + Expect(name).To(Equal(wantName)) + }, + Entry("flattened name", "ns_mcp__docs___search", "mcp__docs", "search"), + Entry("plain function", "exec_command", "", "exec_command"), + Entry("prefix without separator", "ns_docs", "", "ns_docs"), + Entry("empty member name", "ns_docs___", "", "ns_docs___"), + ) + + It("round-trips responsesToolName", func() { + namespace, name := splitResponsesToolName(responsesToolName("mcp__docs", "search")) + Expect(namespace).To(Equal("mcp__docs")) + Expect(name).To(Equal("search")) + Expect(responsesToolName("", "search")).To(Equal("search")) + }) }) var _ = Describe("resolvePreviousResponseMessages", func() { diff --git a/core/http/endpoints/openresponses/responses_stream_test.go b/core/http/endpoints/openresponses/responses_stream_test.go index 13ddb10e15e3..ff1ecd093535 100644 --- a/core/http/endpoints/openresponses/responses_stream_test.go +++ b/core/http/endpoints/openresponses/responses_stream_test.go @@ -146,3 +146,57 @@ var _ = Describe("Responses stream item consistency", func() { Entry("reasoning and automatic fallback tool call", []string{"", "Let me think.", "", `{"name":"get_weather","arguments":{"city":"Rome"}}`}, nil, "Let me think.", "", true), ) }) + +var _ = Describe("Responses stream namespace tools", func() { + It("returns a call to a namespace member with its namespace", func() { + originalInference := backend.ModelInferenceFunc + DeferCleanup(func() { backend.ModelInferenceFunc = originalInference }) + call := `{"name":"ns_mcp__docs___search","arguments":{"q":"x"}}` + backend.ModelInferenceFunc = func( + ctx context.Context, prompt string, messages schema.Messages, + images, videos, audios []string, loader *model.ModelLoader, + cfg *config.ModelConfig, cl *config.ModelConfigLoader, app *config.ApplicationConfig, + tokenCallback func(string, backend.TokenUsage) bool, tools, toolChoice string, + logprobs, topLogprobs *int, logitBias map[string]float64, metadata map[string]string, + ) (func() (backend.LLMResponse, error), error) { + return func() (backend.LLMResponse, error) { + tokenCallback(call, backend.TokenUsage{}) + return backend.LLMResponse{Response: call}, nil + }, nil + } + cfg := &config.ModelConfig{} + cfg.FunctionsConfig.AutomaticToolParsingFallback = true + cfg.FunctionsConfig.JSONRegexMatch = []string{`(?s)(.*?)`} + recorder := httptest.NewRecorder() + request := httptest.NewRequest("POST", "/v1/responses", nil) + c := echo.New().NewContext(request, recorder) + input := &schema.OpenResponsesRequest{Model: "test-model", Input: "hello", Stream: true} + err := handleOpenResponsesStream(c, "resp_test", 1, input, cfg, nil, nil, config.NewApplicationConfig(), "hello", &schema.OpenAIRequest{Context: request.Context()}, nil, false, false, nil, nil) + Expect(err).NotTo(HaveOccurred()) + + var calls []schema.ORItemField + for _, line := range strings.Split(recorder.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + continue + } + var event schema.ORStreamEvent + Expect(json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event)).To(Succeed()) + if event.Item != nil && event.Item.Type == "function_call" { + calls = append(calls, *event.Item) + } + if event.Type == "response.completed" { + for _, item := range event.Response.Output { + if item.Type == "function_call" { + calls = append(calls, item) + } + } + } + } + Expect(calls).To(HaveLen(3), "output_item.added, output_item.done and response.completed") + for _, item := range calls { + Expect(item.Namespace).To(Equal("mcp__docs")) + Expect(item.Name).To(Equal("search")) + Expect(item.Arguments).To(MatchJSON(`{"q":"x"}`)) + } + }) +}) diff --git a/core/schema/openresponses.go b/core/schema/openresponses.go index 4cc0e05b3575..ec0cedb69703 100644 --- a/core/schema/openresponses.go +++ b/core/schema/openresponses.go @@ -79,6 +79,9 @@ type ORFunctionTool struct { Description string `json:"description,omitempty"` Parameters map[string]any `json:"parameters,omitempty"` Strict bool `json:"strict"` // Always include in response + // Tools holds the member tools of a type "namespace" entry (Codex sends + // each MCP server's tools this way). + Tools []ORFunctionTool `json:"tools,omitempty"` } // ORReasoningParam represents reasoning configuration @@ -100,6 +103,7 @@ type ORItemParam struct { // Function call fields CallID string `json:"call_id,omitempty"` Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` // set when Name belongs to a namespace tool Arguments string `json:"arguments"` // Function call output fields diff --git a/docs/content/features/text-generation.md b/docs/content/features/text-generation.md index 9850fc42f393..b5d68b2f30c1 100644 --- a/docs/content/features/text-generation.md +++ b/docs/content/features/text-generation.md @@ -344,7 +344,7 @@ the identity that created them. | `temperature` | float | No | Sampling temperature | | `top_p` | float | No | Nucleus sampling parameter | | `instructions` | string | No | System instructions | -| `tools` | array | No | Array of tool definitions | +| `tools` | array | No | Array of tool definitions: `function` tools, and `namespace` entries such as the ones Codex sends for MCP servers. The model sees a namespace member as `ns_{namespace}___{name}`; the `function_call` items returned carry the `namespace` and `name` again | | `tool_choice` | string/object | No | Tool choice: "auto", "required", "none", or specific tool | | `stream` | boolean | No | Enable streaming responses | | `background` | boolean | No | Run request in background (returns immediately) | diff --git a/swagger/docs.go b/swagger/docs.go index 9d802f117877..a1e1884e5bad 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -27161,6 +27161,13 @@ const docTemplate = `{ "description": "Always include in response", "type": "boolean" }, + "tools": { + "description": "Tools holds the member tools of a type \"namespace\" entry (Codex sends\neach MCP server's tools this way).", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ORFunctionTool" + } + }, "type": { "type": "string" } @@ -27207,6 +27214,10 @@ const docTemplate = `{ "name": { "type": "string" }, + "namespace": { + "description": "set when Name belongs to a namespace tool", + "type": "string" + }, "output": { "description": "Function call output fields" }, diff --git a/swagger/swagger.json b/swagger/swagger.json index f9ff524bcb48..8f54a30234b0 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -27158,6 +27158,13 @@ "description": "Always include in response", "type": "boolean" }, + "tools": { + "description": "Tools holds the member tools of a type \"namespace\" entry (Codex sends\neach MCP server's tools this way).", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ORFunctionTool" + } + }, "type": { "type": "string" } @@ -27204,6 +27211,10 @@ "name": { "type": "string" }, + "namespace": { + "description": "set when Name belongs to a namespace tool", + "type": "string" + }, "output": { "description": "Function call output fields" }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 1c8147e8fa7e..a4199aa0301b 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -8779,6 +8779,13 @@ definitions: strict: description: Always include in response type: boolean + tools: + description: |- + Tools holds the member tools of a type "namespace" entry (Codex sends + each MCP server's tools this way). + items: + $ref: '#/definitions/schema.ORFunctionTool' + type: array type: type: string type: object @@ -8810,6 +8817,9 @@ definitions: type: string name: type: string + namespace: + description: set when Name belongs to a namespace tool + type: string output: description: Function call output fields role: