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
13 changes: 7 additions & 6 deletions core/providers/anthropic/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -5997,20 +5997,21 @@ func convertAnthropicContentBlocksToResponsesMessagesGrouped(contentBlocks []Ant
},
})
} else {
// For input messages, emit text immediately as separate message
// For input messages, emit text immediately as separate message.
// input_text, not output_text: the Responses API matches `input`
// against a union of input-item variants, and an output-side block on
// a user message matches none of them ("Invalid 'input': value did not
// match any expected variant"). Annotations and logprobs are
// output-only for the same reason. Mirrors the ungrouped converter.
bifrostMsg := schemas.ResponsesMessage{
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
Role: role,
Content: &schemas.ResponsesMessageContent{
ContentBlocks: []schemas.ResponsesMessageContentBlock{
{
Type: schemas.ResponsesOutputMessageContentTypeText,
Type: schemas.ResponsesInputMessageContentBlockTypeText,
Text: block.Text,
CacheControl: block.CacheControl,
ResponsesOutputMessageContentText: &schemas.ResponsesOutputMessageContentText{
LogProbs: []schemas.ResponsesOutputMessageContentTextLogProb{},
Annotations: []schemas.ResponsesOutputMessageContentTextAnnotation{},
},
},
},
},
Expand Down
24 changes: 20 additions & 4 deletions core/providers/bedrock/bedrock.go
Original file line number Diff line number Diff line change
Expand Up @@ -1276,9 +1276,13 @@ func (provider *BedrockProvider) ChatCompletion(ctx *schemas.BifrostContext, key
return nil, err
}

if provider.routesToMantle(ctx, key, request.Model) {
surface := provider.resolveSurface(ctx, key, request.Model)
if surface.isMantle() {
return provider.mantleChatCompletions(ctx, key, request)
}
if runtimeServesOpenAIAPI(ctx, key, surface, request.Model, schemas.BedrockAPIChatCompletions) {
return provider.runtimeChatCompletions(ctx, key, request)
}

// Use Bedrock Converse API for all other models
jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody(
Expand Down Expand Up @@ -1467,9 +1471,13 @@ func (provider *BedrockProvider) ChatCompletionStream(ctx *schemas.BifrostContex
return nil, err
}

if provider.routesToMantle(ctx, key, request.Model) {
surface := provider.resolveSurface(ctx, key, request.Model)
if surface.isMantle() {
return provider.mantleChatCompletionsStream(ctx, postHookRunner, postHookSpanFinalizer, key, request)
}
if runtimeServesOpenAIAPI(ctx, key, surface, request.Model, schemas.BedrockAPIChatCompletions) {
return provider.runtimeChatCompletionsStream(ctx, postHookRunner, postHookSpanFinalizer, key, request)
}

// Use Bedrock Converse streaming API for all other models
jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody(
Expand Down Expand Up @@ -1789,9 +1797,13 @@ func (provider *BedrockProvider) Responses(ctx *schemas.BifrostContext, key sche
return nil, err
}

if provider.routesToMantle(ctx, key, request.Model) {
surface := provider.resolveSurface(ctx, key, request.Model)
if surface.isMantle() {
return provider.mantleResponses(ctx, key, request)
}
if runtimeServesOpenAIAPI(ctx, key, surface, request.Model, schemas.BedrockAPIResponses) {
return provider.runtimeResponses(ctx, key, request)
}

// Use Bedrock Converse API for all other models
jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody(
Expand Down Expand Up @@ -1875,9 +1887,13 @@ func (provider *BedrockProvider) ResponsesStream(ctx *schemas.BifrostContext, po
return nil, err
}

if provider.routesToMantle(ctx, key, request.Model) {
surface := provider.resolveSurface(ctx, key, request.Model)
if surface.isMantle() {
return provider.mantleResponsesStream(ctx, postHookRunner, postHookSpanFinalizer, key, request)
}
if runtimeServesOpenAIAPI(ctx, key, surface, request.Model, schemas.BedrockAPIResponses) {
return provider.runtimeResponsesStream(ctx, postHookRunner, postHookSpanFinalizer, key, request)
}

// Use Bedrock Converse streaming API for all other models
jsonData, bifrostErr := providerUtils.CheckContextAndGetRequestBody(
Expand Down
51 changes: 37 additions & 14 deletions core/providers/bedrock/bedrock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4660,17 +4660,9 @@ func TestDocumentFormatFromDataURL(t *testing.T) {

assert.Equal(t, tt.expectedFormat, doc.Format,
"data URL media type %q should map to format %q", tt.mediaType, tt.expectedFormat)
if strings.HasPrefix(strings.ToLower(tt.mediaType), "text/") {
decoded, err := base64.StdEncoding.DecodeString(payload)
require.NoError(t, err)
require.NotNil(t, doc.Source.Text)
assert.Equal(t, string(decoded), *doc.Source.Text)
assert.Nil(t, doc.Source.Bytes, "document source is a union")
} else {
require.NotNil(t, doc.Source.Bytes)
assert.Equal(t, payload, *doc.Source.Bytes, "data URL prefix must be stripped from source.bytes")
assert.Nil(t, doc.Source.Text, "document source is a union")
}
require.NotNil(t, doc.Source.Bytes)
assert.Equal(t, payload, *doc.Source.Bytes, "data URL prefix must be stripped from source.bytes")
assert.Nil(t, doc.Source.Text, "Converse rejects a text-only document source")
})
}
}
Expand Down Expand Up @@ -4717,9 +4709,9 @@ func TestDocumentInlineTextDataURL(t *testing.T) {
})

assert.Equal(t, "txt", doc.Format)
require.NotNil(t, doc.Source.Text)
assert.Equal(t, "Hello World", *doc.Source.Text)
assert.Nil(t, doc.Source.Bytes, "document source is a union and text documents must not also carry bytes")
require.NotNil(t, doc.Source.Bytes)
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("Hello World")), *doc.Source.Bytes)
assert.Nil(t, doc.Source.Text, "Converse rejects a text-only document source")

// A binary format never gets source.text, matching the raw file_data path.
doc = chatFileBlockDocument(t, &schemas.ChatInputFile{
Expand All @@ -4733,6 +4725,37 @@ func TestDocumentInlineTextDataURL(t *testing.T) {
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("%PDF-1.4")), *doc.Source.Bytes)
}

// Converse rejects a text-only document source, so text formats ship as base64 bytes.
func TestTextDocumentUsesBytesSource(t *testing.T) {
t.Parallel()

tests := []struct {
name string
fileType string
expectedFormat string
}{
{"PlainText", "text/plain", "txt"},
{"Markdown", "text/markdown", "md"},
{"CSV", "text/csv", "csv"},
{"HTML", "text/html", "html"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
doc := chatFileBlockDocument(t, &schemas.ChatInputFile{
Filename: schemas.Ptr("notes." + tt.expectedFormat),
FileType: schemas.Ptr(tt.fileType),
FileData: schemas.Ptr("hello world"),
})

assert.Equal(t, tt.expectedFormat, doc.Format)
require.NotNil(t, doc.Source.Bytes)
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("hello world")), *doc.Source.Bytes)
assert.Nil(t, doc.Source.Text, "Converse rejects a text-only document source")
})
}
}

// A non-base64 data URL payload is percent-encoded by definition, so a malformed
// escape is malformed input, not content. Swallowing the PathUnescape error sent
// the literal "%ZZ" bytes to Bedrock as if the caller had asked for them.
Expand Down
30 changes: 23 additions & 7 deletions core/providers/bedrock/mantle.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,10 @@ func isMantleModel(ctx *schemas.BifrostContext, model string) bool {
// mantleOpenAIURL builds the Bedrock Mantle OpenAI-compatible endpoint URL for the given
// region, model, and API path (e.g. "chat/completions", "responses"). Pass the canonical
// (capability-resolved) model for correct path gating; the request body still carries the
// wire request.Model. Frontier families (closed gpt-5.x, Gemma 4, Grok) live under the "openai/v1"
// base path; gpt-oss uses the bare "v1" path.
// wire request.Model. The base path comes from the datasheet, falling back to family
// detection — see schemas.ResolveBedrockMantleBasePath.
func mantleOpenAIURL(endpoints *schemas.BedrockEndpoints, region, model, path string) string {
base := "v1"
if strings.Contains(model, "gpt-5") || strings.Contains(model, "gemma-4") || schemas.IsGrokModel(model) {
base = "openai/v1"
}
base := schemas.ResolveBedrockMantleBasePath(model)
return fmt.Sprintf("https://%s/%s/%s", resolveBedrockHost(endpoints, bedrockServiceMantle, region), base, path)
}

Expand All @@ -78,6 +75,21 @@ func SignMantleV4Headers(
key schemas.Key,
region string,
extraHeaders map[string]string,
) (map[string]string, *schemas.BifrostError) {
return signOpenAIV4Headers(ctx, jsonData, requestURL, accept, key, region, extraHeaders, bedrockMantleSigningService)
}

// signOpenAIV4Headers is SignMantleV4Headers parameterised by signing service, so the
// same OpenAI-compatible surface can be signed on bedrock-runtime ("bedrock") as on
// mantle ("bedrock-mantle"). The two endpoints require different credential scopes.
func signOpenAIV4Headers(
ctx *schemas.BifrostContext,
jsonData []byte,
requestURL, accept string,
key schemas.Key,
region string,
extraHeaders map[string]string,
signingService string,
) (map[string]string, *schemas.BifrostError) {
method := http.MethodPost
if jsonData == nil {
Expand Down Expand Up @@ -113,7 +125,7 @@ func SignMantleV4Headers(
RoleSessionName: key.BedrockMantleKeyConfig.RoleSessionName,
}
}
if bifrostErr := signAWSRequest(ctx, req, keyCfg, region, bedrockMantleSigningService); bifrostErr != nil {
if bifrostErr := signAWSRequest(ctx, req, keyCfg, region, signingService); bifrostErr != nil {
return nil, bifrostErr
}
// Return the headers exactly as signed: signAWSRequest defaults an empty Accept/Content-Type
Expand Down Expand Up @@ -141,6 +153,7 @@ func (provider *BedrockProvider) mantleChatCompletions(
) (*schemas.BifrostChatResponse, *schemas.BifrostError) {
region := resolveBedrockRegion(ctx, key, request.Model)
url := mantleOpenAIURL(bedrockEndpoints(key.BedrockKeyConfig), region, schemas.ResolveCanonicalModel(ctx, request.Model), "chat/completions")
_, request.Model = parseBedrockRegionAndModel(request.Model)

// SigV4 (empty key value): sign the exact body the handler builds via a signer closure.
// Bearer (key has a value): no signer; auth flows through the Authorization header.
Expand Down Expand Up @@ -179,6 +192,7 @@ func (provider *BedrockProvider) mantleChatCompletionsStream(
) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) {
region := resolveBedrockRegion(ctx, key, request.Model)
url := mantleOpenAIURL(bedrockEndpoints(key.BedrockKeyConfig), region, schemas.ResolveCanonicalModel(ctx, request.Model), "chat/completions")
_, request.Model = parseBedrockRegionAndModel(request.Model)

// SigV4 (empty key value): sign the exact body the handler builds via a signer closure.
// Bearer (key has a value): no signer; auth flows through the Authorization header.
Expand Down Expand Up @@ -225,6 +239,7 @@ func (provider *BedrockProvider) mantleResponses(

region := resolveBedrockRegion(ctx, key, request.Model)
url := mantleOpenAIURL(bedrockEndpoints(key.BedrockKeyConfig), region, canonicalModel, "responses")
_, request.Model = parseBedrockRegionAndModel(request.Model)

// SigV4 (empty key value): sign the exact body the handler builds via a signer closure.
// Bearer (key has a value): no signer; auth flows through the Authorization header.
Expand Down Expand Up @@ -269,6 +284,7 @@ func (provider *BedrockProvider) mantleResponsesStream(

region := resolveBedrockRegion(ctx, key, request.Model)
url := mantleOpenAIURL(bedrockEndpoints(key.BedrockKeyConfig), region, canonicalModel, "responses")
_, request.Model = parseBedrockRegionAndModel(request.Model)

// SigV4 (empty key value): sign the exact body the handler builds via a signer closure.
// Bearer (key has a value): no signer; auth flows through the Authorization header.
Expand Down
35 changes: 35 additions & 0 deletions core/providers/bedrock/mantle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ func TestMantleOpenAIURL(t *testing.T) {
"https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions"},
{"grok uses openai/v1", "us-east-1", "xai.grok-4.3", "responses",
"https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses"},
// Mantle answers a frontier model on exactly one path and 400s on the other:
// "model `openai.gpt-6-astra` isn't supported on this route" (verified us-west-2).
{"gpt-6 uses openai/v1", "us-west-2", "openai.gpt-6-astra", "responses",
"https://bedrock-mantle.us-west-2.api.aws/openai/v1/responses"},
{"gpt-6 chat uses openai/v1", "us-west-2", "gpt-6-astra", "chat/completions",
"https://bedrock-mantle.us-west-2.api.aws/openai/v1/chat/completions"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -75,3 +81,32 @@ func TestMantleOpenAIURL(t *testing.T) {
})
}
}

// The "{region}/" prefix is Bifrost addressing, not part of the AWS identifier:
// resolveBedrockRegion consumes it for the host and signing scope, and AWS 404s
// whatever is left ("The model 'us-west-2/openai.gpt-6-astra' does not exist").
// The OpenAI-compatible handlers put request.Model on the wire themselves, so they
// strip it after the region is resolved.
func TestParseBedrockRegionAndModelStripsForTheWire(t *testing.T) {
cases := []struct {
model string
wantRegion string
wantBare string
}{
{"us-west-2/openai.gpt-6-astra", "us-west-2", "openai.gpt-6-astra"},
{"us-gov-west-1/openai.gpt-5.6-terra", "us-gov-west-1", "openai.gpt-5.6-terra"},
{"openai.gpt-6-astra", "", "openai.gpt-6-astra"},
// A cross-region profile is dotted, not slashed, and must survive intact.
{"us.openai.gpt-5.6-terra", "", "us.openai.gpt-5.6-terra"},
// A vendor segment is not a region: only awsRegionRegex may strip.
{"openai/gpt-6-astra", "", "openai/gpt-6-astra"},
}
for _, tc := range cases {
t.Run(tc.model, func(t *testing.T) {
region, bare := parseBedrockRegionAndModel(tc.model)
if region != tc.wantRegion || bare != tc.wantBare {
t.Errorf("got (%q, %q), want (%q, %q)", region, bare, tc.wantRegion, tc.wantBare)
}
})
}
}
Loading
Loading