Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions core/http/endpoints/openresponses/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
50 changes: 50 additions & 0 deletions core/http/endpoints/openresponses/responses_convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
54 changes: 54 additions & 0 deletions core/http/endpoints/openresponses/responses_stream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,57 @@ var _ = Describe("Responses stream item consistency", func() {
Entry("reasoning and automatic fallback tool call", []string{"<think>", "Let me think.", "</think>", `<tool_call>{"name":"get_weather","arguments":{"city":"Rome"}}</tool_call>`}, 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 := `<tool_call>{"name":"ns_mcp__docs___search","arguments":{"q":"x"}}</tool_call>`
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)<tool_call>(.*?)</tool_call>`}
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"}`))
}
})
})
4 changes: 4 additions & 0 deletions core/schema/openresponses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/content/features/text-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
11 changes: 11 additions & 0 deletions swagger/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
},
Expand Down
11 changes: 11 additions & 0 deletions swagger/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"
},
Expand Down
10 changes: 10 additions & 0 deletions swagger/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down