mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+37
-12
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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/",
|
||||
|
||||
Reference in New Issue
Block a user