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
65 changes: 58 additions & 7 deletions core/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -7415,6 +7415,34 @@ func promptCacheResponsesRequest(ctx *schemas.BifrostContext, config *schemas.Pr
return &cp
}

// prepareResponsesRequest returns the Responses request to dispatch for one attempt:
// prompt-cache breakpoints first, then namespace tools flattened when the target wire
// does not understand them (#7048). Both steps are copy-on-write, so the shared
// req.BifrostRequest keeps the caller's namespaces for a later fallback attempt against
// a wire that does.
//
// The provider answers the support question itself when it implements
// schemas.ResponsesNamespaceToolProvider (Bedrock: Mantle yes, Converse no); otherwise
// the per-provider default in providerUtils applies, keyed on the BASE provider so a
// custom provider wrapping OpenAI is treated like OpenAI.
func prepareResponsesRequest(ctx *schemas.BifrostContext, config *schemas.ProviderConfig, provider schemas.Provider, key schemas.Key, r *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesRequest, *schemas.BifrostError) {
r = promptCacheResponsesRequest(ctx, config, provider.GetProviderKey(), r)
if r == nil {
return nil, nil
}
var supported bool
if capable, ok := provider.(schemas.ResponsesNamespaceToolProvider); ok {
supported = capable.SupportsResponsesNamespaceTools(ctx, key, r.Model)
} else {
supported = providerUtils.ResponsesNamespaceToolsSupported(ctx, schemas.ResolveBaseProvider(ctx, provider.GetProviderKey()), r.Model)
}
if supported {
// Pass-through: the request carries no alias map, so nothing is restored.
return r, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return providerUtils.FlattenResponsesNamespaceTools(ctx, r)
}

// promptCacheChatRequest is the Chat Completions parallel of
// promptCacheResponsesRequest, with the same copy-on-write guarantee.
func promptCacheChatRequest(ctx *schemas.BifrostContext, config *schemas.ProviderConfig, provider schemas.ModelProvider, r *schemas.BifrostChatRequest) *schemas.BifrostChatRequest {
Expand Down Expand Up @@ -7463,7 +7491,10 @@ func (bifrost *Bifrost) handleProviderRequest(provider schemas.Provider, config
if changeType, ok := req.Context.Value(schemas.BifrostContextKeyChangeRequestType).(schemas.RequestType); ok && changeType == schemas.ResponsesRequest {
responsesRequest := req.BifrostRequest.ChatRequest.ToResponsesRequest()
if responsesRequest != nil {
responsesRequest = promptCacheResponsesRequest(req.Context, config, provider.GetProviderKey(), responsesRequest)
responsesRequest, bifrostError := prepareResponsesRequest(req.Context, config, provider, key, responsesRequest)
if bifrostError != nil {
return nil, bifrostError
}
responsesResponse, bifrostError := provider.Responses(req.Context, key, responsesRequest)
if bifrostError != nil {
return nil, bifrostError
Expand All @@ -7479,8 +7510,14 @@ func (bifrost *Bifrost) handleProviderRequest(provider schemas.Provider, config
chatCompletionResponse.BackfillParams(req.BifrostRequest.ChatRequest)
response.ChatResponse = chatCompletionResponse
case schemas.ResponsesRequest:
// Prepared BEFORE the chat-fallback branch: ToChatRequest keeps only function
// tools, so a namespace that reached it unflattened would be dropped silently.
preparedRequest, bifrostError := prepareResponsesRequest(req.Context, config, provider, key, req.BifrostRequest.ResponsesRequest)
if bifrostError != nil {
return nil, bifrostError
}
if changeType, ok := req.Context.Value(schemas.BifrostContextKeyChangeRequestType).(schemas.RequestType); ok && changeType == schemas.ChatCompletionRequest {
chatRequest := req.BifrostRequest.ResponsesRequest.ToChatRequest()
chatRequest := preparedRequest.ToChatRequest()
if chatRequest != nil {
chatCompletionResponse, bifrostError := provider.ChatCompletion(req.Context, key, chatRequest)
if bifrostError != nil {
Expand All @@ -7489,15 +7526,17 @@ func (bifrost *Bifrost) handleProviderRequest(provider schemas.Provider, config
responsesResponse := chatCompletionResponse.ToBifrostResponsesResponse()
responsesResponse.BackfillParams(req.BifrostRequest.ResponsesRequest)
response.ResponsesResponse = responsesResponse
providerUtils.RestoreResponsesNamespaceToolCalls(preparedRequest.NamespaceToolAliases, response)
break
}
}
responsesResponse, bifrostError := provider.Responses(req.Context, key, promptCacheResponsesRequest(req.Context, config, provider.GetProviderKey(), req.BifrostRequest.ResponsesRequest))
responsesResponse, bifrostError := provider.Responses(req.Context, key, preparedRequest)
if bifrostError != nil {
return nil, bifrostError
}
responsesResponse.BackfillParams(req.BifrostRequest.ResponsesRequest)
response.ResponsesResponse = responsesResponse
providerUtils.RestoreResponsesNamespaceToolCalls(preparedRequest.NamespaceToolAliases, response)
case schemas.CountTokensRequest:
countTokensResponse, bifrostError := provider.CountTokens(req.Context, key, req.BifrostRequest.CountTokensRequest)
if bifrostError != nil {
Expand Down Expand Up @@ -7850,21 +7889,33 @@ func (bifrost *Bifrost) handleProviderStreamRequest(provider schemas.Provider, c
if changeType, ok := req.Context.Value(schemas.BifrostContextKeyChangeRequestType).(schemas.RequestType); ok && changeType == schemas.ResponsesRequest {
responsesRequest := req.BifrostRequest.ChatRequest.ToResponsesRequest()
if responsesRequest != nil {
return provider.ResponsesStream(req.Context, wrapConvertedStreamPostHookRunner(postHookRunner, schemas.ResponsesRequest), postHookSpanFinalizer, key, promptCacheResponsesRequest(req.Context, config, provider.GetProviderKey(), responsesRequest))
responsesRequest, bifrostError := prepareResponsesRequest(req.Context, config, provider, key, responsesRequest)
if bifrostError != nil {
return nil, bifrostError
}
return provider.ResponsesStream(req.Context, wrapConvertedStreamPostHookRunner(postHookRunner, schemas.ResponsesRequest), postHookSpanFinalizer, key, responsesRequest)
}
}
return provider.ChatCompletionStream(req.Context, postHookRunner, postHookSpanFinalizer, key, promptCacheChatRequest(req.Context, config, provider.GetProviderKey(), req.BifrostRequest.ChatRequest))
case schemas.ResponsesStreamRequest:
// Prepared BEFORE the chat-fallback branch: ToChatRequest keeps only function
// tools, so a namespace that reached it unflattened would be dropped silently.
preparedRequest, bifrostError := prepareResponsesRequest(req.Context, config, provider, key, req.BifrostRequest.ResponsesRequest)
if bifrostError != nil {
return nil, bifrostError
}
if changeType, ok := req.Context.Value(schemas.BifrostContextKeyChangeRequestType).(schemas.RequestType); ok && changeType == schemas.ChatCompletionRequest {
chatRequest := req.BifrostRequest.ResponsesRequest.ToChatRequest()
chatRequest := preparedRequest.ToChatRequest()
if chatRequest != nil {
// The providers' chat streaming handler re-assembles Responses events from the
// chat chunks when this flag is set, so the caller still gets a Responses stream.
req.Context.SetValue(schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true)
return provider.ChatCompletionStream(req.Context, postHookRunner, postHookSpanFinalizer, key, chatRequest)
return provider.ChatCompletionStream(req.Context, providerUtils.WrapNamespaceRestorePostHookRunner(postHookRunner, preparedRequest.NamespaceToolAliases), postHookSpanFinalizer, key, chatRequest)
}
}
return provider.ResponsesStream(req.Context, postHookRunner, postHookSpanFinalizer, key, promptCacheResponsesRequest(req.Context, config, provider.GetProviderKey(), req.BifrostRequest.ResponsesRequest))
// The prepared request carries the alias map; the wrapped runner restores the
// caller's tool names on every chunk before the post hooks see it.
return provider.ResponsesStream(req.Context, providerUtils.WrapNamespaceRestorePostHookRunner(postHookRunner, preparedRequest.NamespaceToolAliases), postHookSpanFinalizer, key, preparedRequest)
case schemas.ResponsesRetrieveStreamRequest:
lifecycle, ok := provider.(schemas.ResponsesLifecycleProvider)
if !ok {
Expand Down
1 change: 1 addition & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
- 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)
138 changes: 138 additions & 0 deletions core/promptcachedispatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"testing"

providerUtils "github.com/maximhq/bifrost/core/providers/utils"
"github.com/maximhq/bifrost/core/schemas"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -209,3 +210,140 @@ func TestPromptCacheDispatch_HonoursPerRequestOverride(t *testing.T) {
assert.Nil(t, req.Input[0].Content.ContentBlocks[0].CacheControl)
})
}

// stubProvider satisfies schemas.Provider for the dispatch seam; only GetProviderKey is
// ever called by prepareResponsesRequest. Everything else panics on the nil embed.
type stubProvider struct {
schemas.Provider
key schemas.ModelProvider
}

func (s stubProvider) GetProviderKey() schemas.ModelProvider { return s.key }

// namespaceCapableStub is a provider that answers the namespace question itself, the
// way Bedrock does from its surface resolver.
type namespaceCapableStub struct {
stubProvider
supported bool
}

func (s namespaceCapableStub) SupportsResponsesNamespaceTools(*schemas.BifrostContext, schemas.Key, string) bool {
return s.supported
}

func responsesReqWithNamespaces(provider schemas.ModelProvider) *schemas.BifrostResponsesRequest {
req := responsesReqWithText("Test the tools")
req.Provider = provider
nested := func(name string) schemas.ResponsesTool {
return schemas.ResponsesTool{Type: schemas.ResponsesToolTypeFunction, Name: new(name), ResponsesToolFunction: &schemas.ResponsesToolFunction{}}
}
req.Params = &schemas.ResponsesParameters{Tools: []schemas.ResponsesTool{
{Type: schemas.ResponsesToolTypeNamespace, Name: new("namespace_a"), ResponsesToolNamespace: &schemas.ResponsesToolNamespace{Tools: []schemas.ResponsesTool{nested("js")}}},
{Type: schemas.ResponsesToolTypeNamespace, Name: new("namespace_b"), ResponsesToolNamespace: &schemas.ResponsesToolNamespace{Tools: []schemas.ResponsesTool{nested("js")}}},
}}
return req
}

// TestPrepareResponsesRequest_FlattensForUnsupportedWireAndIsolatesSharedRequest is the
// dispatch-seam half of issue #7048: an attempt against a wire without namespace support
// gets flattened, prefixed tools, and the shared request keeps the caller's namespaces
// for the next attempt.
func TestPrepareResponsesRequest_FlattensForUnsupportedWireAndIsolatesSharedRequest(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
req := responsesReqWithNamespaces(schemas.Anthropic)

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

require.Nil(t, bifrostErr)
require.NotSame(t, req, out)
require.Len(t, out.Params.Tools, 2)
assert.Equal(t, "namespace_a__js", *out.Params.Tools[0].Name)
assert.Equal(t, "namespace_b__js", *out.Params.Tools[1].Name)
assert.Equal(t, schemas.ResponsesToolTypeNamespace, req.Params.Tools[0].Type, "the shared request was mutated")

// The chat fallback must see function tools, not an empty list.
chat := out.ToChatRequest()
require.NotNil(t, chat)
require.Len(t, chat.Params.Tools, 2)
}

func TestPrepareResponsesRequest_PassesThroughForOpenAI(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
req := responsesReqWithNamespaces(schemas.OpenAI)

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

require.Nil(t, bifrostErr)
assert.Same(t, req, out, "OpenAI understands namespace tools; the request must dispatch unchanged")
}

func TestPrepareResponsesRequest_ProviderAnswerWins(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
req := responsesReqWithNamespaces(schemas.Bedrock)

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

require.Nil(t, bifrostErr)
assert.Same(t, req, out, "a provider that reports namespace support must not be flattened")
}

// TestPrepareResponsesRequest_AliasesTravelWithThePreparedRequest pins the ownership
// rule: the alias map is request state, carried on the prepared copy and applied to
// that attempt's response. A later attempt on a wire that accepts namespaces gets the
// shared request back with no map, so nothing from the earlier attempt can leak into
// its response, without any context key or process-wide store.
func TestPrepareResponsesRequest_AliasesTravelWithThePreparedRequest(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
req := responsesReqWithNamespaces(schemas.Anthropic)

// Attempt 1: unsupported wire, the prepared copy carries the map.
first, bifrostErr := prepareResponsesRequest(ctx, &schemas.ProviderConfig{}, stubProvider{key: schemas.Anthropic}, schemas.Key{}, req)
require.Nil(t, bifrostErr)
require.Len(t, first.NamespaceToolAliases, 2, "attempt 1 must carry the aliases it produced")
assert.Nil(t, req.NamespaceToolAliases, "the shared request never carries a map")

// Attempt 2: supported wire on the same request, nothing to restore.
req.Provider = schemas.OpenAI
second, bifrostErr := prepareResponsesRequest(ctx, &schemas.ProviderConfig{}, stubProvider{key: schemas.OpenAI}, schemas.Key{}, req)
require.Nil(t, bifrostErr)
assert.Same(t, req, second)
assert.Nil(t, second.NamespaceToolAliases)

// Restoring attempt 2's response with attempt 2's (empty) map leaves it untouched.
resp := &schemas.BifrostResponse{ResponsesResponse: &schemas.BifrostResponsesResponse{Output: []schemas.ResponsesMessage{{
Type: new(schemas.ResponsesMessageTypeFunctionCall),
ResponsesToolMessage: &schemas.ResponsesToolMessage{CallID: new("c"), Name: new("namespace_a__js"), Arguments: new("{}")},
}}}}
providerUtils.RestoreResponsesNamespaceToolCalls(second.NamespaceToolAliases, resp)
assert.Equal(t, "namespace_a__js", *resp.ResponsesResponse.Output[0].Name)
assert.Nil(t, resp.ResponsesResponse.Output[0].Namespace)
}

// The streaming path restores through a wrapped PostHookRunner built from the prepared
// request's map, so every chunk reaches the post hooks with the caller's names.
func TestWrapNamespaceRestorePostHookRunner(t *testing.T) {
aliases := map[string]schemas.NamespaceToolAlias{"namespace_a__js": {Namespace: "namespace_a", Name: "js"}}
var seen *schemas.BifrostResponse
inner := func(_ *schemas.BifrostContext, result *schemas.BifrostResponse, err *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError) {
seen = result
return result, err
}
chunk := func() *schemas.BifrostResponse {
return &schemas.BifrostResponse{ResponsesStreamResponse: &schemas.BifrostResponsesStreamResponse{
Type: schemas.ResponsesStreamResponseTypeOutputItemAdded,
Item: &schemas.ResponsesMessage{Type: new(schemas.ResponsesMessageTypeFunctionCall), ResponsesToolMessage: &schemas.ResponsesToolMessage{CallID: new("c"), Name: new("namespace_a__js"), Arguments: new("{}")}},
}}
}

wrapped := providerUtils.WrapNamespaceRestorePostHookRunner(inner, aliases)
wrapped(nil, chunk(), nil)
require.NotNil(t, seen)
assert.Equal(t, "js", *seen.ResponsesStreamResponse.Item.Name)
assert.Equal(t, "namespace_a", *seen.ResponsesStreamResponse.Item.Namespace)

// No aliases: the runner is returned as is and chunks pass through untouched.
plain := providerUtils.WrapNamespaceRestorePostHookRunner(inner, nil)
plain(nil, chunk(), nil)
assert.Equal(t, "namespace_a__js", *seen.ResponsesStreamResponse.Item.Name)
}
23 changes: 23 additions & 0 deletions core/providers/bedrock/surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,29 @@ func (provider *BedrockProvider) resolveSurface(ctx *schemas.BifrostContext, key
return surface
}

// SupportsResponsesNamespaceTools implements schemas.ResponsesNamespaceToolProvider.
// Only the Mantle OpenAI-compatible endpoint understands the Responses `namespace`
// tool type. Converse does not, and neither does the native Anthropic Messages
// surface Claude takes on Mantle, so core flattens namespaces for both.
//
// The surface is routing (identifier form, key ARN) and stays code; it decides what
// the wire can structurally carry, and the datasheet row can only narrow within
// that. Reads the surface directly rather than through routesToMantle to avoid a
// second debug log line per attempt.
func (provider *BedrockProvider) SupportsResponsesNamespaceTools(ctx *schemas.BifrostContext, key schemas.Key, model string) bool {
surface := resolveBedrockSurface(ctx, key, model)
// Converse has no namespace container, and neither does the Anthropic Messages
// surface Claude takes on Mantle, so no datasheet row can enable them: a row
// saying "supported" there would send the container to a wire that rejects it.
if !surface.isMantle() || schemas.IsAnthropicModelFamily(ctx, model) {
return false
}
// Mantle's OpenAI-compatible path accepts namespaces; a bedrock_mantle row may
// still switch it off for a model that turns out not to.
caps := schemas.ResolveModelCaps(schemas.BedrockMantle, schemas.ResolveCanonicalModel(ctx, model))
return caps.SupportsNamespaceTools(true)
}

// routesToMantle resolves the surface and logs the deciding rule.
func (provider *BedrockProvider) routesToMantle(ctx *schemas.BifrostContext, key schemas.Key, model string) bool {
return provider.resolveSurface(ctx, key, model).isMantle()
Expand Down
Loading
Loading