feat: serve 503 sentinel for disabled providers (#25794)

_Disclosure: created with Coder Agents._

When providers are disabled, we should serve a sentinel error so the
requesting client (Claude Code, Coder Agents, etc) is informed. Coder
Agents can also conditionalize its display to show a helpful error
message.

---------

Signed-off-by: Danny Kopping <danny@coder.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny Kopping
2026-05-29 10:24:16 +02:00
committed by GitHub
parent 3fb4eefaf7
commit 5b10268827
15 changed files with 269 additions and 37 deletions
+37 -12
View File
@@ -4,6 +4,7 @@ package cli
import (
"context"
"slices"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
@@ -101,10 +102,18 @@ func (r *poolDBReloader) Reload(ctx context.Context) error {
return nil
}
// BuildProviders loads every ai_providers row (including disabled)
// and returns the active provider list plus per-row outcomes. Per-row
// build errors are logged and excluded from providers but recorded in
// outcomes; only DB query failures propagate.
// BuildProviders loads all ai_providers rows (enabled and disabled),
// attaches keys to enabled rows, and constructs the equivalent
// [aibridge.Provider] instances. The database is the single source of
// truth for runtime provider configuration.
//
// Disabled rows produce a Provider stub with Enabled() == false so the
// bridge can answer requests targeting them with a 503 sentinel.
//
// Per-provider construction errors are logged and the offending row is
// excluded from the returned snapshot; only a failure of the DB query
// itself is propagated. This keeps a single misconfigured row from
// taking the whole daemon down.
func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, []aibridged.ProviderOutcome, error) {
//nolint:gocritic // AsAIBridged has a minimal permission set for this purpose.
authCtx := dbauthz.AsAIBridged(ctx)
@@ -160,12 +169,9 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
Name: row.Name,
Type: string(row.Type),
}
if !row.Enabled {
outcome.Status = aibridged.ProviderStatusDisabled
outcomes = append(outcomes, outcome)
continue
if row.Enabled {
enabledCount++
}
enabledCount++
prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg)
if err != nil {
outcome.Status = aibridged.ProviderStatusError
@@ -179,13 +185,17 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
)
continue
}
outcome.Status = aibridged.ProviderStatusEnabled
if row.Enabled {
outcome.Status = aibridged.ProviderStatusEnabled
} else {
outcome.Status = aibridged.ProviderStatusDisabled
}
outcomes = append(outcomes, outcome)
providers = append(providers, prov)
}
if enabledCount > 0 && len(providers) == 0 {
logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers")
if enabledCount > 0 && !slices.ContainsFunc(providers, func(p aibridge.Provider) bool { return p.Enabled() }) {
logger.Warn(ctx, "all enabled ai providers failed to build; only disabled providers remain")
}
return providers, outcomes, nil
@@ -193,11 +203,18 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg
// buildAIProviderFromRow decodes the settings blob and constructs the
// appropriate [aibridge.Provider] for a single ai_providers row.
// Disabled rows return a Provider stub carrying only Name and
// Disabled: true; settings decode, key loading, and credential checks
// are skipped because the provider will never call upstream.
func buildAIProviderFromRow(
row database.AIProvider,
keys []database.AIProviderKey,
cfg codersdk.AIBridgeConfig,
) (aibridge.Provider, error) {
if !row.Enabled {
return disabledProviderFromRow(row)
}
settings, err := db2sdk.AIProviderSettings(row.Settings)
if err != nil {
return nil, xerrors.Errorf("decode settings: %w", err)
@@ -287,6 +304,14 @@ func buildAIProviderFromRow(
}
}
// disabledProviderFromRow builds a Provider stub for a disabled row.
// Using provider.DisabledStub rather than a concrete provider avoids
// duplicating the row.Type switch and ensures that a new AiProviderType
// value is automatically handled without requiring a matching case here.
func disabledProviderFromRow(row database.AIProvider) (aibridge.Provider, error) {
return aibridge.NewDisabledProviderStub(row.Name, string(row.Type)), nil
}
// buildAIProviderKeyPool builds a [keypool.Pool]. Callers must check
// len(keys) > 0 first; keypool.New rejects empty input.
func buildAIProviderKeyPool(keys []database.AIProviderKey) (*keypool.Pool, error) {
+52 -17
View File
@@ -393,25 +393,60 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) {
t.Run("DisabledRowClassifiedAsDisabled", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, nil)
dbgen.AIProvider(t, db, database.AIProvider{
Type: database.AiProviderTypeOpenai,
Name: "openai-off",
BaseUrl: "https://api.openai.com/",
}, func(p *database.InsertAIProviderParams) {
p.Enabled = false
})
for _, tc := range []struct {
name string
row database.AIProvider
}{
{
name: "OpenAI",
row: database.AIProvider{
Type: database.AiProviderTypeOpenai,
Name: "openai-off",
BaseUrl: "https://api.openai.com/",
},
},
{
// Anthropic and Bedrock have stricter credential checks
// than the OpenAI family; the disabled short-circuit
// must reach them too. No keys, no bedrock settings.
name: "Anthropic",
row: database.AIProvider{
Type: database.AiProviderTypeAnthropic,
Name: "anthropic-off",
BaseUrl: "https://api.anthropic.com/",
},
},
{
name: "Bedrock",
row: database.AIProvider{
Type: database.AiProviderTypeBedrock,
Name: "bedrock-off",
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, nil)
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
require.NoError(t, err)
assert.Empty(t, providers, "disabled providers must not be in the active snapshot")
require.Len(t, outcomes, 1)
assert.Equal(t, "openai-off", outcomes[0].Name)
assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status)
assert.NoError(t, outcomes[0].Err)
dbgen.AIProvider(t, db, tc.row, func(p *database.InsertAIProviderParams) {
p.Enabled = false
})
providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger)
require.NoError(t, err)
require.Len(t, providers, 1, "disabled providers stay in the snapshot so the bridge can serve a 503 sentinel")
assert.Equal(t, tc.row.Name, providers[0].Name())
assert.False(t, providers[0].Enabled())
require.Len(t, outcomes, 1)
assert.Equal(t, tc.row.Name, outcomes[0].Name)
assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status)
assert.NoError(t, outcomes[0].Err)
})
}
})
}
+10
View File
@@ -588,6 +588,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "OpenAI",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenai,
Name: "openai",
BaseUrl: "https://api.openai.com/",
@@ -597,6 +598,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Anthropic",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeAnthropic,
Name: "anthropic",
BaseUrl: "https://api.anthropic.com/",
@@ -606,6 +608,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Copilot",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeCopilot,
Name: "copilot",
BaseUrl: "https://api.githubcopilot.com/",
@@ -615,6 +618,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Azure",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeAzure,
Name: "azure",
BaseUrl: "https://example.openai.azure.com/",
@@ -624,6 +628,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Google",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeGoogle,
Name: "google",
BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
@@ -633,6 +638,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "OpenAICompat",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenaiCompat,
Name: "openai-compat",
BaseUrl: "https://compat.example.com/v1/",
@@ -642,6 +648,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "OpenRouter",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeOpenrouter,
Name: "openrouter",
BaseUrl: "https://openrouter.ai/api/v1/",
@@ -651,6 +658,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Vercel",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeVercel,
Name: "vercel",
BaseUrl: "https://api.v0.dev/v1/",
@@ -660,6 +668,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) {
{
name: "Bedrock",
row: database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeBedrock,
Name: "bedrock",
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",
@@ -694,6 +703,7 @@ func TestBuildAIProviderFromRowBedrockWithoutSettings(t *testing.T) {
t.Parallel()
_, err := buildAIProviderFromRow(database.AIProvider{
Enabled: true,
Type: database.AiProviderTypeBedrock,
Name: "bedrock-no-settings",
BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/",