Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a0446bc
fix: resolve Bedrock application inference profile ARNs
evgeniy-scherbina Sep 1, 2026
57f583a
refactor(aibridge): name Bedrock model accessors by configured and re…
evgeniy-scherbina Sep 1, 2026
0e031e7
refactor(aibridge/intercept/messages): keep Model() structure unchanged
evgeniy-scherbina Sep 1, 2026
1fd6ec5
refactor(aibridge/intercept/messages): extract upstreamModel
evgeniy-scherbina Sep 1, 2026
7742db1
refactor(aibridge/intercept/messages): read small/fast classification…
evgeniy-scherbina Sep 1, 2026
431d2e7
docs: minor changes
evgeniy-scherbina Sep 1, 2026
54508c4
docs(aibridge/provider): clarify which Bedrock identifiers are resolved
evgeniy-scherbina Sep 1, 2026
e9a1916
test(aibridge/provider): resolve inference profiles against a mock en…
evgeniy-scherbina Sep 2, 2026
1ee5a5b
refactor(aibridge/provider): return the loaded AWS config from buildB…
evgeniy-scherbina Sep 2, 2026
a928bb5
refactor(aibridge/provider): call the inference profile resolver dire…
evgeniy-scherbina Sep 2, 2026
6e837d5
docs: minor changes
evgeniy-scherbina Sep 2, 2026
457b81a
test(aibridge/provider): make Bedrock model setup explicit
evgeniy-scherbina Sep 2, 2026
3d65242
docs(docs/ai-coder/ai-gateway): move application inference profiles a…
evgeniy-scherbina Sep 3, 2026
a567995
docs(docs/ai-coder/ai-gateway): clarify application inference profile…
evgeniy-scherbina Sep 3, 2026
0ba11af
test(aibridge/intercept/messages): cover small fast capture in the in…
evgeniy-scherbina Sep 4, 2026
958c981
docs(aibridge/provider): record why the first inference profile model…
evgeniy-scherbina Sep 4, 2026
abda63c
fix(aibridge/provider): stop attributing every profile lookup failure…
evgeniy-scherbina Sep 4, 2026
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
81 changes: 71 additions & 10 deletions aibridge/intercept/messages/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,49 @@ var bedrockSupportedBetaFlags = map[string]bool{
type BedrockRuntime struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-3] BedrockRuntime keeps Cfg and Creds exported while the resolved models are unexported, so a struct literal still compiles and yields an empty Model(). (Netero)

NewBedrockRuntime is the only way to set resolvedModel, but nothing prevents &BedrockRuntime{Cfg: ..., Creds: ...}. On the InvokeModel path Model() then returns "", which flows into usage records, pricing lookups, metrics, and the apidump middleware. Today only provider/anthropic.go:95 constructs one, so this is latent, not live, but the constructor was introduced to carry an invariant it does not enforce. Fix: unexport Cfg and Creds; every in-package read already goes through the receiver, and no other package touches them directly.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, but I'd like to keep it as is for now. I think this pattern is pretty common in our codebase. Also, Go generally recommends using exported fields directly instead of getters.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-3] Panel disposition: held at P3. The 'exported fields are idiomatic' defense answers a question the finding did not ask: this is not fields-versus-getters, it is a type with two exported and two unexported fields with a dependency between them, whose constructor doc asserts an invariant the type does not hold. New evidence defeats the 'latent, needs a struct literal' framing: no struct literal is required, base_internal_test.go already calls NewBedrockRuntime(tt.cfg, ..., "", "") with tt.cfg.Model set, so the sanctioned constructor already accepts the state the doc says cannot exist. And the round-3 'unreachable because Validate rejects empty Model' argument does not hold: Validate never inspects resolvedModel. Two coherent endpoints, either is fine: unexport Cfg/Creds so the constructor enforces the invariant (the only cross-package reader is anthropic.go:197, needing one accessor), or drop the Configured* accessors and read Cfg directly (then CRF-30 disappears too). What should not ship is the current half-encapsulated shape where the doc claims an invariant the type does not enforce. At minimum, fix the type doc, which still enumerates only 'the static Bedrock config plus the AWS credentials provider.'

🤖

Cfg aibconfig.AWSBedrock
Creds aws.CredentialsProvider

resolvedModel string
resolvedSmallFastModel string
}

// NewBedrockRuntime bundles the Bedrock config and credentials with the model
// IDs behind the configured identifiers. The resolved IDs differ from the
// configured ones only when those are application inference profile ARNs, which
// are opaque and must be resolved through AWS; every other identifier resolves
// to itself.
func NewBedrockRuntime(cfg aibconfig.AWSBedrock, creds aws.CredentialsProvider, resolvedModel, resolvedSmallFastModel string) *BedrockRuntime {
return &BedrockRuntime{
Cfg: cfg,
Creds: creds,
resolvedModel: resolvedModel,
resolvedSmallFastModel: resolvedSmallFastModel,
}
}

// ConfiguredModel returns the identifier the operator configured, which may be
// an application inference profile ARN. Requests carry it as the model because
// AWS attributes spend to a profile only when the profile itself is invoked.
func (b *BedrockRuntime) ConfiguredModel() string {
return b.Cfg.Model
}

// ConfiguredSmallFastModel is [BedrockRuntime.ConfiguredModel] for the
// small/fast model.
func (b *BedrockRuntime) ConfiguredSmallFastModel() string {
return b.Cfg.SmallFastModel
}

// ResolvedModel returns the Bedrock model ID behind the configured identifier.
// Model capabilities, usage records, pricing, and metrics all key off this
// rather than the configured identifier.
func (b *BedrockRuntime) ResolvedModel() string {
return b.resolvedModel
}

// ResolvedSmallFastModel is [BedrockRuntime.ResolvedModel] for the small/fast
// model.
func (b *BedrockRuntime) ResolvedSmallFastModel() string {
return b.resolvedSmallFastModel
}

type interceptionBase struct {
Expand All @@ -86,6 +129,14 @@ type interceptionBase struct {
// clientHeaders are the original HTTP headers from the client request.
clientHeaders http.Header

// isSmallFastModel reports whether the client requested a small/fast model

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-17] The isSmallFastModel field comment says "(Haiku 3.5)", but the code matches every Haiku and this PR's own tests rely on that. (Gon P2)

isSmallFastModel(model) is strings.Contains(model, "haiku"), and TestSmallFastModelCapturedAtConstruction feeds claude-haiku-4-5 and asserts the small/fast path. The parenthetical names one version where the code matches a family. It is not cosmetic: streaming.go:119 skips injectTools() when this field is true, so someone debugging why tools were not injected for a Haiku 4.5 request reads this comment and rules out the right cause. This PR rewrote and relocated the comment, so it is in scope. Drop the version, keep the trap.

🤖

// (Haiku 3.5), which is optimized for tasks like code autocomplete and other
// small, quick operations. It is captured at construction because the Bedrock
// InvokeModel remap overwrites the model in the request payload.
// See `ANTHROPIC_SMALL_FAST_MODEL`: https://docs.anthropic.com/en/docs/claude-code/settings#environment-variables
// https://docs.claude.com/en/docs/claude-code/costs#background-token-usage
isSmallFastModel bool

logger slog.Logger
tracer trace.Tracer

Expand Down Expand Up @@ -169,16 +220,26 @@ func (i *interceptionBase) Model() string {
// passthrough, non-Bedrock providers) returns the model the client sent in
// the body.
if i.isBedrockInvokeModel() {
model := i.bedrock.Cfg.Model
if i.isSmallFastModel() {
model = i.bedrock.Cfg.SmallFastModel
model := i.bedrock.ResolvedModel()
if i.isSmallFastModel {
model = i.bedrock.ResolvedSmallFastModel()
}
return model
}

return i.reqPayload.model()
}

// upstreamModel returns the identifier sent to Bedrock as the invocation
// target, which may be an application inference profile ARN.
func (i *interceptionBase) upstreamModel() string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already Model function, maybe this could be added there under some is bedrock condition.
If you want to keep this function then I think it should be renamedupstreamModel -> bedrockModel

@evgeniy-scherbina evgeniy-scherbina Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pawbana The idea is that we need two functions:

  • Model() is used everywhere internally: augmentRequestForBedrockInvokeModel, the database, cost control, etc.
  • upstreamModel() has a single purpose: determining what we send upstream. For example, with an AIP, we want to send the AIP upstream, not the underlying resolved model.

In other words:

  • Model() always returns the resolved model, such as claude-opus or claude-haiku.
  • upstreamModel() may return either a regular model like claude-opus / claude-haiku, or an AIP.

EDIT: we can consider renaming upstreamModel() to configuredModel()?

model := i.bedrock.ConfiguredModel()
if i.isSmallFastModel {
model = i.bedrock.ConfiguredSmallFastModel()
}
return model
}

func (i *interceptionBase) baseTraceAttributes(r *http.Request, streaming bool) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.String(tracing.RequestPath, r.URL.Path),
Expand Down Expand Up @@ -261,12 +322,9 @@ func (*interceptionBase) extractModelThoughts(msg *anthropic.Message) []*recorde
return thoughtRecords
}

// IsSmallFastModel checks if the model is a small/fast model (Haiku 3.5).
// These models are optimized for tasks like code autocomplete and other small, quick operations.
// See `ANTHROPIC_SMALL_FAST_MODEL`: https://docs.anthropic.com/en/docs/claude-code/settings#environment-variables
// https://docs.claude.com/en/docs/claude-code/costs#background-token-usage
func (i *interceptionBase) isSmallFastModel() bool {
return strings.Contains(i.reqPayload.model(), "haiku")
// isSmallFastModel reports whether the client requested a small/fast model.
func isSmallFastModel(model string) bool {
return strings.Contains(model, "haiku")
}

// newMessagesService builds the SDK service used for upstream calls.
Expand Down Expand Up @@ -415,13 +473,16 @@ func (i *interceptionBase) withBedrockMantleOptions(ctx context.Context) ([]opti
// Anthropics' model names. It also converts adaptive thinking to enabled with a budget for models that
// don't support adaptive thinking natively, or enabled thinking to adaptive for models that only support
// adaptive.
//
// The request carries the configured identifier, which may be an application
// inference profile ARN, while capability decisions use the model ID behind it.
func (i *interceptionBase) augmentRequestForBedrockInvokeModel() {
if i.bedrock == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-20] augmentRequestForBedrockInvokeModel guards only i.bedrock == nil, not the protocol, and this PR turned that gap from harmless into payload-corrupting. (Meruem P3, Hisoka P3)

The single call site checks isBedrockInvokeModel() first, so this is latent today. But the old body wrote withModel(i.Model()), which for a mantle runtime returns the client's own model (a no-op rewrite). The new body writes i.upstreamModel(), which reads Cfg.Model unconditionally, and mantle configs legitimately leave that empty. Verified: a mis-call now writes "model":"" into the request body and sjson returns no error, so nothing logs. A function that was a no-op off its intended path and now corrupts the outbound payload is a trap for the next caller. Change the guard to if !i.isBedrockInvokeModel() { return }, which also subsumes the nil check and makes upstreamModel()'s unguarded i.bedrock dereference safe by construction.

🤖

return
}

model := i.Model()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is model used elsewhere? I'd assume either model var or i.Model method or i.upstreamModel should be used consistently everywhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I explained the difference above.

updated, err := i.reqPayload.withModel(model)
updated, err := i.reqPayload.withModel(i.upstreamModel())
if err != nil {
i.logger.Warn(context.Background(), "failed to set model in request payload for Bedrock", slog.Error(err))
return
Expand Down
199 changes: 176 additions & 23 deletions aibridge/intercept/messages/base_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,7 @@ func TestAWSBedrockValidation(t *testing.T) {
t.Parallel()

base := &interceptionBase{
bedrock: &BedrockRuntime{
Cfg: tt.cfg,
Creds: credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""),
},
bedrock: NewBedrockRuntime(tt.cfg, credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""), "", ""),
}
opts, err := base.withBedrockInvokeModelOptions(context.Background())

Expand All @@ -209,6 +206,143 @@ func TestAWSBedrockOptionsRequireRuntime(t *testing.T) {
require.Contains(t, err.Error(), "nil bedrock runtime")
}

// TestModelForBedrockInvokeModel covers the split between the invocation target
// and the model identity used for capabilities, usage records, and metrics.
func TestModelForBedrockInvokeModel(t *testing.T) {
t.Parallel()

const (
profileARN = "arn:aws:bedrock:eu-west-2:123456789012:application-inference-profile/46u2vhiyo6z5"
smallFastProfileARN = "arn:aws:bedrock:eu-west-2:123456789012:application-inference-profile/8x1qk20fzp3r"
)

runtime := NewBedrockRuntime(config.AWSBedrock{
Model: profileARN,
SmallFastModel: smallFastProfileARN,
}, nil, "anthropic.claude-opus-4-8", "anthropic.claude-haiku-4-5")

tests := []struct {
name string
smallFast bool
expectModel string
expectConfigured string
}{
{
name: "primary model",
expectModel: "anthropic.claude-opus-4-8",
expectConfigured: profileARN,
},
{
name: "small fast model",
smallFast: true,
expectModel: "anthropic.claude-haiku-4-5",
expectConfigured: smallFastProfileARN,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

i := &interceptionBase{
reqPayload: mustMessagesPayload(t, `{"model":"claude-opus-4-8","max_tokens":10000}`),
bedrock: runtime,
isSmallFastModel: tt.smallFast,
logger: slog.Make(),
}

require.Equal(t, tt.expectModel, i.Model())

i.augmentRequestForBedrockInvokeModel()
require.Equal(t, tt.expectConfigured, gjson.GetBytes(i.reqPayload, "model").String())
// The remap must not change how the interception identifies itself.
require.Equal(t, tt.expectModel, i.Model())
})
}
}

// TestSmallFastModelCapturedAtConstruction covers the classification captured
// by the interceptor constructors. The configured identifiers are opaque
// profile ARNs, so the classification must come from the client payload as it
// arrives.
func TestSmallFastModelCapturedAtConstruction(t *testing.T) {
t.Parallel()

const (
profileARN = "arn:aws:bedrock:eu-west-2:123456789012:application-inference-profile/46u2vhiyo6z5"
smallFastProfileARN = "arn:aws:bedrock:eu-west-2:123456789012:application-inference-profile/8x1qk20fzp3r"
)

runtime := NewBedrockRuntime(config.AWSBedrock{
Model: profileARN,
SmallFastModel: smallFastProfileARN,
}, nil, "anthropic.claude-opus-4-8", "anthropic.claude-haiku-4-5")

const haikuPayload = `{"model":"claude-haiku-4-5","max_tokens":10000}`
const opusPayload = `{"model":"claude-opus-4-8","max_tokens":10000}`

constructors := []struct {
name string
newInterception func(payload RequestPayload) *interceptionBase
}{
{name: "blocking", newInterception: func(payload RequestPayload) *interceptionBase {
return &NewBlockingInterceptor(uuid.New(), payload, intercept.Config{}, nil, runtime, http.Header{}, nil).interceptionBase
}},
{name: "streaming", newInterception: func(payload RequestPayload) *interceptionBase {
return &NewStreamingInterceptor(uuid.New(), payload, intercept.Config{}, nil, runtime, http.Header{}, nil).interceptionBase
}},
}

tests := []struct {
name string
payload string
expectModel string
expectConfigured string
}{
{
name: "small fast model",
payload: haikuPayload,
expectModel: "anthropic.claude-haiku-4-5",
expectConfigured: smallFastProfileARN,
},
{
name: "primary model",
payload: opusPayload,
expectModel: "anthropic.claude-opus-4-8",
expectConfigured: profileARN,
},
}

for _, c := range constructors {
for _, tt := range tests {
t.Run(c.name+" "+tt.name, func(t *testing.T) {
t.Parallel()

i := c.newInterception(mustMessagesPayload(t, tt.payload))
require.Equal(t, tt.expectModel, i.Model())
require.Equal(t, tt.expectConfigured, i.upstreamModel())
})
}
}
}

// TestModelForPlainBedrockModelID covers Bedrock providers configured with
// plain model IDs, which resolve to themselves.
func TestModelForPlainBedrockModelID(t *testing.T) {
t.Parallel()

i := &interceptionBase{
reqPayload: mustMessagesPayload(t, `{"model":"claude-opus-4-8","max_tokens":10000}`),
bedrock: NewBedrockRuntime(config.AWSBedrock{
Model: "eu.anthropic.claude-opus-4-8",
SmallFastModel: "anthropic.claude-haiku-4-5",
}, nil, "eu.anthropic.claude-opus-4-8", "anthropic.claude-haiku-4-5"),
logger: slog.Make(),
}

require.Equal(t, "eu.anthropic.claude-opus-4-8", i.Model())
}

func TestAccumulateUsage(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -608,6 +742,7 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) {
name string

bedrockModel string
resolvedModel string // underlying model ID when bedrockModel is an application inference profile ARN
requestBody string
clientBetaFlags string

Expand Down Expand Up @@ -760,6 +895,24 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) {
requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":8000}}`,
expectThinkingType: "adaptive",
},
{
// Application inference profile ARNs are opaque, so capability
// detection runs against the model resolved through AWS.
name: "opaque_application_inference_profile_uses_resolved_model_for_enabled_thinking",
bedrockModel: "arn:aws:bedrock:eu-west-2:123456789012:application-inference-profile/46u2vhiyo6z5",
resolvedModel: "anthropic.claude-opus-4-8",
requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":8000}}`,
expectThinkingType: "adaptive",
},
{
name: "opaque_application_inference_profile_keeps_adaptive_thinking_and_output_config",
bedrockModel: "arn:aws:bedrock:eu-west-2:123456789012:application-inference-profile/46u2vhiyo6z5",
resolvedModel: "anthropic.claude-opus-4-8",
requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
expectThinkingType: "adaptive",
expectEffort: "high",
expectKeptFields: []string{"output_config"},
},
{
name: "opus_4_8_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget",
bedrockModel: "eu.anthropic.claude-opus-4-8",
Expand Down Expand Up @@ -804,14 +957,19 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) {
}
}

// Plain model IDs resolve to themselves; an application inference
// profile ARN resolves to the model behind it.
resolvedModel := tc.resolvedModel
if resolvedModel == "" {
resolvedModel = tc.bedrockModel
}

i := &interceptionBase{
reqPayload: mustMessagesPayload(t, tc.requestBody),
bedrock: &BedrockRuntime{
Cfg: config.AWSBedrock{
Model: tc.bedrockModel,
SmallFastModel: "anthropic.claude-haiku-3-5",
},
},
bedrock: NewBedrockRuntime(config.AWSBedrock{
Model: tc.bedrockModel,
SmallFastModel: "anthropic.claude-haiku-3-5",
}, nil, resolvedModel, "anthropic.claude-haiku-3-5"),
clientHeaders: clientHeaders,
logger: slog.Make(),
}
Expand All @@ -832,7 +990,8 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) {
require.Equal(t, tc.expectBudgetTokens, budgetTokens.Int())
}

// Model should always be set to the bedrock model.
// The request always targets the configured identifier, which may be
// an application inference profile ARN.
require.Equal(t, tc.bedrockModel, gjson.GetBytes(i.reqPayload, "model").String())

// Verify expected fields are removed.
Expand Down Expand Up @@ -1159,14 +1318,11 @@ func TestBedrockMantleIsPassthrough(t *testing.T) {
i := &interceptionBase{
reqPayload: mustMessagesPayload(t,
`{"model":"anthropic.claude-opus-4-8","max_tokens":10000,"thinking":{"type":"adaptive"},"metadata":{"user_id":"u123"},"context_management":{"type":"auto"}}`),
bedrock: &BedrockRuntime{
Cfg: config.AWSBedrock{
Region: "us-east-1",
BaseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
Protocol: config.BedrockProtocolMantle,
},
Creds: credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""),
},
bedrock: NewBedrockRuntime(config.AWSBedrock{
Region: "us-east-1",
BaseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
Protocol: config.BedrockProtocolMantle,
}, credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""), "", ""),
logger: slog.Make(),
}

Expand Down Expand Up @@ -1217,10 +1373,7 @@ func TestAWSMantleOptionsValidation(t *testing.T) {
t.Parallel()

base := &interceptionBase{
bedrock: &BedrockRuntime{
Cfg: tt.cfg,
Creds: credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""),
},
bedrock: NewBedrockRuntime(tt.cfg, credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""), "", ""),
}
opts, err := base.withBedrockMantleOptions(t.Context())
if tt.errorMsg != "" {
Expand Down
Loading
Loading