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
22 commits
Select commit Hold shift + click to select a range
dbf927d
feat: support cross-account Bedrock AssumeRole in AI Bridge
evgeniy-scherbina Jun 18, 2026
dfd5c9f
Merge remote-tracking branch 'origin/main' into yevhenii/bedrock-dyna…
evgeniy-scherbina Jun 20, 2026
65cb77c
feat: support cross-account Bedrock AssumeRole in AI Bridge
evgeniy-scherbina Jun 20, 2026
511e209
chore: remove Bedrock role ARN UI
evgeniy-scherbina Jun 22, 2026
3e671c5
refactor: cache only the Bedrock AssumeRole provider
evgeniy-scherbina Jun 22, 2026
5aece13
test: verify Bedrock AssumeRole result is cached
evgeniy-scherbina Jun 22, 2026
55ee6cc
test: cover Bedrock AssumeRole cache expiry refresh
evgeniy-scherbina Jun 22, 2026
0d6cb16
fix(aibridge): preserve SDK-resolved region for Bedrock signing
evgeniy-scherbina Jun 22, 2026
7dcf5db
fix(aibridge): clarify Bedrock credential resolution error
evgeniy-scherbina Jun 22, 2026
eec5a92
feat: validate Bedrock role ARN at write time
evgeniy-scherbina Jun 22, 2026
a4277e5
feat(aibridge/provider): require a region when assuming a Bedrock role
evgeniy-scherbina Jun 23, 2026
8769c21
refactor(aibridge): share Anthropic test provider via aibridgetest (#…
evgeniy-scherbina Jun 23, 2026
8597d74
test: fail via require.NoError in shared Anthropic test provider help…
evgeniy-scherbina Jun 23, 2026
167ad2d
chore: update error message
evgeniy-scherbina Jun 23, 2026
9bc4146
docs: add comments
evgeniy-scherbina Jun 24, 2026
37529bd
fix(codersdk/aiproviders): report what an invalid role_arn resolved to
evgeniy-scherbina Jun 24, 2026
2fd0b14
test(aibridge/provider): cover Bedrock AssumeRole failure path
evgeniy-scherbina Jun 24, 2026
5ef859f
test(aibridge/intercept): assert credential hints fit the DB column
evgeniy-scherbina Jun 24, 2026
b07ef44
test(aibridge/provider): rename newAnthropic to newTestAnthropic
evgeniy-scherbina Jun 24, 2026
9b436d6
test(cli/aibridged): cover RoleARN in bedrockConfigFromRow
evgeniy-scherbina Jun 24, 2026
4ea714e
test(coderd/ai_providers): assert secret cleared in MigrateStaticToRole
evgeniy-scherbina Jun 24, 2026
ed11c8c
docs: minor fix
evgeniy-scherbina Jun 24, 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
19 changes: 19 additions & 0 deletions aibridge/aibridgetest/aibridgetest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package aibridgetest

import (
"context"
"testing"

"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/aibridge"
)

// NewAnthropicProvider builds an Anthropic provider for tests, failing the test
// if credential resolution fails.
func NewAnthropicProvider(t testing.TB, cfg aibridge.AnthropicConfig, bedrockCfg *aibridge.AWSBedrockConfig) aibridge.Provider {
t.Helper()
p, err := aibridge.NewAnthropicProvider(context.Background(), cfg, bedrockCfg)
require.NoError(t, err)
return p
}
4 changes: 2 additions & 2 deletions aibridge/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ func AsActor(ctx context.Context, actorID string, metadata recorder.Metadata) co
return aibcontext.AsActor(ctx, actorID, metadata)
}

func NewAnthropicProvider(cfg config.Anthropic, bedrockCfg *config.AWSBedrock) provider.Provider {
return provider.NewAnthropic(cfg, bedrockCfg)
func NewAnthropicProvider(ctx context.Context, cfg config.Anthropic, bedrockCfg *config.AWSBedrock) (provider.Provider, error) {
return provider.NewAnthropic(ctx, cfg, bedrockCfg)
}

func NewOpenAIProvider(cfg config.OpenAI) provider.Provider {
Expand Down
37 changes: 19 additions & 18 deletions aibridge/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/aibridge/aibridgetest"
"github.com/coder/coder/v2/aibridge/config"
"github.com/coder/coder/v2/aibridge/internal/testutil"
"github.com/coder/coder/v2/aibridge/provider"
Expand All @@ -36,7 +37,7 @@ func TestValidateProviders(t *testing.T) {
name: "all_supported_providers",
providers: []provider.Provider{
aibridge.NewOpenAIProvider(config.OpenAI{Name: "openai", BaseURL: "https://api.openai.com/v1/"}),
aibridge.NewAnthropicProvider(config.Anthropic{Name: "anthropic", BaseURL: "https://api.anthropic.com/"}, nil),
aibridgetest.NewAnthropicProvider(t, config.Anthropic{Name: "anthropic", BaseURL: "https://api.anthropic.com/"}, nil),
aibridge.NewCopilotProvider(config.Copilot{Name: "copilot", BaseURL: "https://api.individual.githubcopilot.com"}),
aibridge.NewCopilotProvider(config.Copilot{Name: "copilot-business", BaseURL: "https://api.business.githubcopilot.com"}),
aibridge.NewCopilotProvider(config.Copilot{Name: "copilot-enterprise", BaseURL: "https://api.enterprise.githubcopilot.com"}),
Expand All @@ -46,7 +47,7 @@ func TestValidateProviders(t *testing.T) {
name: "default_names_and_base_urls",
providers: []provider.Provider{
aibridge.NewOpenAIProvider(config.OpenAI{}),
aibridge.NewAnthropicProvider(config.Anthropic{}, nil),
aibridgetest.NewAnthropicProvider(t, config.Anthropic{}, nil),
aibridge.NewCopilotProvider(config.Copilot{}),
},
},
Expand Down Expand Up @@ -126,13 +127,13 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
name string
baseURLPath string
requestPath string
provider func(string) provider.Provider
provider func(*testing.T, string) provider.Provider
expectPath string
}{
{
name: "openAI_no_base_path",
requestPath: "/openai/v1/conversations",
provider: func(baseURL string) provider.Provider {
provider: func(_ *testing.T, baseURL string) provider.Provider {
return aibridge.NewOpenAIProvider(config.OpenAI{BaseURL: baseURL})
},
expectPath: "/conversations",
Expand All @@ -141,32 +142,32 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
name: "openAI_with_base_path",
baseURLPath: "/v1",
requestPath: "/openai/v1/conversations",
provider: func(baseURL string) provider.Provider {
provider: func(_ *testing.T, baseURL string) provider.Provider {
return aibridge.NewOpenAIProvider(config.OpenAI{BaseURL: baseURL})
},
expectPath: "/v1/conversations",
},
{
name: "anthropic_no_base_path",
requestPath: "/anthropic/v1/models",
provider: func(baseURL string) provider.Provider {
return aibridge.NewAnthropicProvider(config.Anthropic{BaseURL: baseURL}, nil)
provider: func(t *testing.T, baseURL string) provider.Provider {
return aibridgetest.NewAnthropicProvider(t, config.Anthropic{BaseURL: baseURL}, nil)
},
expectPath: "/v1/models",
},
{
name: "anthropic_with_base_path",
baseURLPath: "/v1",
requestPath: "/anthropic/v1/models",
provider: func(baseURL string) provider.Provider {
return aibridge.NewAnthropicProvider(config.Anthropic{BaseURL: baseURL}, nil)
provider: func(t *testing.T, baseURL string) provider.Provider {
return aibridgetest.NewAnthropicProvider(t, config.Anthropic{BaseURL: baseURL}, nil)
},
expectPath: "/v1/v1/models",
},
{
name: "copilot_no_base_path",
requestPath: "/copilot/models",
provider: func(baseURL string) provider.Provider {
provider: func(_ *testing.T, baseURL string) provider.Provider {
return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL})
},
expectPath: "/models",
Expand All @@ -175,7 +176,7 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
name: "copilot_with_base_path",
baseURLPath: "/v1",
requestPath: "/copilot/models",
provider: func(baseURL string) provider.Provider {
provider: func(_ *testing.T, baseURL string) provider.Provider {
return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL})
},
expectPath: "/v1/models",
Expand All @@ -196,7 +197,7 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
t.Cleanup(upstream.Close)

rec := testutil.MockRecorder{}
prov := tc.provider(upstream.URL + tc.baseURLPath)
prov := tc.provider(t, upstream.URL+tc.baseURLPath)
bridge, err := aibridge.NewRequestBridge(t.Context(), []provider.Provider{prov}, &rec, nil, logger, nil, bridgeTestTracer)
require.NoError(t, err)

Expand All @@ -213,13 +214,13 @@ func TestPassthroughRoutesForProviders(t *testing.T) {
func TestRequestBodySizeLimit(t *testing.T) {
t.Parallel()

newOpenAI := func(baseURL string) provider.Provider {
newOpenAI := func(_ *testing.T, baseURL string) provider.Provider {
return aibridge.NewOpenAIProvider(config.OpenAI{Name: "openai", BaseURL: baseURL})
}
newAnthropic := func(baseURL string) provider.Provider {
return aibridge.NewAnthropicProvider(config.Anthropic{Name: "anthropic", BaseURL: baseURL}, nil)
newAnthropic := func(t *testing.T, baseURL string) provider.Provider {
return aibridgetest.NewAnthropicProvider(t, config.Anthropic{Name: "anthropic", BaseURL: baseURL}, nil)
}
newCopilot := func(baseURL string) provider.Provider {
newCopilot := func(_ *testing.T, baseURL string) provider.Provider {
return aibridge.NewCopilotProvider(config.Copilot{Name: "copilot", BaseURL: baseURL})
}

Expand All @@ -232,7 +233,7 @@ func TestRequestBodySizeLimit(t *testing.T) {

tests := []struct {
name string
provider func(baseURL string) provider.Provider
provider func(*testing.T, string) provider.Provider
path string
body []byte
}{
Expand All @@ -258,7 +259,7 @@ func TestRequestBodySizeLimit(t *testing.T) {
}))
t.Cleanup(upstream.Close)

prov := tc.provider(upstream.URL)
prov := tc.provider(t, upstream.URL)
bridge, err := aibridge.NewRequestBridge(
t.Context(),
[]provider.Provider{prov},
Expand Down
5 changes: 5 additions & 0 deletions aibridge/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ type AWSBedrock struct {
// (https://bedrock-runtime.{region}.amazonaws.com).
// This is useful for routing requests through a proxy or for testing.
BaseURL string
// RoleARN, when set, is assumed via STS before calling Bedrock. The base
// identity (static keys or the AWS SDK default credential chain, e.g.
// IRSA / EKS Pod Identity / EC2 Instance Profile) signs the AssumeRole
// call, and the resulting temporary credentials sign Bedrock requests.
RoleARN string
}

// OpenAI carries configuration for an OpenAI provider.
Expand Down
2 changes: 1 addition & 1 deletion aibridge/intercept/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const (
// before failover selects a key, and a key resolved dynamically at request time.
const (
hintFailoverKey = "<failover key>"
hintBedrockChainKey = "<aws chain credentials>"
hintBedrockChainKey = "<aws chain>"
)

// Credential is the per-request upstream authentication for an interception:
Expand Down
7 changes: 6 additions & 1 deletion aibridge/intercept/credential_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import (
func TestCredential(t *testing.T) {
t.Parallel()

// Matches the VARCHAR(15) DB constraint.
const maxCredentialHintLength = 15

tests := []struct {
name string
newCred func(t *testing.T) intercept.Credential
Expand Down Expand Up @@ -72,7 +75,7 @@ func TestCredential(t *testing.T) {
},
expectKind: intercept.CredentialKindCentralized,
expectAuthHeader: "",
expectHint: "<aws chain credentials>",

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.

Why this change? It was more clear before.

@evgeniy-scherbina evgeniy-scherbina Jun 23, 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.

Hint should be under 15 chars, according to DB constraint to prevent accidentally storing real credentials.
So this bug existed before.

But I think this bug wasn't released yet, probably I need to add test for it as well.

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.

Updated test here: 5ef859f to prevent it from happening in the future.

expectHint: "<aws chain>",
expectLength: 0,
},
{
Expand Down Expand Up @@ -118,6 +121,8 @@ func TestCredential(t *testing.T) {
assert.Equal(t, tc.expectKind, cred.Kind(), "Kind")
assert.Equal(t, tc.expectAuthHeader, cred.AuthHeader(), "AuthHeader")
assert.Equal(t, tc.expectHint, cred.Hint(), "Hint")
assert.LessOrEqual(t, len(cred.Hint()), maxCredentialHintLength,
"Hint must fit the credential_hint column")
assert.Equal(t, tc.expectLength, cred.Length(), "Length")

credBYOK, credBYOKOK := intercept.AsBYOK(cred)
Expand Down
91 changes: 43 additions & 48 deletions aibridge/intercept/messages/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import (
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/anthropics/anthropic-sdk-go/shared"
"github.com/anthropics/anthropic-sdk-go/shared/constant"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/google/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
Expand Down Expand Up @@ -65,13 +64,26 @@ var bedrockSupportedBetaFlags = map[string]bool{
"tool-examples-2025-10-29": true,
}

// BedrockRuntime carries everything a Bedrock-backed interception needs: the
// static Bedrock config plus the AWS credentials provider.
type BedrockRuntime struct {
Cfg aibconfig.AWSBedrock
Creds aws.CredentialsProvider
// ResolvedRegion is the region the AWS SDK resolved at construction (from
// the environment, shared config, or IMDS). It is used for request signing
// when Cfg.Region is empty, e.g. a custom base URL with the region supplied
// via AWS_REGION.
ResolvedRegion string
}

type interceptionBase struct {
id uuid.UUID
reqPayload RequestPayload

cfg intercept.Config
cred intercept.Credential
bedrockCfg *aibconfig.AWSBedrock
cfg intercept.Config
cred intercept.Credential
// bedrock is nil for non-Bedrock providers.
bedrock *BedrockRuntime
Comment thread
dannykopping marked this conversation as resolved.

// clientHeaders are the original HTTP headers from the client request.
clientHeaders http.Header
Expand Down Expand Up @@ -107,10 +119,10 @@ func (i *interceptionBase) Model() string {
return "coder-aibridge-unknown"
}

if i.bedrockCfg != nil {
model := i.bedrockCfg.Model
if i.bedrock != nil {
model := i.bedrock.Cfg.Model
if i.isSmallFastModel() {
model = i.bedrockCfg.SmallFastModel
model = i.bedrock.Cfg.SmallFastModel
}
return model
}
Expand All @@ -126,7 +138,7 @@ func (i *interceptionBase) baseTraceAttributes(r *http.Request, streaming bool)
attribute.String(tracing.Provider, i.cfg.ProviderName),
attribute.String(tracing.Model, i.Model()),
attribute.Bool(tracing.Streaming, streaming),
attribute.Bool(tracing.IsBedrock, i.bedrockCfg != nil),
attribute.Bool(tracing.IsBedrock, i.bedrock != nil),
}
}

Expand Down Expand Up @@ -238,10 +250,10 @@ func (i *interceptionBase) newMessagesService(ctx context.Context, opts ...optio
opts = append(opts, option.WithMiddleware(mw))
}

if i.bedrockCfg != nil {
if i.bedrock != nil {
ctx, cancel := context.WithTimeout(ctx, time.Second*30)
defer cancel()
bedrockOpts, err := i.withAWSBedrockOptions(ctx, i.bedrockCfg)
bedrockOpts, err := i.withAWSBedrockOptions(ctx)
if err != nil {
return anthropic.MessageService{}, err
}
Expand All @@ -262,14 +274,13 @@ func (i *interceptionBase) withBody() option.RequestOption {

// withAWSBedrockOptions returns request options for authenticating with AWS Bedrock.
//
// When both AccessKey and AccessKeySecret are set in the aibridge config, they are
// used directly as static credentials. Otherwise, the AWS SDK default credential chain
// resolves credentials (environment variables, shared config/credentials files, IAM
// roles, IRSA, SSO, IMDS, etc.).
func (*interceptionBase) withAWSBedrockOptions(ctx context.Context, cfg *aibconfig.AWSBedrock) ([]option.RequestOption, error) {
if cfg == nil {
return nil, xerrors.New("nil config given")
// Credentials come from i.bedrock.Creds. It is a shared credentials cache, so the per-request Retrieve()
// below is served from that cache and does not re-resolve or re-assume on every request.
func (i *interceptionBase) withAWSBedrockOptions(ctx context.Context) ([]option.RequestOption, error) {
if i.bedrock == nil {
return nil, xerrors.New("nil bedrock runtime")
}
cfg := i.bedrock.Cfg
if cfg.Region == "" && cfg.BaseURL == "" {
return nil, xerrors.New("region or base url required")
}
Expand All @@ -280,38 +291,22 @@ func (*interceptionBase) withAWSBedrockOptions(ctx context.Context, cfg *aibconf
return nil, xerrors.New("small fast model required")
}

loadOpts := []func(*config.LoadOptions) error{
config.WithRegion(cfg.Region),
}

// Use static credentials when explicitly provided, otherwise fall back to the SDK default credential chain.
switch {
// Both set: use static credentials directly.
case cfg.AccessKey != "" && cfg.AccessKeySecret != "":
loadOpts = append(loadOpts, config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.AccessKeySecret,
"",
),
))
// Only one set: misconfiguration.
case cfg.AccessKey != "" || cfg.AccessKeySecret != "":
return nil, xerrors.New("both access key and access key secret must be provided together")
// Neither set: SDK default credential chain resolves credentials.
default:
// Fail fast: ensure credentials can be resolved before signing. Served from

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-18] "Served from the shared cache on most requests (no network); on the cold or refresh path this performs the actual STS/IMDS call." This restates the caching behavior already documented on buildBedrockCredentials and on the withAWSBedrockOptions doc comment two lines above. The intent (fail fast before signing) fits in one line.

Suggested: // Fail fast: surface credential errors before signing.

(Gon)

🤖

// the shared cache on most requests (no network); on the cold or refresh
// path this performs the actual STS/IMDS call.
if _, err := i.bedrock.Creds.Retrieve(ctx); err != nil {
return nil, xerrors.Errorf("resolve AWS credentials: %w", err)
}

awsCfg, err := config.LoadDefaultConfig(ctx, loadOpts...)
if err != nil {
return nil, xerrors.Errorf("failed to load AWS Bedrock config: %w", err)
// Fall back to the SDK-resolved region (e.g. from AWS_REGION) when no
// explicit region is configured.
region := cfg.Region
if region == "" {
region = i.bedrock.ResolvedRegion
}

// Fail fast: ensure credentials can be resolved before making any requests.
// awsCfg already carries the credentials provider, and the Bedrock middleware
// will call Retrieve on it when signing each request.
if _, err := awsCfg.Credentials.Retrieve(ctx); err != nil {
return nil, xerrors.Errorf("no AWS credentials found: %w", err)
awsCfg := aws.Config{
Comment thread
dannykopping marked this conversation as resolved.
Region: region,
Credentials: i.bedrock.Creds,
}

var out []option.RequestOption
Expand All @@ -336,7 +331,7 @@ func (*interceptionBase) withAWSBedrockOptions(ctx context.Context, cfg *aibconf
// don't support adaptive thinking natively, or enabled thinking to adaptive for models that only support
// adaptive (Opus 4.7+).
func (i *interceptionBase) augmentRequestForBedrock() {
if i.bedrockCfg == nil {
if i.bedrock == nil {
return
}

Expand Down
Loading
Loading