From f953c8cbb02db6c1e5df722e0b08efe510397a6d Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Tue, 7 Jul 2026 14:31:15 +0000 Subject: [PATCH 1/3] feat: add AWS Bedrock mantle endpoint to AI Gateway --- aibridge/config/config.go | 58 +++++- aibridge/config/config_test.go | 113 ++++++++++ aibridge/intercept/messages/base.go | 134 ++++++++++-- .../intercept/messages/base_internal_test.go | 80 ++++++- .../integrationtest/bridge_internal_test.go | 59 +++++- cli/aibridged.go | 2 + coderd/aibridged/proto/aibridged.pb.go | 154 +++++++------- coderd/aibridged/proto/aibridged.proto | 3 + coderd/aibridgedserver/aibridgedserver.go | 1 + codersdk/aiproviders.go | 7 + codersdk/aiproviders_bedrock.go | 30 +++ codersdk/aiproviders_test.go | 44 ++++ docs/ai-coder/ai-gateway/providers.md | 35 ++++ site/src/api/typesGenerated.ts | 14 ++ .../components/ProviderForm.stories.tsx | 45 ++++ .../ProvidersPage/components/ProviderForm.tsx | 196 ++++++++++++++---- .../components/providerFormApiMap.test.ts | 65 ++++++ .../components/providerFormApiMap.ts | 41 ++-- 18 files changed, 928 insertions(+), 153 deletions(-) create mode 100644 aibridge/config/config_test.go diff --git a/aibridge/config/config.go b/aibridge/config/config.go index b01282b0cc7..45e0be88ba9 100644 --- a/aibridge/config/config.go +++ b/aibridge/config/config.go @@ -1,6 +1,8 @@ package config import ( + "errors" + "fmt" "time" "github.com/coder/coder/v2/aibridge/keypool" @@ -25,13 +27,34 @@ type Anthropic struct { SendActorHeaders bool } +// BedrockProtocol selects which AWS Bedrock wire protocol a provider targets. +type BedrockProtocol string + +const ( + // BedrockProtocolInvokeModel is the legacy InvokeModel protocol + // (bedrock-runtime.{region}.amazonaws.com), which translates the native + // Messages request into Bedrock's InvokeModel format. It is the default + // for the zero value. + BedrockProtocolInvokeModel BedrockProtocol = "invoke-model" + // BedrockProtocolMantle is the mantle protocol + // (bedrock-mantle.{region}.api.aws/anthropic/v1/messages). It is a + // passthrough: the gateway forwards the native Messages request body + // unchanged and only applies AWS SigV4 signing (service bedrock-mantle). + BedrockProtocolMantle BedrockProtocol = "mantle" +) + type AWSBedrock struct { Region string AccessKey, AccessKeySecret string Model, SmallFastModel string - // If set, requests will be sent to this URL instead of the default AWS Bedrock endpoint - // (https://bedrock-runtime.{region}.amazonaws.com). - // This is useful for routing requests through a proxy or for testing. + // BaseURL configures the upstream Bedrock endpoint. + // + // For InvokeModel, it is optional. When empty, requests use the default + // https://bedrock-runtime.{region}.amazonaws.com endpoint. Set it to route + // InvokeModel requests through a proxy or test server. + // + // For mantle, it is required and must be the Messages API prefix without + // /v1/messages, e.g. https://bedrock-mantle.{region}.api.aws/anthropic. 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. @@ -42,6 +65,35 @@ type AWSBedrock struct { // It is meaningful only alongside RoleARN and must match the // sts:ExternalId condition on the target role's trust policy. ExternalID string + // Protocol selects the Bedrock wire protocol. The zero value behaves as + // BedrockProtocolInvokeModel. + Protocol BedrockProtocol +} + +// Validate verifies protocol-specific Bedrock configuration. +func (c AWSBedrock) Validate() error { + switch c.Protocol { + case "", BedrockProtocolInvokeModel: + if c.Region == "" && c.BaseURL == "" { + return errors.New("region or base url required") + } + if c.Model == "" { + return errors.New("model required") + } + if c.SmallFastModel == "" { + return errors.New("small fast model required") + } + case BedrockProtocolMantle: + if c.Region == "" { + return errors.New("region required") + } + if c.BaseURL == "" { + return errors.New("base_url required") + } + default: + return fmt.Errorf("unknown bedrock protocol: %q", c.Protocol) + } + return nil } // OpenAI carries configuration for an OpenAI provider. diff --git a/aibridge/config/config_test.go b/aibridge/config/config_test.go new file mode 100644 index 00000000000..f2d80fbc7b2 --- /dev/null +++ b/aibridge/config/config_test.go @@ -0,0 +1,113 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge/config" +) + +func TestAWSBedrockValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg config.AWSBedrock + errorMsg string + }{ + { + name: "invoke model valid", + cfg: config.AWSBedrock{ + Region: "us-east-1", + Model: "anthropic.claude-sonnet", + SmallFastModel: "anthropic.claude-haiku", + }, + }, + { + name: "invoke model valid with base url instead of region", + cfg: config.AWSBedrock{ + BaseURL: "https://bedrock-runtime.example.com", + Model: "anthropic.claude-sonnet", + SmallFastModel: "anthropic.claude-haiku", + }, + }, + { + name: "invoke model missing region and base url", + cfg: config.AWSBedrock{ + Model: "anthropic.claude-sonnet", + SmallFastModel: "anthropic.claude-haiku", + }, + errorMsg: "region or base url required", + }, + { + name: "invoke model missing model", + cfg: config.AWSBedrock{ + Region: "us-east-1", + SmallFastModel: "anthropic.claude-haiku", + }, + errorMsg: "model required", + }, + { + name: "invoke model missing small fast model", + cfg: config.AWSBedrock{ + Region: "us-east-1", + Model: "anthropic.claude-sonnet", + }, + errorMsg: "small fast model required", + }, + { + name: "unknown protocol rejected", + cfg: config.AWSBedrock{ + Protocol: config.BedrockProtocol("unknown"), + }, + errorMsg: "unknown bedrock protocol", + }, + { + name: "mantle valid official api prefix", + cfg: config.AWSBedrock{ + Region: "us-east-1", + BaseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic", + Protocol: config.BedrockProtocolMantle, + }, + }, + { + name: "mantle valid proxy api prefix", + cfg: config.AWSBedrock{ + Region: "us-east-1", + BaseURL: "https://proxy.internal/proxy", + Protocol: config.BedrockProtocolMantle, + }, + }, + { + name: "mantle missing region", + cfg: config.AWSBedrock{ + BaseURL: "https://bedrock-mantle.us-east-1.api.aws", + Protocol: config.BedrockProtocolMantle, + }, + errorMsg: "region required", + }, + { + name: "mantle missing base url", + cfg: config.AWSBedrock{ + Region: "us-east-1", + Protocol: config.BedrockProtocolMantle, + }, + errorMsg: "base_url required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.cfg.Validate() + if tt.errorMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + return + } + require.NoError(t, err) + }) + } +} diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index b1e989b6a06..c9566ad724a 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -1,10 +1,14 @@ package messages import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" + "io" "math" "net/http" "strconv" @@ -17,6 +21,7 @@ import ( "github.com/anthropics/anthropic-sdk-go/shared" "github.com/anthropics/anthropic-sdk-go/shared/constant" "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -64,6 +69,22 @@ var bedrockSupportedBetaFlags = map[string]bool{ "tool-examples-2025-10-29": true, } +// BedrockPRMUserAgent is Coder's AWS attribution marker for outbound +// Bedrock requests. +// +// It is appended to Bedrock User-Agent headers so AWS can recognize the +// traffic as Coder-associated Bedrock usage. +const BedrockPRMUserAgent = "sdk-ua-app-id/APN_1.1%2Fpc_cdfmjwn8i6u8l9fwz8h82e4w3%24" + +// bedrockMantleSigningService is the AWS SigV4 service name for mantle. +const bedrockMantleSigningService = "bedrock-mantle" + +func appendBedrockPRMUserAgent(req *http.Request) { + if ua := req.Header.Get("User-Agent"); ua != "" { + req.Header.Set("User-Agent", ua+" "+BedrockPRMUserAgent) + } +} + // BedrockRuntime carries everything a Bedrock-backed interception needs: the // static Bedrock config plus the AWS credentials provider. type BedrockRuntime struct { @@ -109,12 +130,29 @@ func (i *interceptionBase) CorrelatingToolCallID() *string { return i.reqPayload.correlatingToolCallID() } +// isBedrockMantle reports whether the interception targets the Bedrock mantle +// protocol. +func (i *interceptionBase) isBedrockMantle() bool { + return i.bedrock != nil && i.bedrock.Cfg.Protocol == aibconfig.BedrockProtocolMantle +} + +// isBedrockInvokeModel reports whether the interception targets the Bedrock +// InvokeModel protocol. +func (i *interceptionBase) isBedrockInvokeModel() bool { + return i.bedrock != nil && + (i.bedrock.Cfg.Protocol == "" || i.bedrock.Cfg.Protocol == aibconfig.BedrockProtocolInvokeModel) +} + func (i *interceptionBase) Model() string { if len(i.reqPayload) == 0 { return "coder-aibridge-unknown" } - if i.bedrock != nil { + // InvokeModel is the only protocol that remaps the model: it replaces the + // client's model with the operator-configured one. Every other case (mantle + // 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 @@ -245,15 +283,25 @@ func (i *interceptionBase) newMessagesService(ctx context.Context, opts ...optio opts = append(opts, option.WithMiddleware(mw)) } - if i.bedrock != nil { + if i.isBedrockInvokeModel() { ctx, cancel := context.WithTimeout(ctx, time.Second*30) defer cancel() - bedrockOpts, err := i.withAWSBedrockOptions(ctx) + bedrockOpts, err := i.withBedrockInvokeModelOptions(ctx) + if err != nil { + return anthropic.MessageService{}, err + } + opts = append(opts, bedrockOpts...) + i.augmentRequestForBedrockInvokeModel() + } + + if i.isBedrockMantle() { + ctx, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + bedrockOpts, err := i.withBedrockMantleOptions(ctx) if err != nil { return anthropic.MessageService{}, err } opts = append(opts, bedrockOpts...) - i.augmentRequestForBedrock() } return anthropic.NewMessageService(opts...), nil @@ -267,23 +315,18 @@ func (i *interceptionBase) withBody() option.RequestOption { return option.WithRequestBody("application/json", []byte(i.reqPayload)) } -// withAWSBedrockOptions returns request options for authenticating with AWS Bedrock. +// withBedrockInvokeModelOptions returns request options for the AWS Bedrock +// InvokeModel protocol. // // 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) { +func (i *interceptionBase) withBedrockInvokeModelOptions(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") - } - if cfg.Model == "" { - return nil, xerrors.New("model required") - } - if cfg.SmallFastModel == "" { - return nil, xerrors.New("small fast model required") + if err := cfg.Validate(); err != nil { + return nil, err } // Fail fast: ensure credentials can be resolved before signing. Served from @@ -300,9 +343,7 @@ func (i *interceptionBase) withAWSBedrockOptions(ctx context.Context) ([]option. var out []option.RequestOption out = append(out, option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { - if ua := req.Header.Get("User-Agent"); ua != "" { - req.Header.Set("User-Agent", ua+" sdk-ua-app-id/APN_1.1%2Fpc_cdfmjwn8i6u8l9fwz8h82e4w3%24") - } + appendBedrockPRMUserAgent(req) return next(req) })) out = append(out, bedrock.WithConfig(awsCfg)) @@ -315,11 +356,66 @@ func (i *interceptionBase) withAWSBedrockOptions(ctx context.Context) ([]option. return out, nil } -// augmentRequestForBedrock will change the model used for the request since AWS Bedrock doesn't support +// withBedrockMantleOptions returns request options for the AWS Bedrock mantle +// endpoint (bedrock-mantle.{region}.api.aws/anthropic/v1/messages). It speaks +// the native Messages wire format, so this middleware only SigV4-signs the +// request (service "bedrock-mantle") and forwards it; the response is plain +// SSE. +func (i *interceptionBase) withBedrockMantleOptions(ctx context.Context) ([]option.RequestOption, error) { + cfg := i.bedrock.Cfg + if err := cfg.Validate(); err != nil { + return nil, err + } + + // Fail fast: ensure credentials can be resolved before signing. Served from + // 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) + } + + signer := v4.NewSigner() + var out []option.RequestOption + out = append(out, option.WithBaseURL(cfg.BaseURL)) + // Appended last so it runs innermost (right before the HTTP send) and signs + // the request after all other headers are set. + out = append(out, option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + appendBedrockPRMUserAgent(req) + + creds, err := i.bedrock.Creds.Retrieve(req.Context()) + if err != nil { + return nil, err + } + + // SigV4 requires a payload hash, so read the body to hash it and then + // restore it for the downstream HTTP client to send. + var body []byte + if req.Body != nil { + var err error + body, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + } + + hash := sha256.Sum256(body) + if err := signer.SignHTTP(req.Context(), creds, req, hex.EncodeToString(hash[:]), bedrockMantleSigningService, cfg.Region, time.Now()); err != nil { + return nil, err + } + return next(req) + })) + + return out, nil +} + +// augmentRequestForBedrockInvokeModel will change the model used for the request since AWS Bedrock doesn't support // 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 (Opus 4.7+). -func (i *interceptionBase) augmentRequestForBedrock() { +func (i *interceptionBase) augmentRequestForBedrockInvokeModel() { if i.bedrock == nil { return } diff --git a/aibridge/intercept/messages/base_internal_test.go b/aibridge/intercept/messages/base_internal_test.go index 714f32b2c0f..eef99ea86e1 100644 --- a/aibridge/intercept/messages/base_internal_test.go +++ b/aibridge/intercept/messages/base_internal_test.go @@ -183,7 +183,7 @@ func TestAWSBedrockValidation(t *testing.T) { Creds: credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""), }, } - opts, err := base.withAWSBedrockOptions(context.Background()) + opts, err := base.withBedrockInvokeModelOptions(context.Background()) if tt.expectError { require.Error(t, err) @@ -198,12 +198,12 @@ func TestAWSBedrockValidation(t *testing.T) { // TestAWSBedrockOptionsRequireRuntime verifies that option assembly fails when // the Bedrock runtime was not set. This should never happen in practice, since -// withAWSBedrockOptions is only called when i.bedrock != nil. +// withBedrockInvokeModelOptions is only called when i.bedrock != nil. func TestAWSBedrockOptionsRequireRuntime(t *testing.T) { t.Parallel() base := &interceptionBase{} - _, err := base.withAWSBedrockOptions(context.Background()) + _, err := base.withBedrockInvokeModelOptions(context.Background()) require.Error(t, err) require.Contains(t, err.Error(), "nil bedrock runtime") } @@ -800,7 +800,7 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { logger: slog.Make(), } - i.augmentRequestForBedrock() + i.augmentRequestForBedrockInvokeModel() thinkingType := gjson.GetBytes(i.reqPayload, "thinking.type") if tc.expectThinkingType == "" { @@ -1126,3 +1126,75 @@ func TestWriteUpstreamError(t *testing.T) { }) } } + +// TestBedrockMantleIsPassthrough verifies a mantle provider reports the mantle +// protocol and that Model() returns the client's model. +func TestBedrockMantleIsPassthrough(t *testing.T) { + t.Parallel() + + 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", + Protocol: config.BedrockProtocolMantle, + }, + }, + logger: slog.Make(), + } + + require.True(t, i.isBedrockMantle()) + require.False(t, i.isBedrockInvokeModel()) + require.Equal(t, "anthropic.claude-opus-4-8", i.Model()) +} + +// TestAWSMantleOptionsValidation verifies the mantle protocol requires a +// region (it scopes the SigV4 signature) but NOT model fields. +func TestAWSMantleOptionsValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg config.AWSBedrock + errorMsg string + }{ + { + name: "valid without model fields", + cfg: config.AWSBedrock{ + Region: "us-east-1", + BaseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic", + Protocol: config.BedrockProtocolMantle, + }, + }, + { + name: "missing region even with base url", + cfg: config.AWSBedrock{ + BaseURL: "https://proxy.internal", + Protocol: config.BedrockProtocolMantle, + }, + errorMsg: "region required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + base := &interceptionBase{ + bedrock: &BedrockRuntime{ + Cfg: tt.cfg, + Creds: credentials.NewStaticCredentialsProvider("test-key", "test-secret", ""), + }, + } + opts, err := base.withBedrockMantleOptions(context.Background()) + if tt.errorMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + require.NotEmpty(t, opts) + } + }) + } +} diff --git a/aibridge/internal/integrationtest/bridge_internal_test.go b/aibridge/internal/integrationtest/bridge_internal_test.go index 024652928e7..1b515735da4 100644 --- a/aibridge/internal/integrationtest/bridge_internal_test.go +++ b/aibridge/internal/integrationtest/bridge_internal_test.go @@ -36,6 +36,7 @@ import ( "github.com/coder/coder/v2/aibridge/config" "github.com/coder/coder/v2/aibridge/fixtures" "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/messages" "github.com/coder/coder/v2/aibridge/internal/testutil" "github.com/coder/coder/v2/aibridge/mcp" "github.com/coder/coder/v2/aibridge/provider" @@ -377,7 +378,7 @@ func TestAWSBedrockIntegration(t *testing.T) { // Verify PRM attribution is appended to the User-Agent header. ua := received[0].Header.Get("User-Agent") - require.Contains(t, ua, "sdk-ua-app-id/APN_1.1%2Fpc_cdfmjwn8i6u8l9fwz8h82e4w3%24", + require.Contains(t, ua, messages.BedrockPRMUserAgent, "expected AWS PRM attribution in User-Agent header") interceptions := bridgeServer.Recorder.RecordedInterceptions() @@ -388,6 +389,62 @@ func TestAWSBedrockIntegration(t *testing.T) { } }) + // The mantle protocol is a passthrough: the client's model is forwarded in the body + // without remapping, only SigV4 signing (service "bedrock-mantle") is applied. + t.Run("mantle/v1/messages", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.AntSingleBuiltinTool) + upstream := testutil.NewMockUpstream(ctx, t, testutil.NewFixtureResponse(fix)) + + // Mantle needs only region + credentials for signing (no Model fields: + // the client supplies the model). + bedrockCfg := &config.AWSBedrock{ + Region: "us-west-2", + AccessKey: "test-access-key", + AccessKeySecret: "test-secret-key", + BaseURL: upstream.URL + "/anthropic", // Use the mock server. + Protocol: config.BedrockProtocolMantle, + } + // The client's model must be forwarded unchanged. + wantModel := gjson.GetBytes(fix.Request(), "model").String() + require.NotEmpty(t, wantModel) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withCustomProvider(aibridgetest.NewAnthropicProvider(t, anthropicCfg(upstream.URL, apiKey), bedrockCfg)), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, fix.Request()) + require.NoError(t, err) + defer resp.Body.Close() + + received := upstream.ReceivedRequests() + require.Len(t, received, 1) + + // Native passthrough: /anthropic Messages path, model kept in the body + // unchanged. + require.Equal(t, "/anthropic/v1/messages", received[0].Path) + require.Equal(t, wantModel, gjson.GetBytes(received[0].Body, "model").String(), + "model should be forwarded unchanged") + + // SigV4-signed for the bedrock-mantle service. + authHeader := received[0].Header.Get("Authorization") + require.True(t, strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256"), "missing SigV4 auth: %q", authHeader) + require.Contains(t, authHeader, "/bedrock-mantle/aws4_request", + "signature must be scoped to the bedrock-mantle service") + + require.Contains(t, received[0].Header.Get("User-Agent"), + messages.BedrockPRMUserAgent) + + interceptions := bridgeServer.Recorder.RecordedInterceptions() + require.Len(t, interceptions, 1) + require.Equal(t, wantModel, interceptions[0].Model) + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + // Tests that Bedrock-incompatible fields are stripped and adaptive thinking // is handled correctly per model. Different Bedrock model names trigger // different behavior for beta flag filtering and field stripping. diff --git a/cli/aibridged.go b/cli/aibridged.go index addeb3f9598..230f33e5119 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -212,6 +212,7 @@ func protoToProviderSpec(pp *proto.AIProvider) aiProviderSpec { ) bedrock.RoleARN = b.GetRoleArn() bedrock.ExternalID = b.GetExternalId() + bedrock.Protocol = codersdk.AIProviderBedrockProtocol(b.GetProtocol()) spec.Bedrock = ptr.Ref(bedrock) } return spec @@ -354,6 +355,7 @@ func bedrockConfig(baseURL string, bedrock *codersdk.AIProviderBedrockSettings) SmallFastModel: bedrockSettings.SmallFastModel, RoleARN: bedrockSettings.RoleARN, ExternalID: bedrockSettings.ExternalID, + Protocol: config.BedrockProtocol(bedrockSettings.ResolvedProtocol()), } } diff --git a/coderd/aibridged/proto/aibridged.pb.go b/coderd/aibridged/proto/aibridged.pb.go index 2ca74b6333a..b1f8ae9e506 100644 --- a/coderd/aibridged/proto/aibridged.pb.go +++ b/coderd/aibridged/proto/aibridged.pb.go @@ -1583,6 +1583,9 @@ type AIProviderKindBedrock struct { SmallFastModel string `protobuf:"bytes,5,opt,name=small_fast_model,json=smallFastModel,proto3" json:"small_fast_model,omitempty"` RoleArn string `protobuf:"bytes,6,opt,name=role_arn,json=roleArn,proto3" json:"role_arn,omitempty"` ExternalId string `protobuf:"bytes,7,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` + // protocol selects the Bedrock wire protocol ("invoke-model" or "mantle"). + // Empty fallbacks to invoke-model. + Protocol string `protobuf:"bytes,8,opt,name=protocol,proto3" json:"protocol,omitempty"` } func (x *AIProviderKindBedrock) Reset() { @@ -1666,6 +1669,13 @@ func (x *AIProviderKindBedrock) GetExternalId() string { return "" } +func (x *AIProviderKindBedrock) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + var File_coderd_aibridged_proto_aibridged_proto protoreflect.FileDescriptor var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ @@ -1955,7 +1965,7 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x65, 0x79, 0x73, 0x12, 0x36, 0x0a, 0x07, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4b, 0x69, 0x6e, 0x64, 0x42, 0x65, 0x64, 0x72, 0x6f, - 0x63, 0x6b, 0x52, 0x07, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x22, 0xf6, 0x01, 0x0a, 0x15, + 0x63, 0x6b, 0x52, 0x07, 0x62, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x22, 0x92, 0x02, 0x0a, 0x15, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x4b, 0x69, 0x6e, 0x64, 0x42, 0x65, 0x64, 0x72, 0x6f, 0x63, 0x6b, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x0a, @@ -1971,77 +1981,79 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x5f, 0x61, 0x72, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x6f, 0x6c, 0x65, 0x41, 0x72, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, - 0x61, 0x6c, 0x49, 0x64, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, - 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, - 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, - 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, - 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, - 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, - 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, - 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, - 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, - 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, - 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, - 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, - 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xaa, - 0x01, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, - 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x61, 0x6c, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, + 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, + 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, + 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, + 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, + 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, + 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, + 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, + 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, + 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, + 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, + 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xaa, 0x01, 0x0a, 0x0a, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x49, 0x73, 0x42, 0x75, 0x64, 0x67, - 0x65, 0x74, 0x45, 0x78, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x45, 0x78, 0x63, 0x65, 0x65, - 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x45, 0x78, 0x63, 0x65, 0x65, - 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x65, 0x0a, 0x14, 0x50, - 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, - 0x74, 0x6f, 0x72, 0x12, 0x4d, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, - 0x69, 0x64, 0x65, 0x72, 0x73, 0x12, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, - 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, - 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, - 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, + 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x49, 0x73, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x45, 0x78, + 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, + 0x73, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x45, 0x78, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, + 0x73, 0x42, 0x75, 0x64, 0x67, 0x65, 0x74, 0x45, 0x78, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x65, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, + 0x4d, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x73, 0x12, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, + 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x49, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, + 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/coderd/aibridged/proto/aibridged.proto b/coderd/aibridged/proto/aibridged.proto index 6b8bd1e7362..de289ff7186 100644 --- a/coderd/aibridged/proto/aibridged.proto +++ b/coderd/aibridged/proto/aibridged.proto @@ -218,4 +218,7 @@ message AIProviderKindBedrock { string small_fast_model = 5; string role_arn = 6; string external_id = 7; + // protocol selects the Bedrock wire protocol ("invoke-model" or "mantle"). + // Empty fallbacks to invoke-model. + string protocol = 8; } diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index 1ebfc7e889d..45962d5f221 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -1011,6 +1011,7 @@ func aiProviderToProto(row database.AIProvider, keys []database.AIProviderKey) ( SmallFastModel: settings.Bedrock.SmallFastModel, RoleArn: settings.Bedrock.RoleARN, ExternalId: settings.Bedrock.ExternalID, + Protocol: string(settings.Bedrock.Protocol), } } diff --git a/codersdk/aiproviders.go b/codersdk/aiproviders.go index 7826a921f2d..61d78a537e8 100644 --- a/codersdk/aiproviders.go +++ b/codersdk/aiproviders.go @@ -281,6 +281,13 @@ func (req CreateAIProviderRequest) Validate() []ValidationError { Detail: "external_id is server-generated and cannot be set", }) } + // The Mantle protocol signs requests with SigV4, which requires a region. + if req.Settings.Bedrock.ResolvedProtocol() == AIProviderBedrockProtocolMantle && req.Settings.Bedrock.Region == "" { + validations = append(validations, ValidationError{ + Field: "settings.region", + Detail: "region is required for the mantle protocol", + }) + } } if req.Type == AIProviderTypeCopilot && len(req.APIKeys) > 0 { validations = append(validations, ValidationError{ diff --git a/codersdk/aiproviders_bedrock.go b/codersdk/aiproviders_bedrock.go index be17f6c95d0..688e18b4710 100644 --- a/codersdk/aiproviders_bedrock.go +++ b/codersdk/aiproviders_bedrock.go @@ -8,6 +8,23 @@ const AIProviderSettingsTypeBedrock = "bedrock" // AIProviderBedrockSettings. const AIProviderBedrockSettingsVersion = 1 +// AIProviderBedrockProtocol selects which AWS Bedrock wire protocol a provider +// targets. +type AIProviderBedrockProtocol string + +const ( + // AIProviderBedrockProtocolInvokeModel is the legacy InvokeModel protocol + // (bedrock-runtime.{region}.amazonaws.com), which translates the native + // Messages request into Bedrock's InvokeModel format. It is the default + // for the zero value. + AIProviderBedrockProtocolInvokeModel AIProviderBedrockProtocol = "invoke-model" + // AIProviderBedrockProtocolMantle is the mantle protocol + // (bedrock-mantle.{region}.api.aws/anthropic/v1/messages). It is a + // passthrough: the gateway forwards the native Messages request body + // unchanged and only applies AWS SigV4 signing (service bedrock-mantle). + AIProviderBedrockProtocolMantle AIProviderBedrockProtocol = "mantle" +) + // AIProviderBedrockSettings configures providers that authenticate // against AWS Bedrock. AccessKey and AccessKeySecret are write-only: // servers strip them from GET and list responses. Both secret fields @@ -40,6 +57,19 @@ type AIProviderBedrockSettings struct { // reject any client-supplied value that differs from the stored one (an // update may echo the stored value back). ExternalID string `json:"external_id,omitempty"` + // Protocol selects the Bedrock wire protocol. An empty value resolves to + // AIProviderBedrockProtocolInvokeModel, so existing rows keep the legacy + // behavior. + Protocol AIProviderBedrockProtocol `json:"protocol,omitempty"` +} + +// ResolvedProtocol returns the configured protocol, mapping the empty value to +// the legacy InvokeModel protocol. +func (b AIProviderBedrockSettings) ResolvedProtocol() AIProviderBedrockProtocol { + if b.Protocol == "" { + return AIProviderBedrockProtocolInvokeModel + } + return b.Protocol } // IsConfigured reports whether any load-bearing Bedrock field is set, diff --git a/codersdk/aiproviders_test.go b/codersdk/aiproviders_test.go index a0ce28ec4ea..5fd1dd238ca 100644 --- a/codersdk/aiproviders_test.go +++ b/codersdk/aiproviders_test.go @@ -212,3 +212,47 @@ func TestAIProviderRequest_ValidateRoleARN(t *testing.T) { }) } } + +func TestAIProviderRequest_ValidateBedrockMantle(t *testing.T) { + t.Parallel() + + hasFieldError := func(vs []codersdk.ValidationError, field string) bool { + for _, v := range vs { + if v.Field == field { + return true + } + } + return false + } + + t.Run("MantleRequiresRegion", func(t *testing.T) { + t.Parallel() + create := codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeBedrock, + Name: "bedrock", + BaseURL: "https://bedrock-mantle.us-east-1.api.aws", + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Protocol: codersdk.AIProviderBedrockProtocolMantle, + }, + }, + } + require.True(t, hasFieldError(create.Validate(), "settings.region")) + + create.Settings.Bedrock.Region = "us-east-1" + require.False(t, hasFieldError(create.Validate(), "settings.region")) + }) + + t.Run("InvokeModelDoesNotRequireRegionField", func(t *testing.T) { + t.Parallel() + // The default (invoke-model) protocol keeps its existing rules; the + // mantle-specific region check must not fire for it. + create := codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeBedrock, + Name: "bedrock", + BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com", + Settings: codersdk.AIProviderSettings{}, + } + require.False(t, hasFieldError(create.Validate(), "settings.region")) + }) +} diff --git a/docs/ai-coder/ai-gateway/providers.md b/docs/ai-coder/ai-gateway/providers.md index 4f9567342ec..4b70c2da6fc 100644 --- a/docs/ai-coder/ai-gateway/providers.md +++ b/docs/ai-coder/ai-gateway/providers.md @@ -192,6 +192,41 @@ To enforce it, add the external ID to the target role's trust policy as an > not enforced, and the role can still be assumed without it. To rotate the > external ID, recreate the provider. +#### Protocol: InvokeModel vs Mantle + +A Bedrock provider selects a **protocol**: + +- **InvokeModel** (default): AI Gateway translates the request into Bedrock's + InvokeModel format (`bedrock-runtime..amazonaws.com`) and configures + the model and small-fast model on the provider. +- **Mantle**: AI Gateway targets the mantle endpoint + (`https://bedrock-mantle..api.aws/anthropic/v1/messages`), which + serves Anthropic models through the native Messages API. The gateway forwards + the request body **unchanged** and only applies AWS SigV4 signing (service + `bedrock-mantle`) with the provider's configured base identity. The client, + not the gateway, produces the Bedrock-legal request, so no model identifiers + are configured on the provider (the client sends the model in each request). + +Both protocols use the same credentials (default chain, static keys, or a +role ARN) and require a **region**. To route Claude Code through a mantle +provider, run it in mantle mode with client-side signing disabled so the +gateway signs centrally, and pass the Coder token as a Bearer credential for +attribution: + +```sh +export CLAUDE_CODE_USE_MANTLE=1 +export CLAUDE_CODE_SKIP_MANTLE_AUTH=1 +export ANTHROPIC_BEDROCK_MANTLE_BASE_URL=https:///api/v2/aibridge/ +export AWS_REGION=us-east-1 +export ANTHROPIC_CUSTOM_HEADERS="Authorization: Bearer " +``` + +Use the `Authorization: Bearer` header for the Coder token rather than +`X-Coder-AI-Governance-Token`: the governance header puts the request in +[Bring Your Own Key](#bring-your-own-key) mode. A Bearer token is authenticated +for attribution and stripped before the gateway signs with the provider's AWS +credentials. + ### GitHub Copilot GitHub Copilot offers three plans: Individual, Business, and Enterprise, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 84a8a8c3ae4..0629f7d80be 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -293,6 +293,14 @@ export interface AIProvider { readonly updated_at: string; } +// From codersdk/aiproviders_bedrock.go +export type AIProviderBedrockProtocol = "invoke-model" | "mantle"; + +export const AIProviderBedrockProtocols: AIProviderBedrockProtocol[] = [ + "invoke-model", + "mantle", +]; + // From codersdk/aiproviders_bedrock.go /** * AIProviderBedrockSettings configures providers that authenticate @@ -342,6 +350,12 @@ export interface AIProviderBedrockSettings { * update may echo the stored value back). */ readonly external_id?: string; + /** + * Protocol selects the Bedrock wire protocol. An empty value resolves to + * AIProviderBedrockProtocolInvokeModel, so existing rows keep the legacy + * behavior without a schema version bump. + */ + readonly protocol?: AIProviderBedrockProtocol; } // From codersdk/aiproviders_bedrock.go diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index af492374278..693993079ea 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -136,6 +136,51 @@ export const AddBedrock: Story = { }, }; +// Mantle is a passthrough protocol selected via the Protocol dropdown. It +// does not configure model fields (the client sends the model), and the +// endpoint hint points at the mantle host. +export const AddBedrockMantle: Story = { + args: { + initialValues: { type: "bedrock", protocol: "mantle" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByLabelText(/^model\s*\*?$/i)).not.toBeInTheDocument(); + expect( + canvas.queryByLabelText(/^small-fast model\s*\*?$/i), + ).not.toBeInTheDocument(); + await expect(canvas.findByText(/bedrock-mantle/i)).resolves.toBeVisible(); + }, +}; + +// Switching the Protocol selector from InvokeModel to Mantle hides the model +// fields and swaps the endpoint hint to the mantle host. +export const AddBedrockSwitchToMantle: Story = { + args: { + initialValues: { type: "bedrock" }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Model fields are present for the default InvokeModel protocol. + await canvas.findByLabelText(/^model\s*\*?$/i); + + const trigger = canvas.getByRole("combobox"); + await userEvent.click(trigger); + const mantleOption = await screen.findByRole("option", { + name: /mantle/i, + }); + await userEvent.click(mantleOption); + + await waitFor(() => + expect(canvas.queryByLabelText(/^model\s*\*?$/i)).not.toBeInTheDocument(), + ); + expect( + canvas.queryByLabelText(/^small-fast model\s*\*?$/i), + ).not.toBeInTheDocument(); + await expect(canvas.findByText(/bedrock-mantle/i)).resolves.toBeVisible(); + }, +}; + // Regression coverage for CODAGT-626. The create form must accept Bedrock // configurations whose credentials come from the AWS environment (IAM // role, instance profile, AWS_PROFILE) instead of static access keys. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 7a87b06d14a..7835aa8a0b5 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -3,7 +3,10 @@ import { TriangleAlertIcon } from "lucide-react"; import { type FC, useEffect, useRef } from "react"; import { Link } from "react-router"; import * as Yup from "yup"; -import type { AIProviderType } from "#/api/typesGenerated"; +import type { + AIProviderBedrockProtocol, + AIProviderType, +} from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { CodeExample } from "#/components/CodeExample/CodeExample"; @@ -12,6 +15,13 @@ import { Form, FormFields } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; import { Label } from "#/components/Label/Label"; import { Link as DocsLink } from "#/components/Link/Link"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; import { docs } from "#/utils/docs"; @@ -23,6 +33,7 @@ export type ProviderFormValues = { name: string; displayName: string; baseUrl: string; + protocol: AIProviderBedrockProtocol; model: string; smallFastModel: string; accessKey: string; @@ -35,14 +46,24 @@ export type ProviderFormValues = { const HTTP_SCHEME_REGEX = /^https?:\/\//i; const BEDROCK_CANONICAL_URL_REGEX = /^https:\/\/bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com\/?$/i; +// Mantle is a passthrough protocol hosted at bedrock-mantle.{region}.api.aws, +// optionally suffixed with /anthropic. +const BEDROCK_MANTLE_URL_REGEX = + /^https:\/\/bedrock-mantle\.([a-z0-9-]+)\.api\.aws(\/anthropic)?\/?$/i; const PROVIDER_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; export const SAVED_CREDENTIAL_MASK = "********"; +// The region lives in the same subdomain slot for both the InvokeModel host +// (bedrock-runtime.{region}.amazonaws.com) and the mantle host +// (bedrock-mantle.{region}.api.aws), so either shape yields the region. export const parseBedrockRegionFromBaseUrl = ( baseUrl: string, ): string | undefined => { - const match = BEDROCK_CANONICAL_URL_REGEX.exec(baseUrl.trim()); + const trimmed = baseUrl.trim(); + const match = + BEDROCK_CANONICAL_URL_REGEX.exec(trimmed) ?? + BEDROCK_MANTLE_URL_REGEX.exec(trimmed); return match?.[1]?.toLowerCase(); }; @@ -65,6 +86,7 @@ const defaultInitialValues: ProviderFormValues = { name: "", displayName: "", baseUrl: "", + protocol: "invoke-model", model: "", smallFastModel: "", accessKey: "", @@ -74,6 +96,14 @@ const defaultInitialValues: ProviderFormValues = { enabled: true, }; +// Base URL prefills used when switching the Bedrock protocol. The region is +// preserved from whatever the user already entered, falling back to us-east-1. +const BEDROCK_MANTLE_DEFAULT_REGION = "us-east-1"; +const bedrockInvokeModelBaseUrl = (region: string) => + `https://bedrock-runtime.${region}.amazonaws.com`; +const bedrockMantleBaseUrl = (region: string) => + `https://bedrock-mantle.${region}.api.aws`; + // Bedrock model defaults mirror codersdk/deployment.go's // aiGatewayBedrockModel and aiGatewayBedrockSmallFastModel defaults // so the create form lands on the same models the env-seeded path @@ -162,16 +192,38 @@ const makeBedrockSchema = (editing: boolean) => .required(), name: makeNameSchema(editing), displayName: makeDisplayNameSchema(editing), + protocol: Yup.string() + .oneOf(["invoke-model", "mantle"] as const) + .required(), baseUrl: Yup.string() .url("Endpoint must be a valid URL") - .matches( - BEDROCK_CANONICAL_URL_REGEX, - "Endpoint must be a standard AWS Bedrock URL.", - ) + .when("protocol", { + is: "mantle", + then: (schema) => + schema.matches( + BEDROCK_MANTLE_URL_REGEX, + "Endpoint must be a Bedrock mantle URL (https://bedrock-mantle.{region}.api.aws).", + ), + otherwise: (schema) => + schema.matches( + BEDROCK_CANONICAL_URL_REGEX, + "Endpoint must be a standard AWS Bedrock URL.", + ), + }) .required("Endpoint is required"), apiKey: Yup.string(), - model: Yup.string().required("Model is required"), - smallFastModel: Yup.string().required("Small-fast model is required"), + // Mantle passthrough forwards the model chosen by the client, so the + // model fields are not configured on the provider. + model: Yup.string().when("protocol", { + is: (protocol: string) => protocol !== "mantle", + then: (schema) => schema.required("Model is required"), + otherwise: (schema) => schema, + }), + smallFastModel: Yup.string().when("protocol", { + is: (protocol: string) => protocol !== "mantle", + then: (schema) => schema.required("Small-fast model is required"), + otherwise: (schema) => schema, + }), accessKey: Yup.string().test( "access-key-paired", BEDROCK_ACCESS_KEY_PAIRED_MESSAGE, @@ -346,6 +398,23 @@ export const ProviderForm: FC = ({ } }; + // Switching protocols rewrites the base URL to the matching host, keeping + // the region the user already entered so they do not retype it. + const handleBedrockProtocolChange = (protocol: AIProviderBedrockProtocol) => { + void form.setFieldValue("protocol", protocol); + const region = + parseBedrockRegionFromBaseUrl(form.values.baseUrl) ?? + BEDROCK_MANTLE_DEFAULT_REGION; + void form.setFieldValue( + "baseUrl", + protocol === "mantle" + ? bedrockMantleBaseUrl(region) + : bedrockInvokeModelBaseUrl(region), + ); + }; + + const isMantle = form.values.protocol === "mantle"; + // When the parent's mutation finishes without an error, treat the just- // submitted values as the new baseline so the unsaved-changes prompt does // not fire on subsequent navigations. React Query reports a missing error @@ -462,49 +531,90 @@ export const ProviderForm: FC = ({ className="w-full" /> +
+ + +

+ {isMantle + ? "Mantle is a passthrough protocol. The client selects the model at request time." + : "InvokeModel calls the standard AWS Bedrock runtime API."} +

+
- In the format of{" "} - - {"https://bedrock-runtime.{region}.amazonaws.com"} - - + isMantle ? ( + <> + In the format of{" "} + {"https://bedrock-mantle.{region}.api.aws"} + + ) : ( + <> + In the format of{" "} + + {"https://bedrock-runtime.{region}.amazonaws.com"} + + + ) } className="w-full" - placeholder={baseUrlPlaceholder(form.values.type)} + placeholder={ + isMantle + ? bedrockMantleBaseUrl(BEDROCK_MANTLE_DEFAULT_REGION) + : baseUrlPlaceholder(form.values.type) + } /> -
- - -
-

- Find available Bedrock model IDs in the{" "} - - AWS Bedrock model cards - - . -

+ {!isMantle && ( + <> +
+ + +
+

+ Find available Bedrock model IDs in the{" "} + + AWS Bedrock model cards + + . +

+ + )}
{ ).toBe("us-west-2"); }); + it("extracts the region from a mantle URL", () => { + expect( + parseBedrockRegionFromBaseUrl("https://bedrock-mantle.us-east-1.api.aws"), + ).toBe("us-east-1"); + }); + + it("extracts the region from a mantle URL with the /anthropic suffix", () => { + expect( + parseBedrockRegionFromBaseUrl( + "https://bedrock-mantle.eu-west-1.api.aws/anthropic", + ), + ).toBe("eu-west-1"); + }); + it("lowercases the region", () => { expect( parseBedrockRegionFromBaseUrl( @@ -379,6 +396,34 @@ describe("providerFormValuesToCreate", () => { expect(s.region).toBe("us-east-1"); }); + it("omits the protocol discriminator and includes the model fields for InvokeModel", () => { + // InvokeModel is the default protocol, so the protocol is left out + // to keep the settings blob minimal; the model fields are configured + // on the provider. + const req = providerFormValuesToCreate(baseBedrockFormValues); + const s = req.settings as unknown as Record; + expect(s.protocol).toBeUndefined(); + expect(s.model).toBe("anthropic.claude-sonnet-4-5"); + expect(s.small_fast_model).toBe("anthropic.claude-haiku-4-5"); + }); + + it("sets protocol=mantle, derives the region, and omits the model fields", () => { + // Mantle is a passthrough: the client sends the model, so the + // provider stores neither model field but keeps the region so the + // backend recognises the Bedrock provider. + const req = providerFormValuesToCreate({ + ...baseBedrockFormValues, + protocol: "mantle", + baseUrl: "https://bedrock-mantle.us-east-1.api.aws", + }); + const s = req.settings as unknown as Record; + expect(s._type).toBe("bedrock"); + expect(s.protocol).toBe("mantle"); + expect(s.region).toBe("us-east-1"); + expect(s.model).toBeUndefined(); + expect(s.small_fast_model).toBeUndefined(); + }); + it("omits the region when the URL is non-canonical", () => { // The form schema blocks non-canonical endpoints before submit; the // helper itself stays strict, returning an undefined region rather @@ -715,6 +760,26 @@ describe("aiProviderToFormValues", () => { expect(values.smallFastModel).toBe("anthropic.claude-haiku-4-5"); }); + it("reads protocol=mantle back and leaves the model fields blank", () => { + const provider: AIProvider = { + ...MockAIProviderBedrock, + settings: settings({ + _type: "bedrock", + protocol: "mantle", + region: "us-east-1", + }), + }; + const values = aiProviderToFormValues(provider); + expect(values.protocol).toBe("mantle"); + expect(values.model).toBe(""); + expect(values.smallFastModel).toBe(""); + }); + + it("defaults protocol to invoke-model for a legacy provider without one", () => { + const values = aiProviderToFormValues(MockAIProviderBedrock); + expect(values.protocol).toBe("invoke-model"); + }); + it("never round-trips Bedrock secrets back to the form", () => { // AccessKey and AccessKeySecret are write-only; the API strips // them from responses, so the form must seed them as empty. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts index 71ca37dcbef..b5887c0f17f 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -1,5 +1,6 @@ import type { AIProvider, + AIProviderBedrockProtocol, AIProviderBedrockSettings, AIProviderKeyMutation, AIProviderSettings, @@ -113,22 +114,30 @@ export const getProviderDisplayType = ( }; const buildBedrockSettings = ( + protocol: AIProviderBedrockProtocol, region: string | undefined, model: string, smallFastModel: string, accessKey: string, accessKeySecret: string, roleArn: string, -): BedrockSettingsWire => ({ - _type: BEDROCK_SETTINGS_TYPE, - _version: BEDROCK_SETTINGS_VERSION, - ...(region ? { region } : {}), - model, - small_fast_model: smallFastModel, - ...(accessKey ? { access_key: accessKey } : {}), - ...(accessKeySecret ? { access_key_secret: accessKeySecret } : {}), - ...(roleArn ? { role_arn: roleArn } : {}), -}); +): BedrockSettingsWire => { + // Mantle is a passthrough protocol: the client sends the model, so the + // provider omits the model fields. The protocol discriminator is only + // emitted for mantle to keep InvokeModel blobs minimal (an absent + // protocol resolves to InvokeModel server-side). + const isMantle = protocol === "mantle"; + return { + _type: BEDROCK_SETTINGS_TYPE, + _version: BEDROCK_SETTINGS_VERSION, + ...(region ? { region } : {}), + ...(isMantle ? { protocol } : {}), + ...(isMantle ? {} : { model, small_fast_model: smallFastModel }), + ...(accessKey ? { access_key: accessKey } : {}), + ...(accessKeySecret ? { access_key_secret: accessKeySecret } : {}), + ...(roleArn ? { role_arn: roleArn } : {}), + }; +}; // Bedrock credentials live in `settings`; openai/anthropic keys go in // `api_keys`. `display_name` is omitted when blank so the server stores @@ -147,6 +156,7 @@ export const providerFormValuesToCreate = ( if (values.type === "bedrock") { const region = parseBedrockRegionFromBaseUrl(base.base_url); const settings = buildBedrockSettings( + values.protocol, region, values.model.trim(), values.smallFastModel.trim(), @@ -222,6 +232,7 @@ export const providerFormValuesToUpdate = ( const region = parseBedrockRegionFromBaseUrl(base.base_url ?? ""); const settings = buildBedrockSettings( + values.protocol, region, values.model.trim(), values.smallFastModel.trim(), @@ -242,13 +253,19 @@ export const aiProviderToFormValues = ( const displayName = provider.display_name || provider.name; if (isBedrockProvider(provider)) { const s = (provider.settings as SettingsWire | null) ?? {}; + // A missing protocol resolves to InvokeModel (legacy rows). Mantle + // providers store no model fields, so leave them blank. + const protocol: AIProviderBedrockProtocol = + s.protocol === "mantle" ? "mantle" : "invoke-model"; + const isMantle = protocol === "mantle"; return { type: "bedrock", name: provider.name, displayName, baseUrl: provider.base_url, - model: s.model ?? "", - smallFastModel: s.small_fast_model ?? "", + protocol, + model: isMantle ? "" : (s.model ?? ""), + smallFastModel: isMantle ? "" : (s.small_fast_model ?? ""), accessKey: "", accessKeySecret: "", roleArn: s.role_arn ?? "", From bc6ba3657bade80f4076c8fb5a5c083c5218a544 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Thu, 9 Jul 2026 19:41:18 +0000 Subject: [PATCH 2/3] chore(aibridge): defer Bedrock mantle UI and docs to follow-up --- docs/ai-coder/ai-gateway/providers.md | 35 ---- .../components/ProviderForm.stories.tsx | 45 ---- .../ProvidersPage/components/ProviderForm.tsx | 196 ++++-------------- .../components/providerFormApiMap.test.ts | 65 ------ .../components/providerFormApiMap.ts | 41 ++-- 5 files changed, 55 insertions(+), 327 deletions(-) diff --git a/docs/ai-coder/ai-gateway/providers.md b/docs/ai-coder/ai-gateway/providers.md index 4b70c2da6fc..4f9567342ec 100644 --- a/docs/ai-coder/ai-gateway/providers.md +++ b/docs/ai-coder/ai-gateway/providers.md @@ -192,41 +192,6 @@ To enforce it, add the external ID to the target role's trust policy as an > not enforced, and the role can still be assumed without it. To rotate the > external ID, recreate the provider. -#### Protocol: InvokeModel vs Mantle - -A Bedrock provider selects a **protocol**: - -- **InvokeModel** (default): AI Gateway translates the request into Bedrock's - InvokeModel format (`bedrock-runtime..amazonaws.com`) and configures - the model and small-fast model on the provider. -- **Mantle**: AI Gateway targets the mantle endpoint - (`https://bedrock-mantle..api.aws/anthropic/v1/messages`), which - serves Anthropic models through the native Messages API. The gateway forwards - the request body **unchanged** and only applies AWS SigV4 signing (service - `bedrock-mantle`) with the provider's configured base identity. The client, - not the gateway, produces the Bedrock-legal request, so no model identifiers - are configured on the provider (the client sends the model in each request). - -Both protocols use the same credentials (default chain, static keys, or a -role ARN) and require a **region**. To route Claude Code through a mantle -provider, run it in mantle mode with client-side signing disabled so the -gateway signs centrally, and pass the Coder token as a Bearer credential for -attribution: - -```sh -export CLAUDE_CODE_USE_MANTLE=1 -export CLAUDE_CODE_SKIP_MANTLE_AUTH=1 -export ANTHROPIC_BEDROCK_MANTLE_BASE_URL=https:///api/v2/aibridge/ -export AWS_REGION=us-east-1 -export ANTHROPIC_CUSTOM_HEADERS="Authorization: Bearer " -``` - -Use the `Authorization: Bearer` header for the Coder token rather than -`X-Coder-AI-Governance-Token`: the governance header puts the request in -[Bring Your Own Key](#bring-your-own-key) mode. A Bearer token is authenticated -for attribution and stripped before the gateway signs with the provider's AWS -credentials. - ### GitHub Copilot GitHub Copilot offers three plans: Individual, Business, and Enterprise, diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 693993079ea..af492374278 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -136,51 +136,6 @@ export const AddBedrock: Story = { }, }; -// Mantle is a passthrough protocol selected via the Protocol dropdown. It -// does not configure model fields (the client sends the model), and the -// endpoint hint points at the mantle host. -export const AddBedrockMantle: Story = { - args: { - initialValues: { type: "bedrock", protocol: "mantle" }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - expect(canvas.queryByLabelText(/^model\s*\*?$/i)).not.toBeInTheDocument(); - expect( - canvas.queryByLabelText(/^small-fast model\s*\*?$/i), - ).not.toBeInTheDocument(); - await expect(canvas.findByText(/bedrock-mantle/i)).resolves.toBeVisible(); - }, -}; - -// Switching the Protocol selector from InvokeModel to Mantle hides the model -// fields and swaps the endpoint hint to the mantle host. -export const AddBedrockSwitchToMantle: Story = { - args: { - initialValues: { type: "bedrock" }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - // Model fields are present for the default InvokeModel protocol. - await canvas.findByLabelText(/^model\s*\*?$/i); - - const trigger = canvas.getByRole("combobox"); - await userEvent.click(trigger); - const mantleOption = await screen.findByRole("option", { - name: /mantle/i, - }); - await userEvent.click(mantleOption); - - await waitFor(() => - expect(canvas.queryByLabelText(/^model\s*\*?$/i)).not.toBeInTheDocument(), - ); - expect( - canvas.queryByLabelText(/^small-fast model\s*\*?$/i), - ).not.toBeInTheDocument(); - await expect(canvas.findByText(/bedrock-mantle/i)).resolves.toBeVisible(); - }, -}; - // Regression coverage for CODAGT-626. The create form must accept Bedrock // configurations whose credentials come from the AWS environment (IAM // role, instance profile, AWS_PROFILE) instead of static access keys. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 7835aa8a0b5..7a87b06d14a 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -3,10 +3,7 @@ import { TriangleAlertIcon } from "lucide-react"; import { type FC, useEffect, useRef } from "react"; import { Link } from "react-router"; import * as Yup from "yup"; -import type { - AIProviderBedrockProtocol, - AIProviderType, -} from "#/api/typesGenerated"; +import type { AIProviderType } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { CodeExample } from "#/components/CodeExample/CodeExample"; @@ -15,13 +12,6 @@ import { Form, FormFields } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; import { Label } from "#/components/Label/Label"; import { Link as DocsLink } from "#/components/Link/Link"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; import { docs } from "#/utils/docs"; @@ -33,7 +23,6 @@ export type ProviderFormValues = { name: string; displayName: string; baseUrl: string; - protocol: AIProviderBedrockProtocol; model: string; smallFastModel: string; accessKey: string; @@ -46,24 +35,14 @@ export type ProviderFormValues = { const HTTP_SCHEME_REGEX = /^https?:\/\//i; const BEDROCK_CANONICAL_URL_REGEX = /^https:\/\/bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com\/?$/i; -// Mantle is a passthrough protocol hosted at bedrock-mantle.{region}.api.aws, -// optionally suffixed with /anthropic. -const BEDROCK_MANTLE_URL_REGEX = - /^https:\/\/bedrock-mantle\.([a-z0-9-]+)\.api\.aws(\/anthropic)?\/?$/i; const PROVIDER_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; export const SAVED_CREDENTIAL_MASK = "********"; -// The region lives in the same subdomain slot for both the InvokeModel host -// (bedrock-runtime.{region}.amazonaws.com) and the mantle host -// (bedrock-mantle.{region}.api.aws), so either shape yields the region. export const parseBedrockRegionFromBaseUrl = ( baseUrl: string, ): string | undefined => { - const trimmed = baseUrl.trim(); - const match = - BEDROCK_CANONICAL_URL_REGEX.exec(trimmed) ?? - BEDROCK_MANTLE_URL_REGEX.exec(trimmed); + const match = BEDROCK_CANONICAL_URL_REGEX.exec(baseUrl.trim()); return match?.[1]?.toLowerCase(); }; @@ -86,7 +65,6 @@ const defaultInitialValues: ProviderFormValues = { name: "", displayName: "", baseUrl: "", - protocol: "invoke-model", model: "", smallFastModel: "", accessKey: "", @@ -96,14 +74,6 @@ const defaultInitialValues: ProviderFormValues = { enabled: true, }; -// Base URL prefills used when switching the Bedrock protocol. The region is -// preserved from whatever the user already entered, falling back to us-east-1. -const BEDROCK_MANTLE_DEFAULT_REGION = "us-east-1"; -const bedrockInvokeModelBaseUrl = (region: string) => - `https://bedrock-runtime.${region}.amazonaws.com`; -const bedrockMantleBaseUrl = (region: string) => - `https://bedrock-mantle.${region}.api.aws`; - // Bedrock model defaults mirror codersdk/deployment.go's // aiGatewayBedrockModel and aiGatewayBedrockSmallFastModel defaults // so the create form lands on the same models the env-seeded path @@ -192,38 +162,16 @@ const makeBedrockSchema = (editing: boolean) => .required(), name: makeNameSchema(editing), displayName: makeDisplayNameSchema(editing), - protocol: Yup.string() - .oneOf(["invoke-model", "mantle"] as const) - .required(), baseUrl: Yup.string() .url("Endpoint must be a valid URL") - .when("protocol", { - is: "mantle", - then: (schema) => - schema.matches( - BEDROCK_MANTLE_URL_REGEX, - "Endpoint must be a Bedrock mantle URL (https://bedrock-mantle.{region}.api.aws).", - ), - otherwise: (schema) => - schema.matches( - BEDROCK_CANONICAL_URL_REGEX, - "Endpoint must be a standard AWS Bedrock URL.", - ), - }) + .matches( + BEDROCK_CANONICAL_URL_REGEX, + "Endpoint must be a standard AWS Bedrock URL.", + ) .required("Endpoint is required"), apiKey: Yup.string(), - // Mantle passthrough forwards the model chosen by the client, so the - // model fields are not configured on the provider. - model: Yup.string().when("protocol", { - is: (protocol: string) => protocol !== "mantle", - then: (schema) => schema.required("Model is required"), - otherwise: (schema) => schema, - }), - smallFastModel: Yup.string().when("protocol", { - is: (protocol: string) => protocol !== "mantle", - then: (schema) => schema.required("Small-fast model is required"), - otherwise: (schema) => schema, - }), + model: Yup.string().required("Model is required"), + smallFastModel: Yup.string().required("Small-fast model is required"), accessKey: Yup.string().test( "access-key-paired", BEDROCK_ACCESS_KEY_PAIRED_MESSAGE, @@ -398,23 +346,6 @@ export const ProviderForm: FC = ({ } }; - // Switching protocols rewrites the base URL to the matching host, keeping - // the region the user already entered so they do not retype it. - const handleBedrockProtocolChange = (protocol: AIProviderBedrockProtocol) => { - void form.setFieldValue("protocol", protocol); - const region = - parseBedrockRegionFromBaseUrl(form.values.baseUrl) ?? - BEDROCK_MANTLE_DEFAULT_REGION; - void form.setFieldValue( - "baseUrl", - protocol === "mantle" - ? bedrockMantleBaseUrl(region) - : bedrockInvokeModelBaseUrl(region), - ); - }; - - const isMantle = form.values.protocol === "mantle"; - // When the parent's mutation finishes without an error, treat the just- // submitted values as the new baseline so the unsaved-changes prompt does // not fire on subsequent navigations. React Query reports a missing error @@ -531,90 +462,49 @@ export const ProviderForm: FC = ({ className="w-full" />
-
- - -

- {isMantle - ? "Mantle is a passthrough protocol. The client selects the model at request time." - : "InvokeModel calls the standard AWS Bedrock runtime API."} -

-
- In the format of{" "} - {"https://bedrock-mantle.{region}.api.aws"} - - ) : ( - <> - In the format of{" "} - - {"https://bedrock-runtime.{region}.amazonaws.com"} - - - ) + <> + In the format of{" "} + + {"https://bedrock-runtime.{region}.amazonaws.com"} + + } className="w-full" - placeholder={ - isMantle - ? bedrockMantleBaseUrl(BEDROCK_MANTLE_DEFAULT_REGION) - : baseUrlPlaceholder(form.values.type) - } + placeholder={baseUrlPlaceholder(form.values.type)} /> - {!isMantle && ( - <> -
- - -
-

- Find available Bedrock model IDs in the{" "} - - AWS Bedrock model cards - - . -

- - )} +
+ + +
+

+ Find available Bedrock model IDs in the{" "} + + AWS Bedrock model cards + + . +

{ ).toBe("us-west-2"); }); - it("extracts the region from a mantle URL", () => { - expect( - parseBedrockRegionFromBaseUrl("https://bedrock-mantle.us-east-1.api.aws"), - ).toBe("us-east-1"); - }); - - it("extracts the region from a mantle URL with the /anthropic suffix", () => { - expect( - parseBedrockRegionFromBaseUrl( - "https://bedrock-mantle.eu-west-1.api.aws/anthropic", - ), - ).toBe("eu-west-1"); - }); - it("lowercases the region", () => { expect( parseBedrockRegionFromBaseUrl( @@ -396,34 +379,6 @@ describe("providerFormValuesToCreate", () => { expect(s.region).toBe("us-east-1"); }); - it("omits the protocol discriminator and includes the model fields for InvokeModel", () => { - // InvokeModel is the default protocol, so the protocol is left out - // to keep the settings blob minimal; the model fields are configured - // on the provider. - const req = providerFormValuesToCreate(baseBedrockFormValues); - const s = req.settings as unknown as Record; - expect(s.protocol).toBeUndefined(); - expect(s.model).toBe("anthropic.claude-sonnet-4-5"); - expect(s.small_fast_model).toBe("anthropic.claude-haiku-4-5"); - }); - - it("sets protocol=mantle, derives the region, and omits the model fields", () => { - // Mantle is a passthrough: the client sends the model, so the - // provider stores neither model field but keeps the region so the - // backend recognises the Bedrock provider. - const req = providerFormValuesToCreate({ - ...baseBedrockFormValues, - protocol: "mantle", - baseUrl: "https://bedrock-mantle.us-east-1.api.aws", - }); - const s = req.settings as unknown as Record; - expect(s._type).toBe("bedrock"); - expect(s.protocol).toBe("mantle"); - expect(s.region).toBe("us-east-1"); - expect(s.model).toBeUndefined(); - expect(s.small_fast_model).toBeUndefined(); - }); - it("omits the region when the URL is non-canonical", () => { // The form schema blocks non-canonical endpoints before submit; the // helper itself stays strict, returning an undefined region rather @@ -760,26 +715,6 @@ describe("aiProviderToFormValues", () => { expect(values.smallFastModel).toBe("anthropic.claude-haiku-4-5"); }); - it("reads protocol=mantle back and leaves the model fields blank", () => { - const provider: AIProvider = { - ...MockAIProviderBedrock, - settings: settings({ - _type: "bedrock", - protocol: "mantle", - region: "us-east-1", - }), - }; - const values = aiProviderToFormValues(provider); - expect(values.protocol).toBe("mantle"); - expect(values.model).toBe(""); - expect(values.smallFastModel).toBe(""); - }); - - it("defaults protocol to invoke-model for a legacy provider without one", () => { - const values = aiProviderToFormValues(MockAIProviderBedrock); - expect(values.protocol).toBe("invoke-model"); - }); - it("never round-trips Bedrock secrets back to the form", () => { // AccessKey and AccessKeySecret are write-only; the API strips // them from responses, so the form must seed them as empty. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts index b5887c0f17f..71ca37dcbef 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/providerFormApiMap.ts @@ -1,6 +1,5 @@ import type { AIProvider, - AIProviderBedrockProtocol, AIProviderBedrockSettings, AIProviderKeyMutation, AIProviderSettings, @@ -114,30 +113,22 @@ export const getProviderDisplayType = ( }; const buildBedrockSettings = ( - protocol: AIProviderBedrockProtocol, region: string | undefined, model: string, smallFastModel: string, accessKey: string, accessKeySecret: string, roleArn: string, -): BedrockSettingsWire => { - // Mantle is a passthrough protocol: the client sends the model, so the - // provider omits the model fields. The protocol discriminator is only - // emitted for mantle to keep InvokeModel blobs minimal (an absent - // protocol resolves to InvokeModel server-side). - const isMantle = protocol === "mantle"; - return { - _type: BEDROCK_SETTINGS_TYPE, - _version: BEDROCK_SETTINGS_VERSION, - ...(region ? { region } : {}), - ...(isMantle ? { protocol } : {}), - ...(isMantle ? {} : { model, small_fast_model: smallFastModel }), - ...(accessKey ? { access_key: accessKey } : {}), - ...(accessKeySecret ? { access_key_secret: accessKeySecret } : {}), - ...(roleArn ? { role_arn: roleArn } : {}), - }; -}; +): BedrockSettingsWire => ({ + _type: BEDROCK_SETTINGS_TYPE, + _version: BEDROCK_SETTINGS_VERSION, + ...(region ? { region } : {}), + model, + small_fast_model: smallFastModel, + ...(accessKey ? { access_key: accessKey } : {}), + ...(accessKeySecret ? { access_key_secret: accessKeySecret } : {}), + ...(roleArn ? { role_arn: roleArn } : {}), +}); // Bedrock credentials live in `settings`; openai/anthropic keys go in // `api_keys`. `display_name` is omitted when blank so the server stores @@ -156,7 +147,6 @@ export const providerFormValuesToCreate = ( if (values.type === "bedrock") { const region = parseBedrockRegionFromBaseUrl(base.base_url); const settings = buildBedrockSettings( - values.protocol, region, values.model.trim(), values.smallFastModel.trim(), @@ -232,7 +222,6 @@ export const providerFormValuesToUpdate = ( const region = parseBedrockRegionFromBaseUrl(base.base_url ?? ""); const settings = buildBedrockSettings( - values.protocol, region, values.model.trim(), values.smallFastModel.trim(), @@ -253,19 +242,13 @@ export const aiProviderToFormValues = ( const displayName = provider.display_name || provider.name; if (isBedrockProvider(provider)) { const s = (provider.settings as SettingsWire | null) ?? {}; - // A missing protocol resolves to InvokeModel (legacy rows). Mantle - // providers store no model fields, so leave them blank. - const protocol: AIProviderBedrockProtocol = - s.protocol === "mantle" ? "mantle" : "invoke-model"; - const isMantle = protocol === "mantle"; return { type: "bedrock", name: provider.name, displayName, baseUrl: provider.base_url, - protocol, - model: isMantle ? "" : (s.model ?? ""), - smallFastModel: isMantle ? "" : (s.small_fast_model ?? ""), + model: s.model ?? "", + smallFastModel: s.small_fast_model ?? "", accessKey: "", accessKeySecret: "", roleArn: s.role_arn ?? "", From 989e2988f1f19bfc7ec2d64c9145236ba2a63873 Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 10 Jul 2026 11:45:28 +0000 Subject: [PATCH 3/3] ci: make fmt --- aibridge/config/config.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/aibridge/config/config.go b/aibridge/config/config.go index 45e0be88ba9..acc1192319f 100644 --- a/aibridge/config/config.go +++ b/aibridge/config/config.go @@ -1,10 +1,10 @@ package config import ( - "errors" - "fmt" "time" + "golang.org/x/xerrors" + "github.com/coder/coder/v2/aibridge/keypool" ) @@ -75,23 +75,23 @@ func (c AWSBedrock) Validate() error { switch c.Protocol { case "", BedrockProtocolInvokeModel: if c.Region == "" && c.BaseURL == "" { - return errors.New("region or base url required") + return xerrors.New("region or base url required") } if c.Model == "" { - return errors.New("model required") + return xerrors.New("model required") } if c.SmallFastModel == "" { - return errors.New("small fast model required") + return xerrors.New("small fast model required") } case BedrockProtocolMantle: if c.Region == "" { - return errors.New("region required") + return xerrors.New("region required") } if c.BaseURL == "" { - return errors.New("base_url required") + return xerrors.New("base_url required") } default: - return fmt.Errorf("unknown bedrock protocol: %q", c.Protocol) + return xerrors.Errorf("unknown bedrock protocol: %q", c.Protocol) } return nil }