diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index f5641741d18..695293aaaed 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -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{}, - }, }, }, }, diff --git a/core/providers/bedrock/bedrock.go b/core/providers/bedrock/bedrock.go index 5059d3826ba..f4ca4132f6e 100644 --- a/core/providers/bedrock/bedrock.go +++ b/core/providers/bedrock/bedrock.go @@ -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( @@ -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( @@ -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( @@ -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( diff --git a/core/providers/bedrock/bedrock_test.go b/core/providers/bedrock/bedrock_test.go index 03e9b2dd72f..13a1739a6bd 100644 --- a/core/providers/bedrock/bedrock_test.go +++ b/core/providers/bedrock/bedrock_test.go @@ -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") }) } } @@ -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{ @@ -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. diff --git a/core/providers/bedrock/mantle.go b/core/providers/bedrock/mantle.go index 237c5b5244e..3531d500533 100644 --- a/core/providers/bedrock/mantle.go +++ b/core/providers/bedrock/mantle.go @@ -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) } @@ -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 { @@ -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 @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/core/providers/bedrock/mantle_test.go b/core/providers/bedrock/mantle_test.go index 44bafa3736d..b1ec0f06dfe 100644 --- a/core/providers/bedrock/mantle_test.go +++ b/core/providers/bedrock/mantle_test.go @@ -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) { @@ -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) + } + }) + } +} diff --git a/core/providers/bedrock/runtimeopenai.go b/core/providers/bedrock/runtimeopenai.go new file mode 100644 index 00000000000..a70094cefcc --- /dev/null +++ b/core/providers/bedrock/runtimeopenai.go @@ -0,0 +1,167 @@ +package bedrock + +import ( + "context" + "fmt" + + openai "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" +) + +// runtimeOpenAIURL builds the bedrock-runtime OpenAI-compatible endpoint URL for the given +// region and API path (e.g. "responses"). Only the frontier families reach this surface, and +// they all live under "openai/v1"; gpt-oss carries a bare id and so routes to mantle instead. +// Unlike the Converse paths the model is not part of the URL — it rides in the body. +func runtimeOpenAIURL(endpoints *schemas.BedrockEndpoints, region, path string) string { + return fmt.Sprintf("https://%s/openai/v1/%s", resolveBedrockHost(endpoints, bedrockServiceRuntime, region), path) +} + +// runtimeResponses handles non-streaming Responses requests on bedrock-runtime's +// OpenAI-compatible surface. Payloads and SSE follow the OpenAI Responses spec, so the shared +// OpenAI handler does the work and only the URL and SigV4 scope differ from mantle. +func (provider *BedrockProvider) runtimeResponses( + ctx *schemas.BifrostContext, + key schemas.Key, + request *schemas.BifrostResponsesRequest, +) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + region := resolveBedrockRegion(ctx, key, request.Model) + url := runtimeOpenAIURL(bedrockEndpoints(key.BedrockKeyConfig), region, "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. + var signer providerUtils.BodySigner + if key.Value.GetValue() == "" { + signer = func(body []byte) (map[string]string, *schemas.BifrostError) { + return signOpenAIV4Headers(ctx, body, url, "application/json", key, region, provider.networkConfig.ExtraHeaders, bedrockSigningService) + } + } + + // bedrock-runtime is not project-scoped, so no project header is sent here. + return openai.HandleOpenAIResponsesRequest( + ctx, + provider.mantleClient, + url, + request, + openai.BearerAuthHeader(key), + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + nil, + nil, + signer, + provider.logger, + ) +} + +// runtimeResponsesStream handles streaming Responses requests on bedrock-runtime's +// OpenAI-compatible surface. +func (provider *BedrockProvider) runtimeResponsesStream( + ctx *schemas.BifrostContext, + postHookRunner schemas.PostHookRunner, + postHookSpanFinalizer func(context.Context), + key schemas.Key, + request *schemas.BifrostResponsesRequest, +) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + region := resolveBedrockRegion(ctx, key, request.Model) + url := runtimeOpenAIURL(bedrockEndpoints(key.BedrockKeyConfig), region, "responses") + _, request.Model = parseBedrockRegionAndModel(request.Model) + + var signer providerUtils.BodySigner + if key.Value.GetValue() == "" { + signer = func(body []byte) (map[string]string, *schemas.BifrostError) { + return signOpenAIV4Headers(ctx, body, url, "text/event-stream", key, region, provider.networkConfig.ExtraHeaders, bedrockSigningService) + } + } + + return openai.HandleOpenAIResponsesStreaming( + ctx, provider.mantleStreamingClient, url, request, + openai.BearerAuthHeader(key), provider.networkConfig.ExtraHeaders, + provider.networkConfig.StreamIdleTimeoutInSeconds, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), postHookRunner, + nil, + nil, + nil, + nil, + signer, + provider.logger, + postHookSpanFinalizer, + ) +} + +// runtimeChatCompletions handles non-streaming chat requests on bedrock-runtime's +// OpenAI-compatible surface. Reached only when the operator opts in. +func (provider *BedrockProvider) runtimeChatCompletions( + ctx *schemas.BifrostContext, + key schemas.Key, + request *schemas.BifrostChatRequest, +) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + region := resolveBedrockRegion(ctx, key, request.Model) + url := runtimeOpenAIURL(bedrockEndpoints(key.BedrockKeyConfig), region, "chat/completions") + _, request.Model = parseBedrockRegionAndModel(request.Model) + + var signer providerUtils.BodySigner + if key.Value.GetValue() == "" { + signer = func(body []byte) (map[string]string, *schemas.BifrostError) { + return signOpenAIV4Headers(ctx, body, url, "application/json", key, region, provider.networkConfig.ExtraHeaders, bedrockSigningService) + } + } + + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.mantleClient, + url, + request, + openai.BearerAuthHeader(key), + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + nil, + nil, + signer, + provider.logger, + ) +} + +// runtimeChatCompletionsStream handles streaming chat requests on bedrock-runtime's +// OpenAI-compatible surface. +func (provider *BedrockProvider) runtimeChatCompletionsStream( + ctx *schemas.BifrostContext, + postHookRunner schemas.PostHookRunner, + postHookSpanFinalizer func(context.Context), + key schemas.Key, + request *schemas.BifrostChatRequest, +) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + region := resolveBedrockRegion(ctx, key, request.Model) + url := runtimeOpenAIURL(bedrockEndpoints(key.BedrockKeyConfig), region, "chat/completions") + _, request.Model = parseBedrockRegionAndModel(request.Model) + + var signer providerUtils.BodySigner + if key.Value.GetValue() == "" { + signer = func(body []byte) (map[string]string, *schemas.BifrostError) { + return signOpenAIV4Headers(ctx, body, url, "text/event-stream", key, region, provider.networkConfig.ExtraHeaders, bedrockSigningService) + } + } + + return openai.HandleOpenAIChatCompletionStreaming( + ctx, provider.mantleStreamingClient, url, request, + openai.BearerAuthHeader(key), provider.networkConfig.ExtraHeaders, + provider.networkConfig.StreamIdleTimeoutInSeconds, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), postHookRunner, + nil, + nil, + nil, + nil, + nil, + signer, + provider.logger, + postHookSpanFinalizer, + ) +} diff --git a/core/providers/bedrock/surface.go b/core/providers/bedrock/surface.go index 01346860a28..3e664357258 100644 --- a/core/providers/bedrock/surface.go +++ b/core/providers/bedrock/surface.go @@ -2,6 +2,7 @@ package bedrock import ( "regexp" + "slices" "strings" schemas "github.com/maximhq/bifrost/core/schemas" @@ -168,11 +169,58 @@ func resolveBedrockSurface(ctx *schemas.BifrostContext, key schemas.Key, model s return bedrockSurface{host: bedrockServiceRuntime, reason: reasonModelFamilyFallback} } -// routesToMantle resolves the surface and logs the deciding rule. -func (provider *BedrockProvider) routesToMantle(ctx *schemas.BifrostContext, key schemas.Key, model string) bool { +// ResolveUseOpenAIEndpoints reports whether this key or alias routes Bedrock inference +// through the OpenAI-compatible endpoints instead of Converse. Opt-in, and an alias value +// wins over the key, mirroring ResolveUseAnthropicEndpoints. +// +// Opt-in rather than automatic because the two surfaces are not interchangeable. Converse +// carries Bedrock Guardrails, performanceConfig and requestMetadata, all of which the +// OpenAI-compatible endpoints accept and silently ignore, so diverting on Bifrost's own +// initiative could stop a guardrail being enforced with no error anywhere. +func ResolveUseOpenAIEndpoints(ctx *schemas.BifrostContext, key schemas.Key) bool { + if ra := schemas.GetResolvedAlias(ctx); ra != nil && ra.Config != nil && ra.Config.UseOpenAIEndpoints != nil { + return *ra.Config.UseOpenAIEndpoints + } + return key.UseOpenAIEndpoints != nil && *key.UseOpenAIEndpoints +} + +// runtimeServesOpenAIAPI reports whether a runtime-bound request should use +// bedrock-runtime's OpenAI-compatible surface for the given wire API. +// +// What the opt-in buys: Converse holds no conversation state and has no +// previous_response_id, so it silently drops the reference. A stateful client sends only +// the new turn and the model never sees the rest; with tools that fails outright, since +// the tool result arrives with its toolUse left behind in state. +// +// The datasheet decides support when it publishes a runtime row; otherwise family +// detection, since AWS 404s every other family here ("doesn't support this API"). Support +// is a separate question from the flag: opting in never forces a surface the model cannot +// serve. +func runtimeServesOpenAIAPI(ctx *schemas.BifrostContext, key schemas.Key, surface bedrockSurface, model string, api schemas.BedrockAPI) bool { + if !ResolveUseOpenAIEndpoints(ctx, key) { + return false + } + // An application inference profile is Converse-only, so it must never divert. + if surface.isMantle() || surface.reason == reasonApplicationProfile { + return false + } + canonical := schemas.ResolveCanonicalModel(ctx, model) + if apis := schemas.ResolveModelCaps(schemas.Bedrock, canonical).BedrockAPIs(); len(apis) > 0 { + return slices.Contains(apis, api) + } + return schemas.IsOpenAIModelFamily(ctx, canonical) || schemas.IsGrokModel(canonical) +} + +// resolveSurface resolves the surface and logs the deciding rule. +func (provider *BedrockProvider) resolveSurface(ctx *schemas.BifrostContext, key schemas.Key, model string) bedrockSurface { surface := resolveBedrockSurface(ctx, key, model) if provider.logger != nil { provider.logger.Debug("bedrock: model %q routed to %s (%s)", model, surface.host, surface.reason) } - return surface.isMantle() + return surface +} + +// 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() } diff --git a/core/providers/bedrock/surface_test.go b/core/providers/bedrock/surface_test.go index a07fbe21170..7db318d2bcb 100644 --- a/core/providers/bedrock/surface_test.go +++ b/core/providers/bedrock/surface_test.go @@ -594,3 +594,177 @@ func TestIsAIPResourceID(t *testing.T) { } } } + +// Nothing is published for bedrock_apis on the runtime row yet, so family +// detection is what actually gates the OpenAI-compatible Responses surface +// today. AWS 404s every other family there. +func TestRuntimeServesResponsesFamilyFallback(t *testing.T) { + optedIn := schemas.Key{UseOpenAIEndpoints: schemas.Ptr(true)} + cases := []struct { + model string + want bool + }{ + {"us.openai.gpt-5.6-terra", true}, + {"global.openai.gpt-5.6-luna", true}, + {"global.xai.grok-4.6", true}, + {"us.anthropic.claude-sonnet-4-6", false}, + {"us.deepseek.r1-v1:0", false}, + {"amazon.nova-pro-v1:0", false}, + } + for _, tc := range cases { + t.Run(tc.model, func(t *testing.T) { + ctx := surfaceTestCtx() + surface := resolveBedrockSurface(ctx, optedIn, tc.model) + if got := runtimeServesOpenAIAPI(ctx, optedIn, surface, tc.model, schemas.BedrockAPIResponses); got != tc.want { + t.Errorf("runtimeServesOpenAIAPI(%q) = %v, want %v", tc.model, got, tc.want) + } + }) + } +} + +// A published runtime row is authoritative in both directions: it can divert a +// model family detection would not, and hold back one it would. +func TestRuntimeServesResponsesDatasheetWinsOverFamily(t *testing.T) { + optedIn := schemas.Key{UseOpenAIEndpoints: schemas.Ptr(true)} + installCaps(t, map[schemas.ModelProvider]map[string][]schemas.BedrockAPI{ + schemas.Bedrock: { + // Converse-only despite being OpenAI family. + "us.openai.gpt-5.6-terra": {schemas.BedrockAPIConverse}, + // Serves Responses despite not being a family we would divert. + "us.anthropic.claude-sonnet-4-6": {schemas.BedrockAPIConverse, schemas.BedrockAPIResponses}, + }, + }) + cases := []struct { + model string + want bool + }{ + {"us.openai.gpt-5.6-terra", false}, + {"us.anthropic.claude-sonnet-4-6", true}, + } + for _, tc := range cases { + t.Run(tc.model, func(t *testing.T) { + ctx := surfaceTestCtx() + surface := resolveBedrockSurface(ctx, optedIn, tc.model) + if got := runtimeServesOpenAIAPI(ctx, optedIn, surface, tc.model, schemas.BedrockAPIResponses); got != tc.want { + t.Errorf("runtimeServesOpenAIAPI(%q) = %v, want %v", tc.model, got, tc.want) + } + }) + } +} + +// An application inference profile is Converse-only. The wire id carries no +// family, so without the guard the alias chain would resolve it to an OpenAI +// name and divert a request AWS cannot serve. +func TestRuntimeServesResponsesNeverDivertsApplicationProfile(t *testing.T) { + optedIn := keyWithARN(appProfileARN) + optedIn.UseOpenAIEndpoints = schemas.Ptr(true) + ctx := withAlias("my-gpt", "3dnkdwuaalc7", appProfileARN) + name := "gpt-5.6-luna" + schemas.GetResolvedAlias(ctx).Config.ModelName = &name + + surface := resolveBedrockSurface(ctx, optedIn, "3dnkdwuaalc7") + if surface.reason != reasonApplicationProfile { + t.Fatalf("precondition: reason = %q, want %q", surface.reason, reasonApplicationProfile) + } + if runtimeServesOpenAIAPI(ctx, optedIn, surface, "3dnkdwuaalc7", schemas.BedrockAPIResponses) { + t.Error("an application inference profile must stay on Converse") + } +} + +// Mantle has its own Responses path; the runtime surface must never claim it. +func TestRuntimeServesResponsesIgnoresMantleSurface(t *testing.T) { + optedIn := schemas.Key{UseOpenAIEndpoints: schemas.Ptr(true)} + ctx := surfaceTestCtx() + surface := resolveBedrockSurface(ctx, optedIn, "openai.gpt-5.6-terra") + if !surface.isMantle() { + t.Fatalf("precondition: bare id should route to mantle, got %q", surface.host) + } + if runtimeServesOpenAIAPI(ctx, optedIn, surface, "openai.gpt-5.6-terra", schemas.BedrockAPIResponses) { + t.Error("a mantle-bound request must not divert to the runtime surface") + } +} + +// The gate reads the canonical name, so an alias whose wire id carries no family +// still diverts. +func TestRuntimeServesResponsesResolvesAliasedModel(t *testing.T) { + optedIn := schemas.Key{UseOpenAIEndpoints: schemas.Ptr(true)} + ctx := withAlias("my-gpt", "us.openai.gpt-5.6-terra", "") + name := "gpt-5.6-terra" + schemas.GetResolvedAlias(ctx).Config.ModelName = &name + + surface := resolveBedrockSurface(ctx, optedIn, "us.openai.gpt-5.6-terra") + if !runtimeServesOpenAIAPI(ctx, optedIn, surface, "us.openai.gpt-5.6-terra", schemas.BedrockAPIResponses) { + t.Error("aliased OpenAI model must divert to the runtime Responses surface") + } +} + +func TestRuntimeOpenAIURL(t *testing.T) { + if got := runtimeOpenAIURL(nil, "us-east-1", "responses"); got != "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1/responses" { + t.Errorf("runtimeOpenAIURL = %q", got) + } +} + +// Opt-in, two states, mirroring use_anthropic_endpoints: off keeps everything on +// Converse, on moves both request types to the OpenAI-compatible surface. +func TestUseOpenAIEndpointsFlag(t *testing.T) { + const model = "us.openai.gpt-5.6-terra" + cases := []struct { + name string + flag *bool + want bool + }{ + {"unset", nil, false}, + {"false", schemas.Ptr(false), false}, + {"true", schemas.Ptr(true), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := surfaceTestCtx() + key := schemas.Key{UseOpenAIEndpoints: tc.flag} + surface := resolveBedrockSurface(ctx, key, model) + for _, api := range []schemas.BedrockAPI{schemas.BedrockAPIResponses, schemas.BedrockAPIChatCompletions} { + if got := runtimeServesOpenAIAPI(ctx, key, surface, model, api); got != tc.want { + t.Errorf("%s = %v, want %v", api, got, tc.want) + } + } + }) + } +} + +// An alias-level value wins over the key, matching use_anthropic_endpoints. +func TestUseOpenAIEndpointsAliasOverridesKey(t *testing.T) { + const model = "us.openai.gpt-5.6-terra" + ctx := withAlias("my-gpt", model, "") + schemas.GetResolvedAlias(ctx).Config.UseOpenAIEndpoints = schemas.Ptr(false) + + key := schemas.Key{UseOpenAIEndpoints: schemas.Ptr(true)} + surface := resolveBedrockSurface(ctx, key, model) + if runtimeServesOpenAIAPI(ctx, key, surface, model, schemas.BedrockAPIResponses) { + t.Error("alias false must override key true") + } +} + +// Opting in cannot force a surface the model does not serve: AWS 404s Claude there. +func TestUseOpenAIEndpointsCannotForceUnsupportedModel(t *testing.T) { + const model = "us.anthropic.claude-sonnet-4-6" + key := schemas.Key{UseOpenAIEndpoints: schemas.Ptr(true)} + ctx := surfaceTestCtx() + surface := resolveBedrockSurface(ctx, key, model) + if runtimeServesOpenAIAPI(ctx, key, surface, model, schemas.BedrockAPIResponses) { + t.Error("claude must stay on converse even when opted in") + } +} + +// Nor can it override the application-inference-profile guard. +func TestUseOpenAIEndpointsCannotForceApplicationProfile(t *testing.T) { + ctx := withAlias("my-gpt", "3dnkdwuaalc7", appProfileARN) + name := "gpt-5.6-luna" + schemas.GetResolvedAlias(ctx).Config.ModelName = &name + + key := keyWithARN(appProfileARN) + key.UseOpenAIEndpoints = schemas.Ptr(true) + surface := resolveBedrockSurface(ctx, key, "3dnkdwuaalc7") + if runtimeServesOpenAIAPI(ctx, key, surface, "3dnkdwuaalc7", schemas.BedrockAPIResponses) { + t.Error("an application inference profile must stay on Converse even when opted in") + } +} diff --git a/core/providers/bedrock/toolresultdocument_test.go b/core/providers/bedrock/toolresultdocument_test.go index 9b69d13909c..976763be18f 100644 --- a/core/providers/bedrock/toolresultdocument_test.go +++ b/core/providers/bedrock/toolresultdocument_test.go @@ -2,6 +2,7 @@ package bedrock import ( "context" + "encoding/base64" "net/http" "net/http/httptest" "sync/atomic" @@ -170,7 +171,7 @@ func TestToolResultDocumentRejectsMissingSource(t *testing.T) { } } -func TestToolResultTextDocumentUsesSingleSourceMember(t *testing.T) { +func TestToolResultTextDocumentUsesBytesSource(t *testing.T) { tests := []struct { name string fileData string @@ -188,11 +189,12 @@ func TestToolResultTextDocumentUsesSingleSourceMember(t *testing.T) { Filename: schemas.Ptr("result.txt"), FileType: schemas.Ptr("text/plain"), }) - if document.Format != "txt" || document.Source == nil || document.Source.Text == nil || *document.Source.Text != "Hello from the tool" { - t.Fatalf("expected plain text document source, got %#v", document) + encoded := base64.StdEncoding.EncodeToString([]byte("Hello from the tool")) + if document.Format != "txt" || document.Source == nil || document.Source.Bytes == nil || *document.Source.Bytes != encoded { + t.Fatalf("expected base64 text document source, got %#v", document) } - if document.Source.Bytes != nil { - t.Fatalf("expected DocumentSource union to contain only text, got bytes %#v", document.Source.Bytes) + if document.Source.Text != nil { + t.Fatalf("expected DocumentSource union to contain only bytes, got text %#v", document.Source.Text) } }) } diff --git a/core/providers/bedrock/types.go b/core/providers/bedrock/types.go index 0b34fb2ca67..2a0c68cce7d 100644 --- a/core/providers/bedrock/types.go +++ b/core/providers/bedrock/types.go @@ -283,7 +283,7 @@ type BedrockDocumentSource struct { // See: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentSource.html type BedrockDocumentSourceData struct { Bytes *string `json:"bytes,omitempty"` // Base64-encoded document bytes - Text *string `json:"text,omitempty"` // Plain text content + Text *string `json:"text,omitempty"` // Plain text content; Converse rejects it unless the block enables citations S3Location *BedrockS3Location `json:"s3Location,omitempty"` // Optional: S3 location (model-dependent support) } diff --git a/core/providers/bedrock/utils.go b/core/providers/bedrock/utils.go index 72a31014e2a..243df7a1675 100644 --- a/core/providers/bedrock/utils.go +++ b/core/providers/bedrock/utils.go @@ -366,13 +366,11 @@ func materializeBedrockDocument( return nil, fmt.Errorf("unsupported Bedrock document format %q", *fileType) } } - dataURLIsText := false if isDataURL { dataURLFormat, sourceIsText, matched := bedrockDocumentFormat(dataURLMediaType) if !matched && !isOpaqueBedrockDocumentType(dataURLMediaType) { return nil, fmt.Errorf("unsupported Bedrock document format %q", dataURLMediaType) } - dataURLIsText = sourceIsText if format == "" && matched { format, isText = dataURLFormat, sourceIsText } @@ -432,16 +430,7 @@ func materializeBedrockDocument( if isDataURL { if dataURLIsBase64 { - if dataURLIsText { - decoded, err := base64.StdEncoding.DecodeString(dataURLPayload) - if err != nil { - return nil, fmt.Errorf("invalid base64 document data URL: %w", err) - } - text := string(decoded) - document.Source.Text = &text - } else { - document.Source.Bytes = &dataURLPayload - } + document.Source.Bytes = &dataURLPayload return document, nil } @@ -449,17 +438,15 @@ func materializeBedrockDocument( if err != nil { return nil, fmt.Errorf("invalid percent-encoded document data URL payload: %w", err) } - if dataURLIsText { - document.Source.Text = &decoded - } else { - encoded := base64.StdEncoding.EncodeToString([]byte(decoded)) - document.Source.Bytes = &encoded - } + encoded := base64.StdEncoding.EncodeToString([]byte(decoded)) + document.Source.Bytes = &encoded return document, nil } + // Text file_data arrives as literal text; binary file_data is already base64. if isText { - document.Source.Text = fileData + encoded := base64.StdEncoding.EncodeToString([]byte(*fileData)) + document.Source.Bytes = &encoded } else { document.Source.Bytes = fileData } diff --git a/core/providers/bedrockmantle/bedrockmantle.go b/core/providers/bedrockmantle/bedrockmantle.go index fc34b30ecc5..fe54fe028e9 100644 --- a/core/providers/bedrockmantle/bedrockmantle.go +++ b/core/providers/bedrockmantle/bedrockmantle.go @@ -10,7 +10,6 @@ import ( "context" "fmt" "maps" - "strings" "time" "github.com/maximhq/bifrost/core/providers/anthropic" @@ -79,13 +78,10 @@ const defaultMantleRegion = "us-east-1" // region, model, and API path (e.g. "chat/completions", "responses"). The native-Anthropic // path is built separately by mantleAnthropicURL. 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. +// 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", mantleHost(endpoints, region), base, path) } @@ -213,6 +209,7 @@ func (provider *BedrockMantleProvider) ChatCompletion(ctx *schemas.BifrostContex } url := mantleOpenAIURL(mantleEndpoints(key.BedrockMantleKeyConfig), region, schemas.ResolveCanonicalModel(ctx, request.Model), "chat/completions") + _, request.Model = parseBedrockRegionAndModel(request.Model) return openai.HandleOpenAIChatCompletionRequest( ctx, provider.mantleClient, @@ -274,6 +271,7 @@ func (provider *BedrockMantleProvider) ChatCompletionStream(ctx *schemas.Bifrost } url := mantleOpenAIURL(mantleEndpoints(key.BedrockMantleKeyConfig), region, schemas.ResolveCanonicalModel(ctx, request.Model), "chat/completions") + _, request.Model = parseBedrockRegionAndModel(request.Model) return openai.HandleOpenAIChatCompletionStreaming( ctx, provider.mantleStreamingClient, url, request, openai.BearerAuthHeader(key), bedrock.WithMantleProject(provider.networkConfig.ExtraHeaders, bedrock.MantleOpenAIProjectHeader, resolveProjectID(ctx, key)), @@ -328,6 +326,7 @@ func (provider *BedrockMantleProvider) Responses(ctx *schemas.BifrostContext, ke } url := mantleOpenAIURL(mantleEndpoints(key.BedrockMantleKeyConfig), region, canonicalModel, "responses") + _, request.Model = parseBedrockRegionAndModel(request.Model) return openai.HandleOpenAIResponsesRequest( ctx, provider.mantleClient, @@ -395,6 +394,7 @@ func (provider *BedrockMantleProvider) ResponsesStream(ctx *schemas.BifrostConte } url := mantleOpenAIURL(mantleEndpoints(key.BedrockMantleKeyConfig), region, canonicalModel, "responses") + _, request.Model = parseBedrockRegionAndModel(request.Model) return openai.HandleOpenAIResponsesStreaming( ctx, provider.mantleStreamingClient, url, request, openai.BearerAuthHeader(key), bedrock.WithMantleProject(provider.networkConfig.ExtraHeaders, bedrock.MantleOpenAIProjectHeader, resolveProjectID(ctx, key)), diff --git a/core/providers/openai/responses.go b/core/providers/openai/responses.go index 09834c0cb4d..7b55e716ae0 100644 --- a/core/providers/openai/responses.go +++ b/core/providers/openai/responses.go @@ -153,7 +153,9 @@ const maxResponsesCacheBreakpoints = 4 // per-block cache_control through /v1/responses and converts a breakpoint back into // an Anthropic one (#6290). OpenAI defined the field for gpt-5.6, where it pairs with // request-level prompt_cache_options; Azure and Bedrock Mantle serve the same models -// through the same wire format, so they inherit it (#6180). +// through the same wire format, so they inherit it (#6180). Bedrock is listed for the +// same reason: its OpenAI-compatible surfaces on both hosts speak that wire format, and +// a request there reports the bedrock key rather than bedrock_mantle. // // Everything else either accepts cache_control directly or caches implicitly, and for // those the serializer's existing strip is the correct behaviour. @@ -161,7 +163,7 @@ func responsesUsesPromptCacheBreakpoints(provider schemas.ModelProvider, model s switch provider { case schemas.OpenRouter: return true - case schemas.OpenAI, schemas.Azure, schemas.BedrockMantle: + case schemas.OpenAI, schemas.Azure, schemas.BedrockMantle, schemas.Bedrock: return schemas.IsGPT56Model(model) default: return false @@ -195,7 +197,7 @@ func responsesHasPromptCacheBreakpoint(messages []schemas.ResponsesMessage) bool // that off; mode=explicit does. OpenRouter has no equivalent field and needs none. func responsesUsesPromptCacheOptions(provider schemas.ModelProvider, model string) bool { switch provider { - case schemas.OpenAI, schemas.Azure, schemas.BedrockMantle: + case schemas.OpenAI, schemas.Azure, schemas.BedrockMantle, schemas.Bedrock: return schemas.IsGPT56Model(model) default: return false @@ -607,6 +609,21 @@ func ToOpenAIResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.B req.ResponsesParameters.Reasoning.Summary = nil } + // Bedrock's OpenAI-compatible surfaces accept only "auto". They answer + // "concise" and "detailed" with a 400 ("Unsupported parameter: + // 'reasoning.summary' is not supported with the ... model") even though the + // schema advertises all three, on both bedrock-runtime and bedrock-mantle. + // The Anthropic converter pins "detailed" for every Claude Code request, so + // without this each such session fails on its first message. Narrow to a + // summary the target can serve rather than dropping it: the caller asked to + // see reasoning, and "auto" is the only way to say yes here. + if summary := req.ResponsesParameters.Reasoning.Summary; summary != nil && *summary != "auto" { + switch schemas.ResolveBaseProvider(ctx, bifrostReq.Provider) { + case schemas.Bedrock, schemas.BedrockMantle: + req.ResponsesParameters.Reasoning.Summary = schemas.Ptr("auto") + } + } + // Handle xAI-specific parameter filtering // Strip reasoning_effort only for the models known to reject it; current-generation // models (grok-4.5, grok-4.6, grok-4.20-*) accept it. diff --git a/core/providers/openai/utils.go b/core/providers/openai/utils.go index ad2a3c7f4f0..b4d69ad4f1c 100644 --- a/core/providers/openai/utils.go +++ b/core/providers/openai/utils.go @@ -23,8 +23,8 @@ func defaultSupportsReasoningContentBlocks(model string) bool { } // IsOpenAIReasoningModel matches OpenAI-family models that accept -// reasoning.effort: the o1/o3/o4 series, GPT-5.x, and gpt-oss. Broader than -// isOSeriesModel, which covers the o-series alone. +// reasoning.effort: the o1/o3/o4 series, GPT-5.x, GPT-6.x, and gpt-oss. Broader +// than isOSeriesModel, which covers the o-series alone. func IsOpenAIReasoningModel(model string) bool { _, parsedModel := schemas.ParseModelString(model, schemas.OpenAI) if parsedModel != "" { @@ -49,7 +49,7 @@ func IsOpenAIReasoningModel(model string) bool { return true } } - return strings.Contains(modelLower, "gpt-5") + return strings.Contains(modelLower, "gpt-5") || strings.Contains(modelLower, "gpt-6") } // defaultEffortControl widens the base low/medium/high ladder with the effort @@ -86,7 +86,8 @@ func acceptsXHighEffort(model string) bool { strings.Contains(modelLower, "gpt-5.3-codex") || strings.Contains(modelLower, "gpt-5.4") || strings.Contains(modelLower, "gpt-5.5") || - strings.Contains(modelLower, "gpt-5.6") + strings.Contains(modelLower, "gpt-5.6") || + strings.Contains(modelLower, "gpt-6") } // acceptsMinimalEffort reports models that natively accept "minimal" effort: @@ -113,7 +114,7 @@ func acceptsMinimalEffort(model string) bool { func acceptsMaxEffort(model string) bool { modelLower := bareModelLower(model) return strings.Contains(modelLower, "gpt-5.6") || - strings.Contains(modelLower, "gpt-6-astra") || + strings.Contains(modelLower, "gpt-6") || strings.Contains(modelLower, "deepseek-v4") || strings.Contains(modelLower, "glm-5.2") } @@ -127,7 +128,6 @@ func bareModelLower(model string) string { return strings.ToLower(model) } - func ConvertOpenAIMessagesToBifrostMessages(messages []OpenAIMessage) []schemas.ChatMessage { bifrostMessages := make([]schemas.ChatMessage, len(messages)) for i, message := range messages { diff --git a/core/schemas/account.go b/core/schemas/account.go index 02a58b17485..cdbf5f03134 100644 --- a/core/schemas/account.go +++ b/core/schemas/account.go @@ -144,6 +144,7 @@ type Key struct { Enabled *bool `json:"enabled,omitempty"` // Whether the key is active (default:true) UseForBatchAPI *bool `json:"use_for_batch_api,omitempty"` // Whether this key can be used for batch API operations (default:false for new keys, migrated keys default to true) UseAnthropicEndpoints *bool `json:"use_anthropic_endpoints,omitempty"` // Whether to use anthropic endpoints for this key + UseOpenAIEndpoints *bool `json:"use_openai_endpoints,omitempty"` // Whether to use OpenAI-compatible endpoints for this key ConfigHash string `json:"config_hash,omitempty"` // Hash of config.json version, used for change detection Status KeyStatusType `json:"status,omitempty"` // Status of key Description string `json:"description,omitempty"` // Description of key @@ -234,6 +235,7 @@ type AliasConfig struct { // a field name shared by multiple same-depth anonymous structs. ProjectID *SecretVar `json:"project_id,omitempty"` UseAnthropicEndpoints *bool `json:"use_anthropic_endpoints,omitempty"` // Whether to use anthropic endpoints for this alias + UseOpenAIEndpoints *bool `json:"use_openai_endpoints,omitempty"` // Whether to use OpenAI-compatible endpoints for this alias *AzureAliasCfg *VertexAliasCfg @@ -252,6 +254,7 @@ func (ac AliasConfig) isLegacyShape() bool { ac.Region == nil && ac.ProjectID == nil && ac.UseAnthropicEndpoints == nil && + ac.UseOpenAIEndpoints == nil && ac.AzureAliasCfg == nil && ac.VertexAliasCfg == nil && ac.BedrockAliasCfg == nil && diff --git a/core/schemas/modelcapabilities.go b/core/schemas/modelcapabilities.go index 9a1c31fd0ea..6491dc3b5ef 100644 --- a/core/schemas/modelcapabilities.go +++ b/core/schemas/modelcapabilities.go @@ -277,6 +277,14 @@ type ModelCapabilities struct { // Absent or unrecognised falls back to the caller's family detection. BedrockReasoningShape BedrockReasoningShape `json:"bedrock_reasoning_shape,omitempty"` + // Base path Bedrock Mantle serves this model's OpenAI-compatible APIs on. + // Mantle answers a model on exactly one of its two paths and 400s on the + // other ("isn't supported on this route"), so a new closed generation that + // nothing names falls through to the wrong one. + // + // Absent or unrecognised falls back to the caller's family detection. + BedrockMantleBasePath BedrockMantleBasePath `json:"bedrock_mantle_base_path,omitempty"` + // Whether this model verifies the signature on every reasoningText block it // is handed back on Bedrock Converse. Claude does: a thinking block with no // signature is rejected in every serialisation (field absent gives @@ -347,6 +355,31 @@ func (s BedrockReasoningShape) IsValid() bool { return slices.Contains(BedrockReasoningShapeValues, s) } +// BedrockMantleBasePath names the URL base path Bedrock Mantle serves a model's +// OpenAI-compatible APIs on. The two are mutually exclusive: the open-weight +// families answer on the bare path and the closed frontier ones on "openai/v1". +type BedrockMantleBasePath string + +const ( + // https://bedrock-mantle.{region}.api.aws/v1/... — gpt-oss, Gemma 3. + BedrockMantleBasePathV1 BedrockMantleBasePath = "v1" + + // https://bedrock-mantle.{region}.api.aws/openai/v1/... — closed gpt-5.x and + // gpt-6.x, Gemma 4, Grok. + BedrockMantleBasePathOpenAIV1 BedrockMantleBasePath = "openai/v1" +) + +// BedrockMantleBasePathValues lists every recognised BedrockMantleBasePath. +var BedrockMantleBasePathValues = []BedrockMantleBasePath{ + BedrockMantleBasePathV1, + BedrockMantleBasePathOpenAIV1, +} + +// IsValid reports whether p is a base path this binary knows how to build. +func (p BedrockMantleBasePath) IsValid() bool { + return slices.Contains(BedrockMantleBasePathValues, p) +} + // ModelParameterDescriptor is one entry of the datasheet's model_parameters // array. Only ID is modelled: the remaining keys (label, helpText, type, // default, range) describe how the prompt playground renders the control and diff --git a/core/schemas/modelcapabilities_test.go b/core/schemas/modelcapabilities_test.go index 87c6f322f97..2e6795eed80 100644 --- a/core/schemas/modelcapabilities_test.go +++ b/core/schemas/modelcapabilities_test.go @@ -322,3 +322,56 @@ func TestResolveModelCaps_NormalizesRepeatedProviderPrefix(t *testing.T) { }) } } + +// The base-path split is not a preference: mantle answers a model on exactly one +// of its two paths and 400s on the other, so every closed generation has to be +// named. The datasheet leads so a new one is a published row, not a release. +func TestResolveBedrockMantleBasePath(t *testing.T) { + t.Run("FamilyFallback", func(t *testing.T) { + cases := []struct { + model string + want BedrockMantleBasePath + }{ + {"openai.gpt-5.6-terra", BedrockMantleBasePathOpenAIV1}, + {"openai.gpt-6-astra", BedrockMantleBasePathOpenAIV1}, + {"gpt-6-astra", BedrockMantleBasePathOpenAIV1}, + {"google.gemma-4-31b", BedrockMantleBasePathOpenAIV1}, + {"xai.grok-4.3", BedrockMantleBasePathOpenAIV1}, + {"openai.gpt-oss-120b", BedrockMantleBasePathV1}, + {"openai.gpt-oss-safeguard-120b", BedrockMantleBasePathV1}, + {"google.gemma-3-12b-it", BedrockMantleBasePathV1}, + {"deepseek.v3.2", BedrockMantleBasePathV1}, + } + for _, tc := range cases { + if got := ResolveBedrockMantleBasePath(tc.model); got != tc.want { + t.Errorf("ResolveBedrockMantleBasePath(%q) = %q, want %q", tc.model, got, tc.want) + } + } + }) + + // Both directions, so a published row can correct the fallback either way. + t.Run("DatasheetWinsOverFamily", func(t *testing.T) { + model := "openai.gpt-oss-120b" // fallback says v1 + setCapabilityOverride(t, model, ModelCapabilities{BedrockMantleBasePath: BedrockMantleBasePathOpenAIV1}) + if got := ResolveBedrockMantleBasePath(model); got != BedrockMantleBasePathOpenAIV1 { + t.Errorf("datasheet should promote to openai/v1, got %q", got) + } + }) + + t.Run("DatasheetCanDemote", func(t *testing.T) { + model := "openai.gpt-6-astra" // fallback says openai/v1 + setCapabilityOverride(t, model, ModelCapabilities{BedrockMantleBasePath: BedrockMantleBasePathV1}) + if got := ResolveBedrockMantleBasePath(model); got != BedrockMantleBasePathV1 { + t.Errorf("datasheet should demote to v1, got %q", got) + } + }) + + // A value shipped ahead of this binary must not win; the fallback still answers. + t.Run("UnrecognisedValueFallsBack", func(t *testing.T) { + model := "openai.gpt-6-astra" + setCapabilityOverride(t, model, ModelCapabilities{BedrockMantleBasePath: BedrockMantleBasePath("v3")}) + if got := ResolveBedrockMantleBasePath(model); got != BedrockMantleBasePathOpenAIV1 { + t.Errorf("unrecognised value should fall back to openai/v1, got %q", got) + } + }) +} diff --git a/core/schemas/modelcaps.go b/core/schemas/modelcaps.go index 01a7ed07252..d998df5adad 100644 --- a/core/schemas/modelcaps.go +++ b/core/schemas/modelcaps.go @@ -622,6 +622,16 @@ func (c ModelCaps) BedrockReasoningShape(fallback BedrockReasoningShape) Bedrock return fallback } +// BedrockMantleBasePath reports the URL base path Bedrock Mantle serves this +// model's OpenAI-compatible APIs on. Falls back to the caller's name-based answer +// when the row says nothing or publishes a value this binary does not recognise. +func (c ModelCaps) BedrockMantleBasePath(fallback BedrockMantleBasePath) BedrockMantleBasePath { + if c.record != nil && c.record.BedrockMantleBasePath.IsValid() { + return c.record.BedrockMantleBasePath + } + return fallback +} + // BedrockRequiresSignedReasoning reports whether the (provider, model) pair // verifies reasoning signatures on Converse, so an unsigned reasoningText block // cannot be replayed to it. Falls back to the caller's name-based answer when diff --git a/core/schemas/utils.go b/core/schemas/utils.go index bbb35b62777..3d6436ab1cf 100644 --- a/core/schemas/utils.go +++ b/core/schemas/utils.go @@ -1795,6 +1795,28 @@ func BedrockModelSupportsCachePoints(model string) bool { return IsAnthropicModel(model) || IsNovaModel(model) } +// ResolveBedrockMantleBasePath returns the URL base path Bedrock Mantle serves the +// model's OpenAI-compatible APIs on, preferring the datasheet and falling back to +// family detection. +// +// Mantle answers a model on exactly one of its two paths and 400s on the other +// ("model `openai.gpt-6-astra` isn't supported on this route"), so the fallback has +// to name every closed generation explicitly: one that nothing matches drops to the +// bare path the open-weight families use and fails outright. That is why the +// datasheet leads — a new generation becomes a published row rather than a release. +// +// Takes the canonical (capability-resolved) model; the request body still carries +// the wire model. +func ResolveBedrockMantleBasePath(model string) BedrockMantleBasePath { + fallback := BedrockMantleBasePathV1 + lower := strings.ToLower(model) + if strings.Contains(lower, "gpt-5") || strings.Contains(lower, "gpt-6") || + strings.Contains(lower, "gemma-4") || IsGrokModel(model) { + fallback = BedrockMantleBasePathOpenAIV1 + } + return ResolveModelCaps(BedrockMantle, model).BedrockMantleBasePath(fallback) +} + // ModelSupportsPromptCaching is the datasheet-independent fallback for // ModelCaps.SupportsPromptCaching. It answers the narrower question the breakpoint // injector needs: can a marker placed on a content block actually do anything here? diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 4113a25dd71..8bb7397bf05 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -570,6 +570,12 @@ func (p *ProviderConfig) Redacted() *ProviderConfig { } else { redactedConfig.Keys[i].UseAnthropicEndpoints = new(false) } + // Add back use openai endpoints + if key.UseOpenAIEndpoints != nil { + redactedConfig.Keys[i].UseOpenAIEndpoints = key.UseOpenAIEndpoints + } else { + redactedConfig.Keys[i].UseOpenAIEndpoints = new(false) + } // Add model discovery status and error redactedConfig.Keys[i].Status = key.Status diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index e2697d41028..2d3c560f9ba 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -492,6 +492,7 @@ var configstoreMigrationSteps = []migrationStep{ {IDs: []string{"backfill_vk_allow_all_providers_hash"}, run: migrationBackfillVirtualKeyAllowAllProvidersHash}, {IDs: []string{"add_prompt_cache_json_column"}, run: migrationAddPromptCacheJSONColumn}, {IDs: []string{"add_hidden_request_types_json_column"}, run: migrationAddHiddenRequestTypesJSONColumn}, + {IDs: []string{"add_use_openai_endpoints_column"}, run: migrationAddUseOpenAIEndpointsColumn}, } // videoResolutionPricingColumns are the resolution-banded video output rate columns. @@ -13493,3 +13494,31 @@ func migrationAddHiddenRequestTypesJSONColumn(ctx context.Context, db *gorm.DB, } return nil } + +// migrationAddUseOpenAIEndpointsColumn adds the use_openai_endpoints column to the config_keys table. +func migrationAddUseOpenAIEndpointsColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { + migrationName := "add_use_openai_endpoints_column" + logger.Info("[configstore] starting migration %s", migrationName) + defer logger.Info("[configstore] finished migration %s", migrationName) + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: migrationName, + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := addColumnIfNotExists(tx, logger, &tables.TableKey{}, "use_openai_endpoints"); err != nil { + return fmt.Errorf("failed to add use_openai_endpoints column: %w", err) + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := dropColumnIfExists(tx, logger, &tables.TableKey{}, "use_openai_endpoints"); err != nil { + return fmt.Errorf("failed to drop use_openai_endpoints column: %w", err) + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("failed to run migration %s: %w", migrationName, err) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index e7e02a47e49..14a3c2f4ff0 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -154,6 +154,7 @@ func schemaKeyFromTableKey(dbKey tables.TableKey) schemas.Key { Enabled: dbKey.Enabled, UseForBatchAPI: dbKey.UseForBatchAPI, UseAnthropicEndpoints: dbKey.UseAnthropicEndpoints, + UseOpenAIEndpoints: dbKey.UseOpenAIEndpoints, AzureKeyConfig: dbKey.AzureKeyConfig, VertexKeyConfig: dbKey.VertexKeyConfig, BedrockKeyConfig: dbKey.BedrockKeyConfig, @@ -185,6 +186,7 @@ func tableKeyFromSchemaKey(provider tables.TableProvider, key schemas.Key) (tabl Enabled: key.Enabled, UseForBatchAPI: key.UseForBatchAPI, UseAnthropicEndpoints: key.UseAnthropicEndpoints, + UseOpenAIEndpoints: key.UseOpenAIEndpoints, AzureKeyConfig: key.AzureKeyConfig, VertexKeyConfig: key.VertexKeyConfig, BedrockKeyConfig: key.BedrockKeyConfig, @@ -757,6 +759,7 @@ func (s *RDBConfigStore) UpdateProvidersConfig(ctx context.Context, providers ma Enabled: key.Enabled, UseForBatchAPI: key.UseForBatchAPI, UseAnthropicEndpoints: key.UseAnthropicEndpoints, + UseOpenAIEndpoints: key.UseOpenAIEndpoints, AzureKeyConfig: key.AzureKeyConfig, VertexKeyConfig: key.VertexKeyConfig, BedrockKeyConfig: key.BedrockKeyConfig, @@ -1001,6 +1004,7 @@ func (s *RDBConfigStore) UpdateProvider(ctx context.Context, provider schemas.Mo Enabled: key.Enabled, UseForBatchAPI: key.UseForBatchAPI, UseAnthropicEndpoints: key.UseAnthropicEndpoints, + UseOpenAIEndpoints: key.UseOpenAIEndpoints, AzureKeyConfig: key.AzureKeyConfig, VertexKeyConfig: key.VertexKeyConfig, BedrockKeyConfig: key.BedrockKeyConfig, @@ -1157,6 +1161,7 @@ func (s *RDBConfigStore) AddProvider(ctx context.Context, provider schemas.Model Enabled: key.Enabled, UseForBatchAPI: key.UseForBatchAPI, UseAnthropicEndpoints: key.UseAnthropicEndpoints, + UseOpenAIEndpoints: key.UseOpenAIEndpoints, AzureKeyConfig: key.AzureKeyConfig, VertexKeyConfig: key.VertexKeyConfig, BedrockKeyConfig: key.BedrockKeyConfig, @@ -9888,4 +9893,4 @@ func (s *RDBConfigStore) DeleteWebhookJob(ctx context.Context, id, runnerID stri return fmt.Errorf("webhook job not found or no longer owned by caller") } return nil -} \ No newline at end of file +} diff --git a/framework/configstore/tables/key.go b/framework/configstore/tables/key.go index 64cf527e6e7..9597ed83577 100644 --- a/framework/configstore/tables/key.go +++ b/framework/configstore/tables/key.go @@ -98,6 +98,11 @@ type TableKey struct { // endpoints instead of its OpenAI-compatible ones. UseAnthropicEndpoints *bool `gorm:"default:false" json:"use_anthropic_endpoints,omitempty"` + // UseOpenAIEndpoints routes Bedrock inference through the OpenAI-compatible endpoints + // instead of Converse. Column name is pinned: the default naming strategy does not + // split OpenAI the way the JSON tag does. + UseOpenAIEndpoints *bool `gorm:"column:use_openai_endpoints;default:false" json:"use_openai_endpoints,omitempty"` + Status string `gorm:"type:varchar(50);default:'unknown'" json:"status"` Description string `gorm:"type:text" json:"description,omitempty"` @@ -163,6 +168,10 @@ func (k *TableKey) BeforeSave(tx *gorm.DB) error { useAnthropicEndpoints := false // DB default k.UseAnthropicEndpoints = &useAnthropicEndpoints } + if k.UseOpenAIEndpoints == nil { + useOpenAIEndpoints := false // DB default + k.UseOpenAIEndpoints = &useOpenAIEndpoints + } // IMPORTANT: All *SecretVar fields assigned from provider config structs (AzureKeyConfig, // VertexKeyConfig, BedrockKeyConfig) MUST be value-copied before assignment. The caller // may retain the config struct pointer; if BeforeSave (or future encryption) mutates a @@ -865,6 +874,10 @@ func (k *TableKey) AfterFind(tx *gorm.DB) error { useAnthropicEndpoints := false // DB default k.UseAnthropicEndpoints = &useAnthropicEndpoints } + if k.UseOpenAIEndpoints == nil { + useOpenAIEndpoints := false // DB default + k.UseOpenAIEndpoints = &useOpenAIEndpoints + } // Reconstruct Azure config if fields are present if k.AzureEndpoint != nil || k.AzureClientID != nil || k.AzureClientSecret != nil || k.AzureTenantID != nil || (k.AzureScopesJSON != nil && *k.AzureScopesJSON != "") { var scopes []string diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json index b81852185c6..bf6a13973a1 100644 --- a/helm-charts/bifrost/values.schema.json +++ b/helm-charts/bifrost/values.schema.json @@ -6133,6 +6133,11 @@ "description": "For deepseek, fireworks, vllm, and sgl keys: route chat completions and responses requests through Anthropic-compatible endpoints.", "default": false }, + "use_openai_endpoints": { + "type": "boolean", + "description": "For bedrock keys: route chat completions and responses requests through Bedrock's OpenAI-compatible endpoints (/openai/v1) instead of Converse, for models that serve them. Converse is used for everything else, and Bedrock Guardrails, performanceConfig and requestMetadata apply only on Converse.", + "default": false + }, "azure_key_config": { "type": "object", "properties": { @@ -6506,6 +6511,10 @@ "use_anthropic_endpoints": { "type": "boolean", "description": "Routes chat completions and responses requests through Anthropic-compatible endpoints." + }, + "use_openai_endpoints": { + "type": "boolean", + "description": "Per-alias override of use_openai_endpoints." } }, "required": ["model_id"], diff --git a/helm-charts/bifrost/values.yaml b/helm-charts/bifrost/values.yaml index 646f110bb62..5276e74b541 100644 --- a/helm-charts/bifrost/values.yaml +++ b/helm-charts/bifrost/values.yaml @@ -347,6 +347,11 @@ bifrost: # # use_anthropic_endpoints: false # deepseek/fireworks/vllm/sgl keys only: route chat completions # # and responses through Anthropic-compatible endpoints (also # # settable per-alias alongside use_deployments_endpoint) + # # use_openai_endpoints: false # bedrock keys only: route chat completions and responses + # # through Bedrock's OpenAI-compatible endpoints (/openai/v1) + # # instead of Converse, for models that serve them. Guardrails, + # # performanceConfig and requestMetadata apply only on Converse + # # (also settable per-alias) # - name: "secondary-key" # value: "env.OPENAI_KEY" # Reference to environment variable # weight: 1 diff --git a/tests/e2e/api/HARNESS_COVERAGE_BACKLOG.md b/tests/e2e/api/HARNESS_COVERAGE_BACKLOG.md index 99842a7b17e..2546d8c6881 100644 --- a/tests/e2e/api/HARNESS_COVERAGE_BACKLOG.md +++ b/tests/e2e/api/HARNESS_COVERAGE_BACKLOG.md @@ -182,7 +182,7 @@ Sources: - [x] Tool config (`toolConfig: { tools: [{ toolSpec: { name, inputSchema } }] }`) - [ ] **Streaming** (`POST /model/{modelId}/converse-stream`) - [ ] **Vision** (`content: [{ image: { format, source: { bytes } } }]`) -- [~] **Document input** (`content: [{ document: { format, name, source: { bytes } } }]`) — the converter into this block is covered by folder 42 (#5472: OpenAI `type:"file"` / Responses `input_file` document uploads via `/v1/chat/completions` and `/v1/responses`, xlsx/docx/csv/pdf/txt + `file_url`). A native Converse-shaped `document` block posted directly at `/bedrock/model/{id}/converse` is still uncovered. +- [x] **Document input** (`content: [{ document: { format, name, source: { bytes } } }]`) — the converter into this block is covered by folder 42 (#5472: OpenAI `type:"file"` / Responses `input_file` document uploads via `/v1/chat/completions` and `/v1/responses`, xlsx/docx/csv/pdf/txt + `file_url`). A native Converse-shaped `document` block posted directly at `/bedrock/model/{id}/converse` is covered by folder 77 (#7072), which also pins text-format documents (txt/md/csv/html) across every ingress - they shipped as a bare `source.text` and 400d. - [ ] **Video input** (`content: [{ video: { format, source } }]`) - [ ] **Tool result** (`content: [{ toolResult: { toolUseId, content, status } }]`) - [ ] **Stop sequences** (`inferenceConfig: { stopSequences: [...] }`) diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index afc5e600d5d..9da07241a3c 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -145436,6 +145436,1559 @@ } } ] + }, + { + "name": "77. Bedrock text-format document source (#7072 / PR #5663)", + "description": "Bedrock Converse rejects a DocumentSource that carries only `text` (\"must set one of the following keys: bytes, s3Location\") unless the document block enables citations - `text` and `content` are the citations-oriented members. PR #5663 centralized document conversion into materializeBedrockDocument and, reading the AWS docs' \"only one member\" union rule, made text-format documents ship `source.text` alone, which 400s for every txt/md/csv/html document (#7072). The fix ships every document as `source.bytes`, base64-encoding literal text. These rows pin that across all three materializeBedrockDocument call sites (chat, responses, tool result) and every ingress that can reach them. Each row asserts the marker is echoed, not merely a 200: a double-encoded document returns HTTP 200 with EMPTY content, so status-only assertions cannot see this class of bug. 77.07 is the reverse guard - binary documents must keep passing base64 through untouched. bedrock_mantle is deliberately absent: it routes through the OpenAI-compat path (mantle.go -> openai.HandleOpenAIChatCompletionRequest) and never reaches this converter.", + "item": [ + { + "name": "77.01 bedrock/claude-haiku /v1/chat/completions file_data raw text/plain - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"note.txt\",\n \"file_type\": \"text/plain\",\n \"file_data\": \"The document marker is cobalt-7701.\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Exact issue repro: raw text file_data on the chat converter.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7701');", + "});" + ] + } + } + ] + }, + { + "name": "77.02 bedrock/claude-haiku /v1/chat/completions file_data base64 data URL text/markdown - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"note.md\",\n \"file_type\": \"text/markdown\",\n \"file_data\": \"data:text/markdown;base64,VGhlIGRvY3VtZW50IG1hcmtlciBpcyBjb2JhbHQtNzcwMi4=\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Base64 text data URL must pass through as source.bytes, not be decoded into source.text.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7702');", + "});" + ] + } + } + ] + }, + { + "name": "77.03 bedrock/claude-haiku /v1/chat/completions file_data percent-encoded text data URL - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"note.txt\",\n \"file_data\": \"data:text/plain,The%20document%20marker%20is%20cobalt-7703.\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Percent-encoded text data URL: unescape then base64 into source.bytes.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7703');", + "});" + ] + } + } + ] + }, + { + "name": "77.04 bedrock/claude-haiku /v1/chat/completions text document format from filename extension - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"rows.csv\",\n \"file_data\": \"The document marker is cobalt-7704.\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// No file_type: csv format inferred from the filename still takes the text branch.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7704');", + "});" + ] + } + } + ] + }, + { + "name": "77.05 bedrock/claude-haiku /v1/chat/completions file_data text/html - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"page.html\",\n \"file_type\": \"text/html\",\n \"file_data\": \"\\n\\n
The document marker is cobalt-7705.
\\n\\n\\n\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// html is a Bedrock text format and must ship as bytes. Bedrock content-sniffs the payload, so the fixture is a full HTML document - a bare fragment is detected as PLAIN_TEXT and rejected.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7705');", + "});" + ] + } + } + ] + }, + { + "name": "77.06 bedrock/claude-haiku /v1/chat/completions streaming raw text document - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"stream\": true,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"note.txt\",\n \"file_type\": \"text/plain\",\n \"file_data\": \"The document marker is cobalt-7706.\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Streaming shares the same request converter.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "// The model splits the marker across SSE deltas ('cob' + 'alt-77xx'), so it only", + "// exists in the reassembled stream - a substring check on the raw body false-fails.", + "var joined = '';", + "body.split('\\n').forEach(function (line) {", + " if (line.indexOf('data: ') !== 0) { return; }", + " try {", + " var j = JSON.parse(line.slice(6));", + " var d = j.delta;", + " // three delta shapes: chat choices[].delta.content, Anthropic delta.text,", + " // and Responses response.output_text.delta where delta is a bare string.", + " var t = (j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content) ||", + " (typeof d === 'string' ? d : (d && d.text)) || '';", + " if (t) { joined += t; }", + " } catch (e) { /* [DONE] and other non-JSON sentinels */ }", + "});", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7706');", + "});" + ] + } + } + ] + }, + { + "name": "77.07 bedrock/claude-haiku /v1/chat/completions inline base64 PDF stays binary - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"note.pdf\",\n \"file_type\": \"application/pdf\",\n \"file_data\": \"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA2NiA+PgpzdHJlYW0KQlQgL0YxIDE4IFRmIDQwIDcwMCBUZCAoVGhlIGRvY3VtZW50IG1hcmtlciBpcyBjb2JhbHQtNzcwNy4pIFRqIEVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvRm9udCAvU3VidHlwZSAvVHlwZTEgL0Jhc2VGb250IC9IZWx2ZXRpY2EgPj4KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDI0MSAwMDAwMCBuIAowMDAwMDAwMzU3IDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKNDI3CiUlRU9GCg==\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Guard against over-correction: binary documents must keep passing base64 through untouched.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7707');", + "});" + ] + } + } + ] + }, + { + "name": "77.08 bedrock/claude-haiku /v1/responses input_file raw text/plain - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_output_tokens\": 64,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"input_text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"input_file\",\n \"filename\": \"note.txt\",\n \"file_type\": \"text/plain\",\n \"file_data\": \"The document marker is cobalt-7708.\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Responses converter is a separate materializeBedrockDocument call site.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7708');", + "});" + ] + } + } + ] + }, + { + "name": "77.09 bedrock/claude-haiku /v1/responses input_file base64 data URL text/markdown - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_output_tokens\": 64,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"input_text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"input_file\",\n \"filename\": \"note.md\",\n \"file_type\": \"text/markdown\",\n \"file_data\": \"data:text/markdown;base64,VGhlIGRvY3VtZW50IG1hcmtlciBpcyBjb2JhbHQtNzcwOS4=\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Responses converter, base64 text data URL.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7709');", + "});" + ] + } + } + ] + }, + { + "name": "77.10 bedrock/claude-haiku /v1/responses streaming raw text document - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_output_tokens\": 64,\n \"stream\": true,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"input_text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"input_file\",\n \"filename\": \"note.txt\",\n \"file_type\": \"text/plain\",\n \"file_data\": \"The document marker is cobalt-7710.\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Responses streaming shares the same request converter.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "// The model splits the marker across SSE deltas ('cob' + 'alt-77xx'), so it only", + "// exists in the reassembled stream - a substring check on the raw body false-fails.", + "var joined = '';", + "body.split('\\n').forEach(function (line) {", + " if (line.indexOf('data: ') !== 0) { return; }", + " try {", + " var j = JSON.parse(line.slice(6));", + " var d = j.delta;", + " // three delta shapes: chat choices[].delta.content, Anthropic delta.text,", + " // and Responses response.output_text.delta where delta is a bare string.", + " var t = (j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content) ||", + " (typeof d === 'string' ? d : (d && d.text)) || '';", + " if (t) { joined += t; }", + " } catch (e) { /* [DONE] and other non-JSON sentinels */ }", + "});", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7710');", + "});" + ] + } + } + ] + }, + { + "name": "77.11 bedrock/claude-haiku /openai/v1/chat/completions file_data raw text/plain - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"note.txt\",\n \"file_type\": \"text/plain\",\n \"file_data\": \"The document marker is cobalt-7711.\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/openai/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "openai", + "v1", + "chat", + "completions" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// OpenAI drop-in ingress reaches the same Bedrock chat converter.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7711');", + "});" + ] + } + } + ] + }, + { + "name": "77.12 bedrock/claude-haiku /openai/v1/responses input_file raw text/plain - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_output_tokens\": 64,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"input_text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"input_file\",\n \"filename\": \"note.txt\",\n \"file_type\": \"text/plain\",\n \"file_data\": \"The document marker is cobalt-7712.\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/openai/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "openai", + "v1", + "responses" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// OpenAI Responses drop-in ingress.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7712');", + "});" + ] + } + } + ] + }, + { + "name": "77.13 bedrock/claude-haiku /anthropic/v1/messages document source.type text - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"document\",\n \"source\": {\n \"type\": \"text\",\n \"media_type\": \"text/plain\",\n \"data\": \"The document marker is cobalt-7713.\"\n },\n \"title\": \"Note\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Issue repro surface: Anthropic Messages text document block against a Bedrock model.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7713');", + "});" + ] + } + } + ] + }, + { + "name": "77.14 bedrock/claude-haiku /anthropic/v1/messages document source.type base64 text/plain - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"document\",\n \"source\": {\n \"type\": \"base64\",\n \"media_type\": \"text/plain\",\n \"data\": \"VGhlIGRvY3VtZW50IG1hcmtlciBpcyBjb2JhbHQtNzcxNC4=\"\n },\n \"title\": \"Note\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Anthropic base64 document with a text media type.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7714');", + "});" + ] + } + } + ] + }, + { + "name": "77.15 bedrock/claude-haiku /anthropic/v1/messages streaming document source.type text - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 64,\n \"stream\": true,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"type\": \"document\",\n \"source\": {\n \"type\": \"text\",\n \"media_type\": \"text/plain\",\n \"data\": \"The document marker is cobalt-7715.\"\n },\n \"title\": \"Note\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Anthropic drop-in streaming.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "// The model splits the marker across SSE deltas ('cob' + 'alt-77xx'), so it only", + "// exists in the reassembled stream - a substring check on the raw body false-fails.", + "var joined = '';", + "body.split('\\n').forEach(function (line) {", + " if (line.indexOf('data: ') !== 0) { return; }", + " try {", + " var j = JSON.parse(line.slice(6));", + " var d = j.delta;", + " // three delta shapes: chat choices[].delta.content, Anthropic delta.text,", + " // and Responses response.output_text.delta where delta is a bare string.", + " var t = (j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content) ||", + " (typeof d === 'string' ? d : (d && d.text)) || '';", + " if (t) { joined += t; }", + " } catch (e) { /* [DONE] and other non-JSON sentinels */ }", + "});", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7715');", + "});" + ] + } + } + ] + }, + { + "name": "77.16 bedrock/claude-haiku /bedrock converse native document source.text round-trip - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"document\": {\n \"format\": \"txt\",\n \"name\": \"Note\",\n \"source\": {\n \"text\": \"The document marker is cobalt-7716.\"\n }\n }\n }\n ]\n }\n ],\n \"inferenceConfig\": {\n \"maxTokens\": 64\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/bedrock/model/global.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bedrock", + "model", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "converse" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Bedrock-native ingress: an inbound source.text must be re-materialized as source.bytes on egress.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7716');", + "});" + ] + } + } + ] + }, + { + "name": "77.17 bedrock/claude-haiku /bedrock converse native document source.bytes round-trip - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Reply with only the document marker, nothing else.\"\n },\n {\n \"document\": {\n \"format\": \"txt\",\n \"name\": \"Note\",\n \"source\": {\n \"bytes\": \"VGhlIGRvY3VtZW50IG1hcmtlciBpcyBjb2JhbHQtNzcxNy4=\"\n }\n }\n }\n ]\n }\n ],\n \"inferenceConfig\": {\n \"maxTokens\": 64\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/bedrock/model/global.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bedrock", + "model", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "converse" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Bedrock-native ingress: inbound bytes must not be double-encoded on the way back out.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7717');", + "});" + ] + } + } + ] + }, + { + "name": "77.18 bedrock/claude-haiku /v1/responses function_call_output text document tool result - #7072", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_output_tokens\": 64,\n \"tools\": [\n {\n \"type\": \"function\",\n \"name\": \"fetch_note\",\n \"description\": \"Fetch the note\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {}\n }\n }\n ],\n \"input\": [\n {\n \"type\": \"message\",\n \"role\": \"user\",\n \"content\": \"Call fetch_note, then reply with only the document marker.\"\n },\n {\n \"type\": \"function_call\",\n \"role\": \"assistant\",\n \"status\": \"completed\",\n \"call_id\": \"call_7718\",\n \"name\": \"fetch_note\",\n \"arguments\": \"{}\"\n },\n {\n \"type\": \"function_call_output\",\n \"call_id\": \"call_7718\",\n \"output\": [\n {\n \"type\": \"input_file\",\n \"filename\": \"note.txt\",\n \"file_type\": \"text/plain\",\n \"file_data\": \"The document marker is cobalt-7718.\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Tool-result document path - the branch PR #5663 was written to add.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('Bedrock did not reject the DocumentSource', function () {", + " pm.expect(body).to.not.include('must set one of the following keys');", + "});", + "pm.test('request succeeded', function () {", + " pm.expect(pm.response.code, 'failed: ' + body.slice(0, 400)).to.be.below(400);", + "});", + "var joined = body;", + "// A text document shipped as a bare source.text 400s; one double-encoded into", + "// source.bytes returns 200 with EMPTY content. Only echoing the marker proves", + "// the document bytes actually survived conversion.", + "pm.test('document content reached the model', function () {", + " pm.expect(joined, 'marker missing - document did not survive conversion: ' + body.slice(0, 400)).to.include('cobalt-7718');", + "});" + ] + } + } + ] + } + ] + }, + { + "name": "78. Bedrock OpenAI-compatible surface (use_openai_endpoints / gpt-6 path / region prefix)", + "description": "Pins the Bedrock OpenAI-compatible routing added in df47e2173, 0eaf82d19 and 473af3607.\n\nMantle answers a frontier model on exactly one of its two base paths and 400s on the other, so a new closed generation that nothing names falls through to the bare \"v1\" path gpt-oss uses. gpt-6-astra did exactly that. The base path now comes from schemas.ResolveBedrockMantleBasePath (datasheet bedrock_mantle_base_path, family list as fallback).\n\nThe \"{region}/model\" form is Bifrost addressing, not part of the AWS identifier: it selects the host and signing scope and must be stripped before the wire. The Converse and native-Anthropic paths always did; the OpenAI-compatible ones handed request.Model straight to the shared handler and AWS 404d it.\n\nResponses on bedrock-runtime can now be served from /openai/v1/responses instead of Converse, behind the key-level use_openai_endpoints opt-in. Converse has no previous_response_id, so a stateful client loses prior-turn context and any tool continuation fails. The opt-in never overrides capability: Claude and application inference profiles stay on Converse.\n\nCases 4 and 5 require the bedrock_openai_ep provider in tests/integrations/python/config.json (same credentials, use_openai_endpoints: true).", + "item": [ + { + "name": "bedrock_mantle/us-west-2/openai.gpt-6-astra /v1/chat/completions routes to openai/v1 with the region prefix stripped - bedrock-openai-surface", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-bf-send-back-raw-request", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock_mantle/us-west-2/openai.gpt-6-astra\",\"max_tokens\":24,\"messages\":[{\"role\":\"user\",\"content\":\"Say OK\"}]}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "// Two distinct regressions answer here, both with a 4xx, so 400/404 must NOT be skipped:", + "// wrong base path -> 400 \"model `openai.gpt-6-astra` isn't supported on this route\"", + "// prefix left on -> 404 \"The model 'us-west-2/openai.gpt-6-astra' does not exist\"", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "pm.test('gpt-6 reaches mantle on openai/v1 with a bare model id', function () {", + " pm.expect(body, 'mantle rejected the base path').to.not.include(\"isn't supported on this route\");", + " // Quoted error phrasing, not the bare id: routing_info.model,", + " // original_model_requested and resolved_model_used all echo the prefixed", + " // form on a healthy 200, so a bare substring check could never pass.", + " pm.expect(body, 'prefixed id was rejected upstream').to.not.include(\"The model 'us-west-2/openai.gpt-6-astra'\");", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " pm.expect(j.choices, 'expected choices in the response').to.be.an('array').that.is.not.empty;", + " var wire = ((j.extra_fields || {}).raw_request || {}).model;", + " pm.expect(wire, 'raw_request missing - is send_back_raw_request honoured?').to.be.a('string');", + " pm.expect(wire, 'region prefix reached the wire: ' + wire).to.equal('openai.gpt-6-astra');", + "});" + ] + } + } + ] + }, + { + "name": "bedrock_mantle/us-west-2/openai.gpt-6-astra /v1/responses keeps xhigh reasoning effort - bedrock-openai-surface", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-bf-send-back-raw-request", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock_mantle/us-west-2/openai.gpt-6-astra\",\"max_output_tokens\":2048,\"reasoning\":{\"effort\":\"xhigh\"},\"input\":\"Say OK\"}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "pm.test('xhigh survives to the wire instead of being normalised to high', function () {", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var raw = JSON.stringify((j.extra_fields || {}).raw_request || {});", + " pm.expect(raw, 'raw_request missing - is send_back_raw_request honoured?').to.not.equal('{}');", + " // Before gpt-6 joined acceptsXHighEffort the ladder lacked the tier and the", + " // requested effort was silently clamped to the next one down.", + " pm.expect(raw, 'effort was downgraded: ' + raw).to.include('\"xhigh\"');", + "});" + ] + } + } + ] + }, + { + "name": "bedrock/us.openai.gpt-5.6-terra /v1/responses stays on Converse without the opt-in - bedrock-openai-surface", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock/us.openai.gpt-5.6-terra\",\"max_output_tokens\":64,\"input\":\"Say OK\"}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "pm.test('default bedrock key serves Responses from Converse', function () {", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " // Converse returns the AWS request id; the OpenAI surface mints resp_*.", + " pm.expect(String(j.id || ''), 'diverted without use_openai_endpoints: ' + j.id).to.not.match(/^resp_/);", + "});" + ] + } + } + ] + }, + { + "name": "bedrock_openai_ep/us.openai.gpt-5.6-terra /v1/responses diverts to the OpenAI surface when opted in - bedrock-openai-surface", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock_openai_ep/us.openai.gpt-5.6-terra\",\"max_output_tokens\":64,\"input\":\"Say OK\"}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "pm.test('use_openai_endpoints routes Responses to /openai/v1/responses', function () {", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " // resp_* is what makes previous_response_id continuations possible at all.", + " pm.expect(String(j.id || ''), 'still on Converse: ' + j.id).to.match(/^resp_/);", + "});" + ] + } + } + ] + }, + { + "name": "bedrock_openai_ep/us.anthropic.claude-sonnet-4-6 /v1/responses keeps claude on Converse despite the opt-in - bedrock-openai-surface", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock_openai_ep/us.anthropic.claude-sonnet-4-6\",\"max_output_tokens\":64,\"input\":\"Say OK\"}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "pm.test('opting in cannot force a surface the model does not serve', function () {", + " // AWS 404s every non-OpenAI family on /openai/v1 (\"doesn't support this API\"),", + " // so a diverted Claude request would fail outright rather than degrade.", + " pm.expect(body, 'claude was diverted to the OpenAI surface').to.not.include(\"doesn't support this API\");", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " pm.expect(String(j.id || ''), 'claude diverted: ' + j.id).to.not.match(/^resp_/);", + "});" + ] + } + } + ] + }, + { + "name": "bedrock/openai.gpt-5.6-terra /v1/responses translates cache_control into a prompt cache breakpoint - bedrock-openai-surface", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "x-bf-send-back-raw-request", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock/openai.gpt-5.6-terra\",\"max_output_tokens\":48,\"input\":[{\"type\":\"message\",\"role\":\"system\",\"content\":[{\"type\":\"input_text\",\"text\":\"You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. You are a meticulous code reviewer. Follow these rules exactly. \",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Say OK.\"}]}]}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "pm.test('bedrock reaches the gpt-5.6 breakpoint path instead of dropping the marker', function () {", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var raw = JSON.stringify((j.extra_fields || {}).raw_request || {});", + " pm.expect(raw, 'raw_request missing - is send_back_raw_request honoured?').to.not.equal('{}');", + " // schemas.Bedrock was absent from responsesUsesPromptCacheBreakpoints, so the", + " // serializer stripped cache_control and left implicit caching in place.", + " pm.expect(raw, 'cache_control was dropped: ' + raw).to.include('prompt_cache_breakpoint');", + " pm.expect(raw, 'explicit mode not requested: ' + raw).to.include('prompt_cache_options');", + "});" + ] + } + } + ] + } + ] + }, + { + "name": "79. Claude Code against Bedrock OpenAI-family models (anthropic route conversion)", + "description": "Two defects made every Claude Code session against a Bedrock OpenAI-family model fail on its first message. Both live in the Anthropic -> Responses conversion and both answer with a 400, so neither degrades quietly.\n\nConvertAnthropicMessagesToBifrostMessages is called with keepToolsGrouped = (provider == bedrock). The grouped converter built input messages with ResponsesOutputMessageContentTypeText plus annotations/logprobs, all output-side. The Responses API matches `input` against a union of input-item variants and a user message carrying output_text matches none: \"Invalid 'input': value did not match any expected variant\". The ungrouped converter (bedrock_mantle) always used input_text, which is why only the bedrock provider failed.\n\nSeparately, the converter pins reasoning.summary to \"detailed\" whenever the user agent is claude-cli. Bedrock advertises concise/detailed/auto in its schema but its models serve only \"auto\", answering the others with \"Unsupported parameter: 'reasoning.summary' is not supported with the ... model\" on BOTH bedrock-runtime and bedrock-mantle. The summary is now narrowed to auto for Bedrock targets rather than dropped, since the caller did ask to see reasoning.\n\nThe third case is the regression guard: Claude on the bedrock provider takes the same grouped converter, so the input_text change had to leave it working.", + "item": [ + { + "name": "bedrock/openai.gpt-5.6-terra via /anthropic/v1/messages with a claude-cli agent - bedrock-cc-anthropic-conversion", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "anthropic-version", + "value": "2023-06-01" + }, + { + "key": "User-Agent", + "value": "claude-cli/1.0" + }, + { + "key": "x-bf-send-back-raw-request", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock/openai.gpt-5.6-terra\",\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024},\"system\":[{\"type\":\"text\",\"text\":\"You are Claude Code, Anthropic's official CLI for Claude.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Hi\"}]}]}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "pm.test('grouped converter emits input_text and a summary bedrock serves', function () {", + " pm.expect(body, 'input union rejected - output_text on an input message?').to.not.include('did not match any expected variant');", + " pm.expect(body, 'summary style the model cannot serve').to.not.include(\"Unsupported parameter: 'reasoning.summary'\");", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var raw = (j.extra_fields || {}).raw_request || {};", + " pm.expect(raw.input, 'raw_request missing - is send_back_raw_request honoured?').to.be.an('array');", + " var types = {};", + " raw.input.forEach(function (item) {", + " (item.content || []).forEach(function (part) { if (part && part.type) { types[part.type] = true; } });", + " });", + " // A user turn carries input_text; output_text is output-side and makes the", + " // Responses input union unmatchable.", + " pm.expect(types['output_text'], 'output_text on an input message: ' + JSON.stringify(types)).to.not.equal(true);", + " pm.expect(types['input_text'], 'expected input_text, got ' + JSON.stringify(types)).to.equal(true);", + " var reasoning = raw.reasoning || {};", + " // Bedrock advertises concise/detailed/auto but serves only auto; the Anthropic", + " // converter pins 'detailed' for every Claude Code request.", + " if (reasoning.summary) { pm.expect(reasoning.summary, 'summary not narrowed for bedrock').to.equal('auto'); }", + "});" + ] + } + } + ] + }, + { + "name": "bedrock_mantle/openai.gpt-5.6-terra via /anthropic/v1/messages with a claude-cli agent - bedrock-cc-anthropic-conversion", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "anthropic-version", + "value": "2023-06-01" + }, + { + "key": "User-Agent", + "value": "claude-cli/1.0" + }, + { + "key": "x-bf-send-back-raw-request", + "value": "true" + } + ], + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock_mantle/openai.gpt-5.6-terra\",\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024},\"system\":[{\"type\":\"text\",\"text\":\"You are Claude Code, Anthropic's official CLI for Claude.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Hi\"}]}]}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "// The ungrouped converter already emitted input_text, so this case isolates the", + "// summary narrowing on the other host.", + "pm.test('summary is narrowed to auto on the mantle host too', function () {", + " pm.expect(body, 'input union rejected - output_text on an input message?').to.not.include('did not match any expected variant');", + " pm.expect(body, 'summary style the model cannot serve').to.not.include(\"Unsupported parameter: 'reasoning.summary'\");", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var raw = (j.extra_fields || {}).raw_request || {};", + " pm.expect(raw.input, 'raw_request missing - is send_back_raw_request honoured?').to.be.an('array');", + " var types = {};", + " raw.input.forEach(function (item) {", + " (item.content || []).forEach(function (part) { if (part && part.type) { types[part.type] = true; } });", + " });", + " // A user turn carries input_text; output_text is output-side and makes the", + " // Responses input union unmatchable.", + " pm.expect(types['output_text'], 'output_text on an input message: ' + JSON.stringify(types)).to.not.equal(true);", + " pm.expect(types['input_text'], 'expected input_text, got ' + JSON.stringify(types)).to.equal(true);", + " var reasoning = raw.reasoning || {};", + " // Bedrock advertises concise/detailed/auto but serves only auto; the Anthropic", + " // converter pins 'detailed' for every Claude Code request.", + " if (reasoning.summary) { pm.expect(reasoning.summary, 'summary not narrowed for bedrock').to.equal('auto'); }", + "});" + ] + } + } + ] + }, + { + "name": "bedrock/us.anthropic.claude-sonnet-4-6 via /anthropic/v1/messages keeps working through the grouped converter - bedrock-cc-anthropic-conversion", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "anthropic-version", + "value": "2023-06-01" + }, + { + "key": "User-Agent", + "value": "claude-cli/1.0" + } + ], + "url": { + "raw": "{{baseUrl}}/anthropic/v1/messages", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "anthropic", + "v1", + "messages" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"model\":\"bedrock/us.anthropic.claude-sonnet-4-6\",\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024},\"system\":[{\"type\":\"text\",\"text\":\"You are Claude Code, Anthropic's official CLI for Claude.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Hi\"}]}]}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "var body = pm.response.text() || '';", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "// Claude on the bedrock provider shares keepToolsGrouped=true with the models", + "// above, so the input_text change had to leave this path intact.", + "pm.test('claude on bedrock still answers through the grouped converter', function () {", + " pm.expect(body, 'input union rejected').to.not.include('did not match any expected variant');", + " pm.expect(pm.response.code, 'request failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " pm.expect(j.content, 'expected content blocks').to.be.an('array').that.is.not.empty;", + "});" + ] + } + } + ] + } + ] } ] } \ No newline at end of file diff --git a/tests/integrations/python/config.json b/tests/integrations/python/config.json index e3a0d5f0e2e..200bdf2737d 100644 --- a/tests/integrations/python/config.json +++ b/tests/integrations/python/config.json @@ -518,6 +518,30 @@ ] } }, + "bedrock_openai_ep": { + "keys": [ + { + "name": "Bedrock API Key", + "bedrock_key_config": { + "access_key": "env.AWS_ACCESS_KEY_ID", + "secret_key": "env.AWS_SECRET_ACCESS_KEY", + "region": "env.AWS_REGION" + }, + "weight": 1, + "models": [ + "*" + ], + "use_openai_endpoints": true + } + ], + "custom_provider_config": { + "base_provider_type": "bedrock", + "is_key_less": false + }, + "network_config": { + "default_request_timeout_in_seconds": 300 + } + }, "openai_pc_auto": { "keys": [ { diff --git a/transports/config.schema.json b/transports/config.schema.json index 3dd0de146f3..d391f60a6c7 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -4590,6 +4590,10 @@ "use_anthropic_endpoints": { "type": "boolean", "description": "Routes chat completions and responses requests through Anthropic-compatible endpoints." + }, + "use_openai_endpoints": { + "type": "boolean", + "description": "Per-alias override of use_openai_endpoints." } }, "required": ["model_id"], @@ -4721,6 +4725,10 @@ }, "required": ["region"], "additionalProperties": false + }, + "use_openai_endpoints": { + "type": "boolean", + "description": "Routes chat completions and responses requests through Bedrock's OpenAI-compatible endpoints (/openai/v1) instead of Converse, for models that serve them. Converse is used for everything else. Bedrock Guardrails, performanceConfig and requestMetadata apply only on Converse: the OpenAI-compatible endpoints accept and ignore them." } } } diff --git a/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx b/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx index 63a2a97217f..81fb8f386d6 100644 --- a/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx +++ b/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx @@ -1244,6 +1244,28 @@ export function ApiKeyFormFragment({ control, providerName, baseProviderType, fo )} {isBedrock && (