fix: support Bedrock ambient AWS credentials for Agents providers (#24397)

> This PR was authored by Mux on behalf of Mike.

Adds AWS Bedrock ambient credential support to the Agents provider path.
Bedrock providers can now be saved without a stored API key and
authenticated via the standard AWS SDK credential chain on the Coder
server (IAM roles, `AWS_ACCESS_KEY_ID`, etc.). Also fixes missing `Base
URL` forwarding for Bedrock.

## Changes

**Backend runtime** (`coderd/x/chatd/chatprovider/chatprovider.go`):
- New `ProviderAllowsAmbientCredentials(provider)` helper. Currently
returns true only for Bedrock.
- `ModelFromConfig` no longer errors on an empty API key when the
provider is in the ambient-allowed set AND was explicitly resolved via
`ByProvider`. This preserves the policy gate: unresolvable providers
(disabled central key, user-key-required without a user key) still
error.
- `setResolvedProviderAPIKey` internalizes the ambient-credentials
contract via `ProviderAllowsAmbientCredentials`, so a
resolved-but-keyless Bedrock provider is represented as an empty
`ByProvider` entry rather than a post-hoc sentinel patch in the caller.
- `WithAPIKey` is only appended when a token is present.
- `WithBaseURL(baseURL)` is now forwarded for Bedrock (was previously
missing).

**Backend admin API** (`coderd/exp_chats.go`):
- `validateChatProviderCentralAPIKey` exempts Bedrock from requiring a
stored API key when central credentials are enabled.
- AI Gateway separation (`ChatProviderAPIKeysFromDeploymentValues`) is
unchanged. No silent reuse of `CODER_AIBRIDGE_BEDROCK_*` flags.

**Frontend**
(`site/src/pages/AgentsPage/components/ChatModelAdminPanel/*`):
- API Key field is optional for Bedrock when central credentials are
enabled.
- Bedrock-specific descriptions on API Key and Base URL fields
(bearer-token vs ambient modes, `AWS_REGION` guidance).
- Right-aligned "Clear stored token" action switches an existing Bedrock
provider back to ambient mode.
- `hasEffectiveAPIKey` treats Bedrock with central credentials enabled
as configured, so the provider list shows the correct status icon.
- Three new stories: `ProviderFormBedrockAmbientCredentials`,
`ProviderFormBedrockBearerToken`, `ProviderFormBedrockClearBearerToken`.

**Docs** (`docs/ai-coder/agents/models.md`,
`docs/ai-coder/ai-gateway/setup.md`):
- New "Configuring AWS Bedrock" section covering both credential modes,
region resolution, and the Base URL override.
- Explicit note that the `us-east-1` region fallback only applies to
bearer-token mode; ambient credentials require a region from the
standard AWS SDK chain.
- Cross-reference in AI Gateway docs clarifying that
`CODER_AIBRIDGE_BEDROCK_*` flags are a separate configuration path from
Agents.

## Not in scope

- Reusing AI Gateway Bedrock flags as an implicit Agents fallback.
- Per-provider AWS access key, secret, or region fields (would need a
migration and audit-table review).
- IMDS or network-backed credential probes in admin/listing request
paths.

## Related

Dogfood deployment integration:
https://github.com/coder/dogfood/pull/324
This commit is contained in:
Michael Suchacz
2026-04-22 14:20:23 +02:00
committed by GitHub
parent 78d9a220cf
commit 9634739aed
9 changed files with 774 additions and 95 deletions
+23 -7
View File
@@ -5165,6 +5165,7 @@ func (api *API) createChatProvider(rw http.ResponseWriter, r *http.Request) {
}
if err := validateChatProviderCentralAPIKey(
provider,
centralAPIKeyEnabled,
api.hasEffectiveCentralProviderAPIKey(ctx, database.ChatProvider{
Provider: provider,
@@ -5326,6 +5327,7 @@ func (api *API) updateChatProvider(rw http.ResponseWriter, r *http.Request) {
}
if err := validateChatProviderCentralAPIKey(
existing.Provider,
centralAPIKeyEnabled,
api.hasEffectiveCentralProviderAPIKey(ctx, database.ChatProvider{
ID: existing.ID,
@@ -5462,7 +5464,7 @@ func (api *API) listUserChatProviderConfigs(rw http.ResponseWriter, r *http.Requ
hasUserAPIKey := hasUserAPIKeyByProviderID[provider.ID]
hasCentralAPIKeyFallback := provider.Enabled &&
provider.AllowCentralApiKeyFallback &&
api.hasEffectiveCentralProviderAPIKey(ctx, provider, uuid.Nil)
api.hasEffectiveCentralProviderCredentials(ctx, provider, uuid.Nil)
resp = append(
resp,
convertUserChatProviderConfig(
@@ -5548,7 +5550,7 @@ func (api *API) upsertUserChatProviderKey(rw http.ResponseWriter, r *http.Reques
hasCentralAPIKeyFallback := provider.Enabled &&
provider.AllowCentralApiKeyFallback &&
api.hasEffectiveCentralProviderAPIKey(ctx, provider, uuid.Nil)
api.hasEffectiveCentralProviderCredentials(ctx, provider, uuid.Nil)
httpapi.Write(
ctx,
rw,
@@ -6395,15 +6397,17 @@ func validateChatProviderCredentialPolicy(
//nolint:revive // This helper validates central-key requirements.
func validateChatProviderCentralAPIKey(
provider string,
centralEnabled bool,
hasCentralAPIKey bool,
) error {
if centralEnabled && !hasCentralAPIKey {
return xerrors.New(
"API key is required when central API key is enabled.",
)
if !centralEnabled || hasCentralAPIKey {
return nil
}
return nil
if chatprovider.ProviderAllowsAmbientCredentials(provider) {
return nil
}
return xerrors.New("API key is required when central API key is enabled.")
}
// ChatProviderAPIKeysFromDeploymentValues returns deployment-backed chat
@@ -6421,6 +6425,18 @@ func (api *API) hasEffectiveProviderAPIKey(ctx context.Context, provider databas
return api.hasEffectiveCentralProviderAPIKey(ctx, provider, uuid.Nil)
}
func (api *API) hasEffectiveCentralProviderCredentials(
ctx context.Context,
provider database.ChatProvider,
excludeProviderID uuid.UUID,
) bool {
if api.hasEffectiveCentralProviderAPIKey(ctx, provider, excludeProviderID) {
return true
}
return provider.CentralApiKeyEnabled &&
chatprovider.ProviderAllowsAmbientCredentials(provider.Provider)
}
func (api *API) hasEffectiveCentralProviderAPIKey(
ctx context.Context,
provider database.ChatProvider,
+130 -5
View File
@@ -46,7 +46,10 @@ import (
"github.com/coder/websocket/wsjson"
)
const chatProviderAPIKeySizeLimit = 10240
const (
chatProviderAPIKeySizeLimit = 10240
missingCentralKeyMessage = "API key is required when central API key is enabled."
)
func chatDeploymentValues(t testing.TB) *codersdk.DeploymentValues {
t.Helper()
@@ -2098,6 +2101,100 @@ func TestCreateChatProvider(t *testing.T) {
require.Equal(t, codersdk.ChatProviderConfigSourceDatabase, provider.Source)
})
t.Run("AllowsBedrockWithCentralAPIKeyEnabledWithoutStoredKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "bedrock",
DisplayName: "AWS Bedrock",
CentralAPIKeyEnabled: ptr.Ref(true),
})
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, provider.ID)
require.Equal(t, "bedrock", provider.Provider)
require.Equal(t, "AWS Bedrock", provider.DisplayName)
require.True(t, provider.Enabled)
require.False(t, provider.HasAPIKey)
require.True(t, provider.CentralAPIKeyEnabled)
require.Equal(t, codersdk.ChatProviderConfigSourceDatabase, provider.Source)
providers, err := client.ListChatProviders(ctx)
require.NoError(t, err)
for _, listed := range providers {
if listed.Provider == "bedrock" {
require.False(t, listed.HasAPIKey)
return
}
}
t.Fatal("bedrock provider not found")
})
t.Run("ReportsBedrockAmbientFallbackForUserConfigs", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "bedrock",
DisplayName: "AWS Bedrock Fallback",
CentralAPIKeyEnabled: ptr.Ref(true),
AllowUserAPIKey: ptr.Ref(true),
AllowCentralAPIKeyFallback: ptr.Ref(true),
})
require.NoError(t, err)
require.False(t, provider.HasAPIKey)
configs, err := client.ListUserChatProviderConfigs(ctx)
require.NoError(t, err)
require.Len(t, configs, 1)
require.Equal(t, provider.ID, configs[0].ProviderID)
require.Equal(t, provider.Provider, configs[0].Provider)
require.False(t, configs[0].HasUserAPIKey)
require.True(t, configs[0].HasCentralAPIKeyFallback)
})
t.Run("AllowsBedrockWithExplicitAPIKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "bedrock",
DisplayName: "AWS Bedrock Token",
APIKey: "bedrock-bearer-token",
CentralAPIKeyEnabled: ptr.Ref(true),
})
require.NoError(t, err)
require.Equal(t, "bedrock", provider.Provider)
require.Equal(t, "AWS Bedrock Token", provider.DisplayName)
require.True(t, provider.HasAPIKey)
require.True(t, provider.CentralAPIKeyEnabled)
})
t.Run("RejectsMissingCentralAPIKeyForNonBedrock", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
_, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "openai",
DisplayName: "OpenAI",
CentralAPIKeyEnabled: ptr.Ref(true),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, missingCentralKeyMessage, sdkErr.Message)
})
t.Run("InvalidProvider", func(t *testing.T) {
t.Parallel()
@@ -2199,7 +2296,7 @@ func TestCreateChatProvider(t *testing.T) {
Provider: "openai",
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "API key is required when central API key is enabled.", sdkErr.Message)
require.Equal(t, missingCentralKeyMessage, sdkErr.Message)
})
t.Run("RejectsInvalidPolicyTuple", func(t *testing.T) {
@@ -2310,6 +2407,34 @@ func TestUpdateChatProvider(t *testing.T) {
require.Equal(t, baseURL, updated.BaseURL)
})
t.Run("AllowsClearingBedrockAPIKeyWithCentralAPIKeyEnabled", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
_ = coderdtest.CreateFirstUser(t, client.Client)
provider, err := client.CreateChatProvider(ctx, codersdk.CreateChatProviderConfigRequest{
Provider: "bedrock",
DisplayName: "AWS Bedrock",
APIKey: "bedrock-bearer-token",
CentralAPIKeyEnabled: ptr.Ref(true),
})
require.NoError(t, err)
require.True(t, provider.HasAPIKey)
require.True(t, provider.CentralAPIKeyEnabled)
updated, err := client.UpdateChatProvider(ctx, provider.ID, codersdk.UpdateChatProviderConfigRequest{
APIKey: ptr.Ref(""),
CentralAPIKeyEnabled: ptr.Ref(true),
})
require.NoError(t, err)
require.Equal(t, provider.ID, updated.ID)
require.Equal(t, "bedrock", updated.Provider)
require.False(t, updated.HasAPIKey)
require.True(t, updated.CentralAPIKeyEnabled)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
@@ -2408,7 +2533,7 @@ func TestUpdateChatProvider(t *testing.T) {
CentralAPIKeyEnabled: ptr.Ref(true),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "API key is required when central API key is enabled.", sdkErr.Message)
require.Equal(t, missingCentralKeyMessage, sdkErr.Message)
})
t.Run("RejectsClearingLastCentralKey", func(t *testing.T) {
@@ -2428,7 +2553,7 @@ func TestUpdateChatProvider(t *testing.T) {
APIKey: ptr.Ref(""),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "API key is required when central API key is enabled.", sdkErr.Message)
require.Equal(t, missingCentralKeyMessage, sdkErr.Message)
})
t.Run("RejectsEnablingCentralKeyWithoutKey", func(t *testing.T) {
@@ -2449,7 +2574,7 @@ func TestUpdateChatProvider(t *testing.T) {
CentralAPIKeyEnabled: ptr.Ref(true),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "API key is required when central API key is enabled.", sdkErr.Message)
require.Equal(t, missingCentralKeyMessage, sdkErr.Message)
})
t.Run("RejectsInvalidPolicyTuple", func(t *testing.T) {
+50 -8
View File
@@ -74,6 +74,13 @@ func ProviderDisplayName(provider string) string {
return normalized
}
// ProviderAllowsAmbientCredentials reports whether provider can use
// ambient credentials from the Coder server instead of an explicit
// API key.
func ProviderAllowsAmbientCredentials(provider string) bool {
return NormalizeProvider(provider) == fantasybedrock.Name
}
// ProviderAPIKeys contains API keys for provider calls.
type ProviderAPIKeys struct {
OpenAI string
@@ -136,6 +143,17 @@ func (k ProviderAPIKeys) APIKey(provider string) string {
}
}
// HasProvider reports whether a provider has an explicit resolved entry
// in the provider key map, even when the resolved key is empty.
func (k ProviderAPIKeys) HasProvider(provider string) bool {
normalized := NormalizeProvider(provider)
if normalized == "" || k.ByProvider == nil {
return false
}
_, ok := k.ByProvider[normalized]
return ok
}
// BaseURL returns the configured base URL for a provider.
func (k ProviderAPIKeys) BaseURL(provider string) string {
normalized := NormalizeProvider(provider)
@@ -295,6 +313,15 @@ func ResolveUserProviderKeys(
} else {
resolved.UnavailableReason = codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired
}
case normalizedProvider == fantasybedrock.Name && provider.CentralAPIKeyEnabled:
// Bedrock can use ambient AWS credentials from the Coder server
// without an explicit key, but only when the credential policy
// allows central credentials to satisfy the request.
if !provider.AllowUserAPIKey || provider.AllowCentralAPIKeyFallback {
resolved.Available = true
} else {
resolved.UnavailableReason = codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired
}
case provider.AllowUserAPIKey && provider.AllowCentralAPIKeyFallback && provider.CentralAPIKeyEnabled:
// When users can add their own key, a missing central fallback key is
// still something the user can remedy.
@@ -305,14 +332,18 @@ func ResolveUserProviderKeys(
resolved.UnavailableReason = codersdk.ChatModelProviderUnavailableMissingAPIKey
}
setResolvedProviderAPIKey(&merged, normalizedProvider, chosenKey)
setResolvedProviderAPIKey(&merged, normalizedProvider, chosenKey, resolved)
availabilityByProvider[normalizedProvider] = resolved
}
return merged, availabilityByProvider
}
func setResolvedProviderAPIKey(keys *ProviderAPIKeys, provider string, apiKey string) {
// setResolvedProviderAPIKey keeps ByProvider presence aligned with
// resolved provider availability. An empty value means ambient
// credentials may satisfy the provider. An absent entry means the
// provider is not resolvable.
func setResolvedProviderAPIKey(keys *ProviderAPIKeys, provider string, apiKey string, availability ProviderAvailability) {
normalizedProvider := NormalizeProvider(provider)
if normalizedProvider == "" {
return
@@ -329,7 +360,7 @@ func setResolvedProviderAPIKey(keys *ProviderAPIKeys, provider string, apiKey st
case fantasyanthropic.Name:
keys.Anthropic = trimmedKey
}
if trimmedKey != "" {
if trimmedKey != "" || (availability.Available && ProviderAllowsAmbientCredentials(normalizedProvider)) {
keys.ByProvider[normalizedProvider] = trimmedKey
}
}
@@ -1132,7 +1163,8 @@ func ModelFromConfig(
}
apiKey := providerKeys.APIKey(provider)
if apiKey == "" {
if apiKey == "" &&
!(ProviderAllowsAmbientCredentials(provider) && providerKeys.HasProvider(provider)) {
return nil, missingProviderAPIKeyError(provider)
}
baseURL := providerKeys.BaseURL(provider)
@@ -1173,12 +1205,17 @@ func ModelFromConfig(
providerClient, err = fantasyazure.New(azureOpts...)
case fantasybedrock.Name:
bedrockOpts := []fantasybedrock.Option{
fantasybedrock.WithAPIKey(apiKey),
fantasybedrock.WithUserAgent(userAgent),
}
if apiKey != "" {
bedrockOpts = append(bedrockOpts, fantasybedrock.WithAPIKey(apiKey))
}
if len(extraHeaders) > 0 {
bedrockOpts = append(bedrockOpts, fantasybedrock.WithHeaders(extraHeaders))
}
if baseURL != "" {
bedrockOpts = append(bedrockOpts, fantasybedrock.WithBaseURL(baseURL))
}
if httpClient != nil {
bedrockOpts = append(bedrockOpts, fantasybedrock.WithHTTPClient(httpClient))
}
@@ -1260,7 +1297,7 @@ func ModelFromConfig(
return nil, xerrors.Errorf("unsupported model provider %q", provider)
}
if err != nil {
return nil, xerrors.Errorf("create %s provider: %w", provider, err)
return nil, providerCreationError(provider, err)
}
model, err := providerClient.LanguageModel(context.Background(), modelID)
@@ -1270,14 +1307,19 @@ func ModelFromConfig(
return model, nil
}
func providerCreationError(provider string, err error) error {
return xerrors.Errorf("create %s provider: %w", provider, err)
}
// Providers that allow ambient credentials, such as Bedrock, bypass
// this helper only after ResolveUserProviderKeys marks them
// available.
func missingProviderAPIKeyError(provider string) error {
switch provider {
case fantasyanthropic.Name:
return xerrors.New("ANTHROPIC_API_KEY is not set")
case fantasyazure.Name:
return xerrors.New("AZURE_OPENAI_API_KEY is not set")
case fantasybedrock.Name:
return xerrors.New("BEDROCK_API_KEY is not set")
case fantasygoogle.Name:
return xerrors.New("GOOGLE_API_KEY is not set")
case fantasyopenai.Name:
@@ -1,11 +1,14 @@
package chatprovider_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasybedrock "charm.land/fantasy/providers/bedrock"
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
fantasyvercel "charm.land/fantasy/providers/vercel"
@@ -44,6 +47,7 @@ func TestResolveUserProviderKeys(t *testing.T) {
openAIProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
anthropicProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
bedrockProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000003")
tests := []struct {
name string
@@ -52,6 +56,7 @@ func TestResolveUserProviderKeys(t *testing.T) {
userKeys []chatprovider.UserProviderKey
wantAvailability map[string]chatprovider.ProviderAvailability
wantKeys map[string]string
wantKeyPresence map[string]bool
}{
{
name: "CentralOnlyKeyPresent",
@@ -72,6 +77,74 @@ func TestResolveUserProviderKeys(t *testing.T) {
wantKeys: map[string]string{
fantasyopenai.Name: "",
},
wantKeyPresence: map[string]bool{
fantasyopenai.Name: false,
},
},
{
name: "BedrockCentralOnlyAmbientCredentialsEnabled",
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", false, false)},
wantAvailability: map[string]chatprovider.ProviderAvailability{
fantasybedrock.Name: {Available: true},
},
wantKeys: map[string]string{
fantasybedrock.Name: "",
},
wantKeyPresence: map[string]bool{
fantasybedrock.Name: true,
},
},
{
name: "BedrockFallbackAmbientCredentialsEnabled",
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", true, true)},
wantAvailability: map[string]chatprovider.ProviderAvailability{
fantasybedrock.Name: {Available: true},
},
wantKeys: map[string]string{
fantasybedrock.Name: "",
},
wantKeyPresence: map[string]bool{
fantasybedrock.Name: true,
},
},
{
name: "BedrockUserKeyRequiredWithoutFallback",
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", true, false)},
wantAvailability: map[string]chatprovider.ProviderAvailability{
fantasybedrock.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
},
wantKeys: map[string]string{
fantasybedrock.Name: "",
},
wantKeyPresence: map[string]bool{
fantasybedrock.Name: false,
},
},
{
name: "BedrockCentralDisabledMissingAPIKey",
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, false, "", false, false)},
wantAvailability: map[string]chatprovider.ProviderAvailability{
fantasybedrock.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey},
},
wantKeys: map[string]string{
fantasybedrock.Name: "",
},
wantKeyPresence: map[string]bool{
fantasybedrock.Name: false,
},
},
{
name: "BedrockCentralStoredKeyPresent",
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "bedrock-token", false, false)},
wantAvailability: map[string]chatprovider.ProviderAvailability{
fantasybedrock.Name: {Available: true},
},
wantKeys: map[string]string{
fantasybedrock.Name: "bedrock-token",
},
wantKeyPresence: map[string]bool{
fantasybedrock.Name: true,
},
},
{
name: "UserOnlyUserHasKey",
@@ -177,6 +250,14 @@ func TestResolveUserProviderKeys(t *testing.T) {
require.Equal(t, wantAvailability, gotAvailability)
require.Equal(t, tt.wantKeys[provider], keys.APIKey(provider))
}
for provider, wantPresent := range tt.wantKeyPresence {
gotKey, ok := keys.ByProvider[provider]
require.Equal(t, wantPresent, ok, "unexpected key presence for provider %q", provider)
require.Equal(t, wantPresent, keys.HasProvider(provider), "unexpected HasProvider result for provider %q", provider)
if wantPresent {
require.Equal(t, tt.wantKeys[provider], gotKey)
}
}
})
}
}
@@ -742,6 +823,181 @@ func TestCoderHeaders(t *testing.T) {
})
}
func TestModelFromConfig_Bedrock(t *testing.T) {
t.Parallel()
const modelID = "us.anthropic.claude-sonnet-4-20250514-v1:0"
// This verifies the policy gate that permits an empty Bedrock key.
// End-to-end ambient credential auth would need a real AWS
// environment or a more complete mock, which is outside this scope.
t.Run("AllowsEmptyAPIKeyForAmbientCredentials", func(t *testing.T) {
t.Parallel()
model, err := chatprovider.ModelFromConfig(
fantasybedrock.Name,
modelID,
chatprovider.ProviderAPIKeys{
ByProvider: map[string]string{
fantasybedrock.Name: "",
},
},
chatprovider.UserAgent(),
nil,
nil,
)
require.NoError(t, err)
require.NotNil(t, model)
require.Equal(t, fantasybedrock.Name, model.Provider())
})
t.Run("RequiresResolvedProviderForAmbientCredentials", func(t *testing.T) {
t.Parallel()
model, err := chatprovider.ModelFromConfig(
fantasybedrock.Name,
modelID,
chatprovider.ProviderAPIKeys{},
chatprovider.UserAgent(),
nil,
nil,
)
require.Nil(t, model)
require.EqualError(t, err, "API key for provider \"bedrock\" is not set")
})
t.Run("ForwardsBaseURLAndExplicitAPIKey", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
type requestCapture struct {
Path string
Authorization string
UserAgent string
}
requests := make(chan requestCapture, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests <- requestCapture{
Path: r.URL.Path,
Authorization: r.Header.Get("Authorization"),
UserAgent: r.Header.Get("User-Agent"),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bedrockNonStreamingResponse())
}))
defer server.Close()
model, err := chatprovider.ModelFromConfig(
fantasybedrock.Name,
modelID,
chatprovider.ProviderAPIKeys{
ByProvider: map[string]string{
fantasybedrock.Name: "test-key",
},
BaseURLByProvider: map[string]string{
fantasybedrock.Name: server.URL,
},
},
chatprovider.UserAgent(),
nil,
nil,
)
require.NoError(t, err)
require.NotNil(t, model)
_, err = model.Generate(ctx, fantasy.Call{
Prompt: []fantasy.Message{
{
Role: fantasy.MessageRoleUser,
Content: []fantasy.MessagePart{
fantasy.TextPart{Text: "hello"},
},
},
},
})
require.NoError(t, err)
got := testutil.TryReceive(ctx, t, requests)
require.Equal(t, "/model/"+modelID+"/invoke", got.Path)
require.Equal(t, "Bearer test-key", got.Authorization)
require.Equal(t, chatprovider.UserAgent(), got.UserAgent)
})
t.Run("NonBedrockStillRequiresAPIKey", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
provider string
model string
wantErr string
}{
{
name: "OpenAI",
provider: fantasyopenai.Name,
model: "gpt-4",
wantErr: "OPENAI_API_KEY is not set",
},
{
name: "Anthropic",
provider: fantasyanthropic.Name,
model: "claude-sonnet-4-20250514",
wantErr: "ANTHROPIC_API_KEY is not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
model, err := chatprovider.ModelFromConfig(
tt.provider,
tt.model,
chatprovider.ProviderAPIKeys{},
chatprovider.UserAgent(),
nil,
nil,
)
require.Nil(t, model)
require.EqualError(t, err, tt.wantErr)
})
}
})
}
func bedrockNonStreamingResponse() map[string]any {
return map[string]any{
"id": "msg_01Test",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": []any{
map[string]any{
"type": "text",
"text": "Hi there",
},
},
"stop_reason": "end_turn",
"stop_sequence": "",
"usage": map[string]any{
"cache_creation": map[string]any{
"ephemeral_1h_input_tokens": 0,
"ephemeral_5m_input_tokens": 0,
},
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"input_tokens": 5,
"output_tokens": 2,
"server_tool_use": map[string]any{
"web_search_requests": 0,
},
"service_tier": "standard",
},
}
}
// TestModelFromConfig_ExtraHeaders verifies that extra headers passed
// to ModelFromConfig are sent on outgoing LLM API requests. Only the
// OpenAI and Anthropic providers are tested end-to-end because the