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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed config.json
Empty file.
1 change: 1 addition & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
- fix: an OpenAI-compatible upstream that omits `[DONE]` and then goes silent after `finish_reason` no longer fails the stream when `stream_idle_timeout_in_seconds` fires. The chat and text completion read loops now treat an idle timeout after a terminal signal as a parked upstream, mark the stream parked so the connection is abandoned rather than drained, and synthesize the final chunk with the buffered `finish_reason`; a stall before `finish_reason` still surfaces as the idle-timeout error (#7108)
- fix: a streamed upstream that drops the connection mid-body is again reported as the retryable 502 `provider closed the stream before sending a completion marker` error instead of a generic `Error reading stream: unexpected EOF`. The Bifrost round tripper's chunked decoder surfaced the drop as `io.ErrUnexpectedEOF`, which no provider read loop treats as end of stream; it now reports the plain `io.EOF` fasthttp always did and discards the half-read connection (#7104 follow-up)
- fix: fallbacks for image edit, image variation and video edit requests now reach the configured fallback provider and model. `prepareFallbackRequest` had no arm for those three types, so the shallow request copy kept the primary's sub-request pointer and the "fallback" attempt was routed back to the primary while `RoutingInfo`, the `x-bifrost-routing-info-*` headers, the log row and the `fallback_index` metric label reported it as a fallback. The helper now also verifies the prepared request targets the fallback provider and model and skips the fallback with a warning otherwise, so a future request type added without an arm fails loudly instead of silently re-running the primary (#6966)
- fix: route Bedrock Claude requests that carry a `compact_20260112` edit to InvokeModel / InvokeModelWithResponseStream with the native Anthropic Messages body, so server-side compaction works on `bedrock/` models, including keys that pin an inference-profile ARN. AWS documents compaction as unsupported on the Converse API, which previously received the edit and silently ignored it (#6825)
75 changes: 70 additions & 5 deletions core/internal/llmtests/compaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"testing"
"time"

"github.com/bytedance/sonic"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/providers/anthropic"
"github.com/maximhq/bifrost/core/schemas"
"github.com/tidwall/gjson"
)

// RunCompactionTest tests that context_management with compaction is correctly
Expand All @@ -26,9 +28,13 @@ func RunCompactionTest(t *testing.T, client *bifrost.Bifrost, ctx context.Contex
return
}

// Compaction is currently Anthropic-only
if testConfig.Provider != schemas.Anthropic {
t.Logf("Compaction test skipped: only supported for Anthropic provider")
// Compaction runs on the Claude API and, since #6825, on Bedrock, where a
// compact_20260112 edit routes the request to InvokeModel because Converse
// cannot run it. Other providers still skip here.
switch testConfig.Provider {
case schemas.Anthropic, schemas.Bedrock:
default:
t.Logf("Compaction test skipped: not enabled for provider %s", testConfig.Provider)
return
}

Expand Down Expand Up @@ -67,7 +73,11 @@ func RunCompactionTest(t *testing.T, client *bifrost.Bifrost, ctx context.Contex

// --- Non-streaming test ---
t.Run("NonStreaming", func(t *testing.T) {
bfCtx := schemas.NewBifrostContext(ctx, schemas.NoDeadline)
// Capture the outbound provider body so the Bedrock case can prove the
// request left on InvokeModel rather than Converse (#6825).
rawCtx := context.WithValue(ctx, schemas.BifrostContextKeyAllowPerRequestRawOverride, true)
rawCtx = context.WithValue(rawCtx, schemas.BifrostContextKeySendBackRawRequest, true)
bfCtx := schemas.NewBifrostContext(rawCtx, schemas.NoDeadline)

request := &schemas.BifrostResponsesRequest{
Provider: testConfig.Provider,
Expand Down Expand Up @@ -100,13 +110,19 @@ func RunCompactionTest(t *testing.T, client *bifrost.Bifrost, ctx context.Contex
t.Log("Compaction triggered unexpectedly on short input")
}

assertCompactionEgress(t, testConfig.Provider, response.ExtraFields.RawRequest)

t.Logf("Compaction non-streaming passed: stop_reason=%v, content=%s",
response.StopReason, content)
})

// --- Streaming test ---
t.Run("Streaming", func(t *testing.T) {
bfCtx := schemas.NewBifrostContext(ctx, schemas.NoDeadline)
// Capture the outbound provider body so the Bedrock case can prove the
// request left on InvokeModel rather than Converse (#6825).
rawCtx := context.WithValue(ctx, schemas.BifrostContextKeyAllowPerRequestRawOverride, true)
rawCtx = context.WithValue(rawCtx, schemas.BifrostContextKeySendBackRawRequest, true)
bfCtx := schemas.NewBifrostContext(rawCtx, schemas.NoDeadline)

request := &schemas.BifrostResponsesRequest{
Provider: testConfig.Provider,
Expand All @@ -129,6 +145,7 @@ func RunCompactionTest(t *testing.T, client *bifrost.Bifrost, ctx context.Contex
var fullContent strings.Builder
var chunkCount int
var hasCreated, hasCompleted bool
var streamRawRequest interface{}

streamCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
Expand All @@ -141,6 +158,9 @@ func RunCompactionTest(t *testing.T, client *bifrost.Bifrost, ctx context.Contex
}
chunkCount++
if chunk.BifrostResponsesStreamResponse != nil {
if rr := chunk.BifrostResponsesStreamResponse.ExtraFields.RawRequest; rr != nil {
streamRawRequest = rr
}
if chunk.BifrostResponsesStreamResponse.Type == schemas.ResponsesStreamResponseTypeCreated {
hasCreated = true
}
Expand All @@ -167,12 +187,57 @@ func RunCompactionTest(t *testing.T, client *bifrost.Bifrost, ctx context.Contex
t.Error("Missing response.completed event")
}

assertCompactionEgress(t, testConfig.Provider, streamRawRequest)

content := fullContent.String()
t.Logf("Compaction streaming passed: %d chunks, content=%s", chunkCount, content)
})
})
}

// assertCompactionEgress checks the captured outbound body against the wire
// format the provider must use for compaction. On Bedrock that is InvokeModel
// (anthropic_version "bedrock-2023-05-31", no model field, beta in the
// anthropic_beta array), because the Converse API silently ignores compaction
// (#6825). On the Claude API the body is the plain Messages request.
func assertCompactionEgress(t *testing.T, provider schemas.ModelProvider, rawRequest interface{}) {
t.Helper()
if rawRequest == nil {
t.Fatal("raw request not captured; BifrostContextKeySendBackRawRequest should have been honoured")
}
rawJSON, err := sonic.Marshal(rawRequest)
if err != nil {
t.Fatalf("raw request is not JSON-marshalable: %v", err)
}
body := gjson.ParseBytes(rawJSON)
if !body.Get("context_management").Exists() {
t.Errorf("context_management missing from outbound body: %s", string(rawJSON))
}
switch provider {
case schemas.Bedrock:
if got := body.Get("anthropic_version").String(); got != "bedrock-2023-05-31" {
t.Errorf("Bedrock compaction must egress via InvokeModel: anthropic_version=%q, want %q; body=%s", got, "bedrock-2023-05-31", string(rawJSON))
}
if body.Get("model").Exists() {
t.Errorf("InvokeModel body must not carry model (it is in the URL): %s", string(rawJSON))
}
betas := body.Get("anthropic_beta").Array()
found := false
for _, b := range betas {
if b.String() == anthropic.AnthropicCompactionBetaHeader {
found = true
}
}
if !found {
t.Errorf("anthropic_beta must carry %q on InvokeModel, got %v", anthropic.AnthropicCompactionBetaHeader, betas)
}
case schemas.Anthropic:
if body.Get("anthropic_version").Exists() {
t.Errorf("Claude API body must not carry anthropic_version: %s", string(rawJSON))
}
}
}

// RunExternalCompactionTest tests OpenAI's /v1/responses/compact endpoint via
// bifrost.CompactionRequest. It validates:
// 1. The response object is "response.compaction"
Expand Down
33 changes: 22 additions & 11 deletions core/providers/anthropic/requestbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,30 @@ type AnthropicProviderRequestDefaults struct {
var AnthropicProviderRequestDefaultsMap = map[schemas.ModelProvider]AnthropicProviderRequestDefaults{
schemas.Anthropic: {},
schemas.Azure: {},
// Bedrock Mantle native-Anthropic endpoint (/anthropic/v1/messages): the
// request is the native Anthropic Messages body, so model stays in the body
// (set to the bare Bedrock model id), the version is sent as an
// "anthropic-version" HTTP header rather than a body field, and stream is a
// body field. Tool type versions are still remapped to the canonical pair
// the hosted Claude generation expects.
// Classic Bedrock InvokeModel / InvokeModelWithResponseStream, used by the
// Bedrock provider for Claude requests that need a feature Converse cannot
// deliver (today: compaction, see the InvokeModel section of bedrock/bedrock.go and #6825).
// Per the AWS Messages API reference
// (https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html):
// the model is in the URL (no body field), streaming is selected by the URL
// (no stream field), anthropic_version must be "bedrock-2023-05-31", and beta
// features are opted into through the anthropic_beta body array. Tool type
// versions are remapped to the pair the hosted Claude generation expects, and
// URL image/document sources are inlined because AWS-hosted Claude has no URL
// fetcher (the Converse path already does the same).
schemas.Bedrock: {
RemapToolVersions: true,
DeleteModelField: true,
DeleteStreamField: true,
AddAnthropicVersion: true,
AnthropicVersion: "bedrock-2023-05-31",
RemapToolVersions: true,
InjectBetaHeadersIntoBody: true,
InlineURLSources: true,
},
// Bedrock Mantle shares the Bedrock native-Anthropic request shape (model in
// body, anthropic-version HTTP header, tool versions remapped). It has its own
// entry so its feature surface in ProviderFeatures can diverge from Bedrock's
// Converse path without coupling the two.
// Bedrock Mantle native-Anthropic endpoint (/anthropic/v1/messages): the
// request is the plain Anthropic Messages body (model in body, version as the
// anthropic-version HTTP header, stream as a body field), unlike classic
// Bedrock's InvokeModel shape above. Tool type versions are remapped.
schemas.BedrockMantle: {
RemapToolVersions: true,
// AWS-hosted Claude has no URL fetcher: a {"type":"url"} image or document
Expand Down
90 changes: 90 additions & 0 deletions core/providers/anthropic/requestbuilder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package anthropic

import (
"context"
"encoding/json"
"fmt"
"io"
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -844,3 +846,91 @@ func TestBuildAnthropicResponsesRequestBody_RemapToolVersions(t *testing.T) {
}
})
}

// Regression tests for maximhq/bifrost#6825.
//
// The Bedrock provider routes Claude requests that carry a compact_20260112
// edit to InvokeModel / InvokeModelWithResponseStream, because AWS documents
// compaction as unsupported on Converse:
// https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-compaction.html
//
// InvokeModel takes the native Anthropic Messages body with three Bedrock
// specifics, per
// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html:
// - anthropic_version must be "bedrock-2023-05-31"
// - the model is in the URL, so the body carries no "model"
// - streaming is selected by the URL, so the body carries no "stream"
// - beta features are opted into via the anthropic_beta body array
// The shared anthropic request builder must produce exactly that shape when
// cfg.Provider is schemas.Bedrock.

const bedrockInvokeCompactionContextManagement = `{"edits":[{"type":"compact_20260112","trigger":{"type":"input_tokens","value":50000}}]}`

func assertBedrockInvokeBodyShape(t *testing.T, body []byte) {
t.Helper()
if providerUtils.JSONFieldExists(body, "model") {
t.Errorf("InvokeModel body must not carry model (it is in the URL), got: %s", string(body))
}
if providerUtils.JSONFieldExists(body, "stream") {
t.Errorf("InvokeModel body must not carry stream (the URL selects streaming), got: %s", string(body))
}
if got := providerUtils.GetJSONField(body, "anthropic_version").String(); got != "bedrock-2023-05-31" {
t.Errorf("anthropic_version = %q, want %q", got, "bedrock-2023-05-31")
}
betas := providerUtils.GetJSONField(body, "anthropic_beta")
if !betas.Exists() || !betas.IsArray() {
t.Fatalf("anthropic_beta array missing, got: %s", string(body))
}
var betaValues []string
for _, b := range betas.Array() {
betaValues = append(betaValues, b.String())
}
if !slices.Contains(betaValues, AnthropicCompactionBetaHeader) {
t.Errorf("anthropic_beta = %v, want it to contain %q", betaValues, AnthropicCompactionBetaHeader)
}
if got := providerUtils.GetJSONField(body, "context_management.edits.0.type").String(); got != string(ContextManagementEditTypeCompact) {
t.Errorf("context_management.edits.0.type = %q, want %q; body=%s", got, ContextManagementEditTypeCompact, string(body))
}
}

func TestBuildAnthropicResponsesRequestBody_BedrockInvokeShape(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), time.Time{})
request := &schemas.BifrostResponsesRequest{
Provider: schemas.Bedrock,
Model: "us.anthropic.claude-sonnet-4-6",
Input: makeSimpleInput("Hello!"),
Params: &schemas.ResponsesParameters{
ContextManagement: json.RawMessage(bedrockInvokeCompactionContextManagement),
},
}
body, err := BuildAnthropicResponsesRequestBody(ctx, request, AnthropicRequestBuildConfig{
Provider: schemas.Bedrock,
Model: "us.anthropic.claude-sonnet-4-6",
IsStreaming: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertBedrockInvokeBodyShape(t, body)
}

func TestBuildAnthropicChatRequestBody_BedrockInvokeShape(t *testing.T) {
ctx := schemas.NewBifrostContext(context.Background(), time.Time{})
request := &schemas.BifrostChatRequest{
Provider: schemas.Bedrock,
Model: "us.anthropic.claude-sonnet-4-6",
Input: []schemas.ChatMessage{{Role: schemas.ChatMessageRoleUser, Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("Hello!")}}},
Params: &schemas.ChatParameters{
ContextManagement: json.RawMessage(bedrockInvokeCompactionContextManagement),
},
}
body, err := BuildAnthropicChatRequestBody(ctx, request, AnthropicRequestBuildConfig{
Provider: schemas.Bedrock,
Model: "us.anthropic.claude-sonnet-4-6",
IsStreaming: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertBedrockInvokeBodyShape(t, body)
}
15 changes: 9 additions & 6 deletions core/providers/anthropic/urlsourceinlining_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,16 @@ func TestInlineURLContentSources_NoURLSourcesIsAPassthrough(t *testing.T) {
}
}

// BedrockMantle is the provider that needs this; native Anthropic fetches URLs itself
// and must not pay for a redundant download.
func TestInlineURLSourcesEnabledOnlyForBedrockMantle(t *testing.T) {
if !AnthropicProviderRequestDefaultsMap[schemas.BedrockMantle].InlineURLSources {
t.Error("BedrockMantle must inline URL sources")
// AWS-hosted Claude has no URL fetcher, so both the Bedrock InvokeModel entry and
// Bedrock Mantle inline URL sources; native Anthropic fetches URLs itself and must
// not pay for a redundant download.
func TestInlineURLSourcesEnabledOnlyForAWSHostedClaude(t *testing.T) {
for _, provider := range []schemas.ModelProvider{schemas.Bedrock, schemas.BedrockMantle} {
if !AnthropicProviderRequestDefaultsMap[provider].InlineURLSources {
t.Errorf("%s must inline URL sources", provider)
}
}
for _, provider := range []schemas.ModelProvider{schemas.Anthropic, schemas.Azure, schemas.Vertex, schemas.Bedrock} {
for _, provider := range []schemas.ModelProvider{schemas.Anthropic, schemas.Azure, schemas.Vertex} {
if AnthropicProviderRequestDefaultsMap[provider].InlineURLSources {
t.Errorf("%s must not inline URL sources", provider)
}
Expand Down
Loading
Loading