Skip to content
Merged
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
7 changes: 7 additions & 0 deletions core/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -7430,6 +7430,13 @@ func prepareResponsesRequest(ctx *schemas.BifrostContext, config *schemas.Provid
if r == nil {
return nil, nil
}
// Codex's explicit "functions" namespace is the default namespace by definition,
// so it is unwrapped for every wire before the support check: Bedrock Mantle
// reserves the name, and flattening wires would otherwise prefix its members.
r, bifrostErr := providerUtils.UnwrapDefaultNamespaceTools(r)
if bifrostErr != nil {
return nil, bifrostErr
}
var supported bool
if capable, ok := provider.(schemas.ResponsesNamespaceToolProvider); ok {
supported = capable.SupportsResponsesNamespaceTools(ctx, key, r.Model)
Expand Down
2 changes: 2 additions & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
- feat: the namespace-tool names a provider reserves for its own server tools (`web`, `image_gen`, `browser`, `python` on Bedrock and Bedrock Mantle) are now read from the datasheet row `reserved_tool_namespaces` (new `ModelCapabilities` field and `ModelCaps.ReservedToolNamespaces`) for the base provider and canonical model; a non-empty row replaces the hardcoded list, an absent or empty row keeps it, so any OpenAI-wire provider can reserve names through a row without a code change
- fix: Codex >= 0.147 wraps its default tools in a namespace literally named `functions` (openai/codex#37022), which Bedrock Mantle reserves and rejects with `User-defined namespace 'functions' collides with an existing tool namespace`. Core now unwraps that namespace to top-level tools, unprefixed, for every provider before dispatch; Codex treats the bare name and the explicit `functions` namespace as the same tool, so no mapping back is needed; a description on the `functions` namespace is prepended to each hoisted member, as flattening does
- fix: Responses `namespace` tools are now flattened in core for every provider whose wire does not support the type (Anthropic, Gemini, Vertex, Bedrock Converse, DeepSeek, and every OpenAI-compatible third party), with nested functions renamed to `<namespace>__<function>` so two namespaces that share a function name no longer collide into an upstream `Tool names must be unique` 400; returned `function_call` items are mapped back to the bare `name` plus `namespace` (unary and streaming), prior-turn calls carrying `namespace` and `tool_choice` names are re-aliased to match, and a name that is still duplicated after flattening or a `tool_choice` that matches several namespaces is rejected with a clear 400 before reaching the provider. Bedrock answers namespace support from its own surface resolver via the new optional `ResponsesNamespaceToolProvider` interface. A datasheet row `supports_namespace_tools` (new `ModelCapabilities` field and `ModelCaps.SupportsNamespaceTools`) overrides the per-provider default for a (provider, model) pair; with no row the default applies. A row can only narrow within what the wire can carry: the Anthropic Messages API (Anthropic, Claude on Azure or Bedrock Mantle), the Gemini API (Gemini, Vertex) and Bedrock Converse have no namespace container, so they answer false regardless of the row and always flatten. Flattened names honour the target wire's documented tool-name limit, 64 characters of `[A-Za-z0-9_-]` for OpenAI-compatible wires, Bedrock Converse and Fireworks, 128 for Anthropic, and 128 with `.` and `:` allowed for Gemini and Vertex, overridable per model through the datasheet row `tool_name_max_length` (a row below 10 cannot hold the hashed form and is ignored); a longer alias becomes an 8-hex hash prefix plus the function name, deterministically, so history and `tool_choice` re-alias to the same string. The alias map travels on the prepared request (`BifrostResponsesRequest.NamespaceToolAliases`) and is applied to that attempt's response, unary and streaming; nothing is kept on the request context or in process-wide state (#7048)
- fix: drop namespace tools whose name Amazon Bedrock reserves (`web`, `image_gen`, `browser`, `python`) on the Bedrock and Bedrock Mantle Responses paths instead of forwarding them into a `tools.namespace` collision 400; a dropped Codex `web` namespace becomes the hosted `web_search` tool on Bedrock Mantle
- fix: Bedrock Mantle chat streaming no longer drops the usage-only chunk that arrives after `finish_reason`; `ProviderSendsDoneMarker` now treats `bedrock_mantle` (and the legacy Mantle route under the `bedrock` key) as sending `[DONE]`, so streamed usage and cost are recorded (#7065)
2 changes: 1 addition & 1 deletion core/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ require (
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/molecule-man/go-brrr v1.0.1 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
Expand Down
3 changes: 1 addition & 2 deletions core/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/molecule-man/go-brrr v1.0.1 h1:cEjgx8hgNw6UGdhQ94SPDbPkKuRbkUcxBO3IzbGpA/o=
github.com/molecule-man/go-brrr v1.0.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
Expand Down
44 changes: 44 additions & 0 deletions core/promptcachedispatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,47 @@ func TestWrapNamespaceRestorePostHookRunner(t *testing.T) {
plain(nil, chunk(), nil)
assert.Equal(t, "namespace_a__js", *seen.ResponsesStreamResponse.Item.Name)
}

// Codex >= 0.147 sends a "functions" namespace on every request. It is the default
// namespace by definition, so it is unwrapped for EVERY provider, including ones
// whose wire accepts namespaces: Bedrock Mantle reserves the name and 400s, and on
// OpenAI the unwrap is a no-op semantically.
func TestPrepareResponsesRequest_UnwrapsFunctionsNamespaceForEveryWire(t *testing.T) {
functionsNS := schemas.ResponsesTool{
Type: schemas.ResponsesToolTypeNamespace,
Name: new("functions"),
ResponsesToolNamespace: &schemas.ResponsesToolNamespace{Tools: []schemas.ResponsesTool{
{Type: schemas.ResponsesToolTypeFunction, Name: new("wait"), ResponsesToolFunction: &schemas.ResponsesToolFunction{}},
}},
}

t.Run("namespace-capable provider still gets it unwrapped", func(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
req := responsesReqWithNamespaces(schemas.Bedrock)
req.Params.Tools = []schemas.ResponsesTool{functionsNS, req.Params.Tools[0]}

out, bifrostErr := prepareResponsesRequest(ctx, &schemas.ProviderConfig{},
namespaceCapableStub{stubProvider: stubProvider{key: schemas.Bedrock}, supported: true}, schemas.Key{}, req)

require.Nil(t, bifrostErr)
require.NotSame(t, req, out)
require.Len(t, out.Params.Tools, 2)
assert.Equal(t, schemas.ResponsesToolTypeFunction, out.Params.Tools[0].Type)
assert.Equal(t, "wait", *out.Params.Tools[0].Name, "functions members are hoisted without a prefix")
assert.Equal(t, schemas.ResponsesToolTypeNamespace, out.Params.Tools[1].Type, "other namespaces pass through on a capable wire")
assert.Equal(t, schemas.ResponsesToolTypeNamespace, req.Params.Tools[0].Type, "the shared request was mutated")
})

t.Run("flattening wire hoists functions members without a prefix", func(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
req := responsesReqWithNamespaces(schemas.Anthropic)
req.Params.Tools = []schemas.ResponsesTool{functionsNS, req.Params.Tools[0]}

out, bifrostErr := prepareResponsesRequest(ctx, &schemas.ProviderConfig{}, stubProvider{key: schemas.Anthropic}, schemas.Key{}, req)

require.Nil(t, bifrostErr)
require.Len(t, out.Params.Tools, 2)
assert.Equal(t, "wait", *out.Params.Tools[0].Name)
assert.Equal(t, "namespace_a__js", *out.Params.Tools[1].Name)
})
}
47 changes: 34 additions & 13 deletions core/providers/openai/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,37 @@ var ProviderFeatures = map[schemas.ModelProvider]ResponsesFeatureSupport{
schemas.BedrockMantle: {AdditionalToolsItem: false, ContextManagement: false},
}

// reservedToolNamespaces lists the namespace-tool names a provider keeps for
// its own server-side tools. Bedrock rejects a user-defined namespace with one
// of these names outright on both the bedrock-mantle and bedrock-runtime
// Responses endpoints: "Invalid Value: 'tools.namespace'. User-defined namespace
// 'web' collides with an existing tool namespace." (HTTP 400). Codex registers a
// client-side "web" namespace (web.run) whenever it believes it is talking to
// OpenAI, which is the case when Bifrost is configured through openai_base_url.
// Live-verified against openai.gpt-5.6-luna on 2026-09-09.
var reservedToolNamespaces = map[schemas.ModelProvider]map[string]bool{
schemas.Bedrock: {"web": true, "image_gen": true, "browser": true, "python": true},
schemas.BedrockMantle: {"web": true, "image_gen": true, "browser": true, "python": true},
// reservedToolNamespaces is the hardcoded fallback for the namespace-tool names a
// provider keeps for its own server-side tools, used when the datasheet row for the
// (base provider, model) publishes no reserved_tool_namespaces. Bedrock rejects a
// user-defined namespace with one of these names outright on both the
// bedrock-mantle and bedrock-runtime Responses endpoints: "Invalid Value:
// 'tools.namespace'. User-defined namespace 'web' collides with an existing tool
// namespace." (HTTP 400). Codex registers a client-side "web" namespace (web.run)
// whenever it believes it is talking to OpenAI, which is the case when Bifrost is
// configured through openai_base_url. Live-verified against openai.gpt-5.6-luna on
// 2026-09-09.
var reservedToolNamespaces = map[schemas.ModelProvider][]string{
schemas.Bedrock: {"web", "image_gen", "browser", "python"},
schemas.BedrockMantle: {"web", "image_gen", "browser", "python"},
}

// resolveReservedToolNamespaces returns the reserved-name set for one attempt:
// the datasheet row for (toolProvider, capModel) when it publishes one, else the
// hardcoded fallback. toolProvider is the BASE provider, so a custom provider
// wrapping Mantle reads the bedrock_mantle row and the bedrock_mantle fallback.
func resolveReservedToolNamespaces(toolProvider schemas.ModelProvider, capModel string) map[string]bool {
names := schemas.ResolveModelCaps(toolProvider, capModel).ReservedToolNamespaces(reservedToolNamespaces[toolProvider])
if len(names) == 0 {
return nil
}
reserved := make(map[string]bool, len(names))
for _, name := range names {
if name != "" {
reserved[name] = true
}
}
return reserved
}

// dropReservedNamespaceTools returns a copy of tools without the namespace tools
Expand Down Expand Up @@ -674,9 +694,10 @@ func ToOpenAIResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.B
// the hoist so additional_tools namespaces get the same treatment, and before
// filterUnsupportedTools so a substituted web_search goes through its copy path.
// Match on the base provider: a custom provider built on bedrock reports its own
// key, which the map does not know, so the reserved namespace would reach AWS.
// key, which neither the datasheet nor the fallback map knows, so the reserved
// namespace would reach AWS.
toolProvider := schemas.ResolveBaseProvider(ctx, bifrostReq.Provider)
if reserved := reservedToolNamespaces[toolProvider]; len(reserved) > 0 && len(req.Tools) > 0 {
if reserved := resolveReservedToolNamespaces(toolProvider, capModel); len(reserved) > 0 && len(req.Tools) > 0 {
substitute := toolProvider == schemas.BedrockMantle && caps.SupportsWebSearch(true)
req.Tools = dropReservedNamespaceTools(req.Tools, reserved, substitute)
}
Expand Down
102 changes: 102 additions & 0 deletions core/providers/openai/responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2924,3 +2924,105 @@ func TestToOpenAIResponsesRequest_DropsReservedNamespaceForBedrock(t *testing.T)
})
}
}

// The reserved-namespace list is datasheet-first: a row's reserved_tool_namespaces
// replaces the hardcoded per-provider fallback for that (provider, model), and a
// provider with no fallback at all can still reserve names through a row. The row
// is looked up on the BASE provider so a custom provider wrapping Mantle reads
// the bedrock_mantle row.
func TestToOpenAIResponsesRequest_ReservedNamespacesFromDatasheet(t *testing.T) {
rows := map[schemas.ModelProvider]map[string][]string{
schemas.BedrockMantle: {"openai.gpt-5.6-luna": {"only_this"}},
schemas.XAI: {"grok-4.6": {"x_tools"}},
}
schemas.SetCapabilityResolver(func(provider schemas.ModelProvider, model string) *schemas.ModelCapabilities {
reserved, ok := rows[provider][model]
if !ok {
return nil
}
return &schemas.ModelCapabilities{ReservedToolNamespaces: reserved}
})
t.Cleanup(func() { schemas.SetCapabilityResolver(nil) })

namespace := func(name string) schemas.ResponsesTool {
return schemas.ResponsesTool{
Type: schemas.ResponsesToolTypeNamespace,
Name: schemas.Ptr(name),
ResponsesToolNamespace: &schemas.ResponsesToolNamespace{Tools: []schemas.ResponsesTool{{
Type: schemas.ResponsesToolTypeFunction,
Name: schemas.Ptr("run"),
ResponsesToolFunction: &schemas.ResponsesToolFunction{},
}}},
}
}

tests := []struct {
name string
provider schemas.ModelProvider
baseProvider schemas.ModelProvider
model string
tools []schemas.ResponsesTool
wantNames []string
}{
{
name: "mantle row replaces the hardcoded list, so web survives and only_this is dropped",
provider: schemas.BedrockMantle,
model: "openai.gpt-5.6-luna",
tools: []schemas.ResponsesTool{namespace("web"), namespace("only_this"), namespace("keep")},
wantNames: []string{"web", "keep"},
},
{
name: "mantle model without a row keeps the hardcoded fallback",
provider: schemas.BedrockMantle,
model: "openai.gpt-5.6-terra",
tools: []schemas.ResponsesTool{namespace("web"), namespace("only_this"), namespace("keep")},
wantNames: []string{"only_this", "keep", ""},
},
{
name: "a provider with no hardcoded entry reserves names through its row",
provider: schemas.XAI,
model: "grok-4.6",
tools: []schemas.ResponsesTool{namespace("x_tools"), namespace("keep")},
wantNames: []string{"keep"},
},
{
name: "custom provider on a mantle base reads the bedrock_mantle row",
provider: schemas.ModelProvider("my-mantle"),
baseProvider: schemas.BedrockMantle,
model: "openai.gpt-5.6-luna",
tools: []schemas.ResponsesTool{namespace("web"), namespace("only_this")},
wantNames: []string{"web"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
bifrostReq := &schemas.BifrostResponsesRequest{
Provider: tc.provider,
Model: tc.model,
Input: []schemas.ResponsesMessage{{
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
Content: &schemas.ResponsesMessageContent{ContentStr: schemas.Ptr("hi")},
}},
Params: &schemas.ResponsesParameters{Tools: tc.tools},
}
var ctx *schemas.BifrostContext
if tc.baseProvider != "" {
ctx = schemas.NewBifrostContextWithValue(context.Background(), schemas.NoDeadline,
schemas.BifrostContextKeyBaseProviderType, tc.baseProvider)
}
result := ToOpenAIResponsesRequest(ctx, bifrostReq)
require.NotNil(t, result)

gotNames := make([]string, 0, len(result.Tools))
for _, tool := range result.Tools {
name := ""
if tool.Name != nil {
name = *tool.Name
}
gotNames = append(gotNames, name)
}
require.Equal(t, tc.wantNames, gotNames)
})
}
}
Loading
Loading