diff --git a/coderd/ai_providers.go b/coderd/ai_providers.go index dee812ede7d..71b40597751 100644 --- a/coderd/ai_providers.go +++ b/coderd/ai_providers.go @@ -92,7 +92,7 @@ func (api *API) aiProvidersList(rw http.ResponseWriter, r *http.Request) { }) return } - sdk.Status = aiProviderHostnameWarningFromMap(row, namesByHost) + sdk.Status = api.aiProviderStatus(row, namesByHost) out = append(out, sdk) } httpapi.Write(ctx, rw, http.StatusOK, out) @@ -134,7 +134,7 @@ func (api *API) aiProvidersGet(rw http.ResponseWriter, r *http.Request) { }) return } - sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, row) + sdk.Status = api.aiProviderStatusFromDB(ctx, row) httpapi.Write(ctx, rw, http.StatusOK, sdk) } @@ -256,7 +256,7 @@ func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) { }) return } - sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, row) + sdk.Status = api.aiProviderStatusFromDB(ctx, row) httpapi.Write(ctx, rw, http.StatusCreated, sdk) } @@ -450,7 +450,7 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) { }) return } - sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, updated) + sdk.Status = api.aiProviderStatusFromDB(ctx, updated) httpapi.Write(ctx, rw, http.StatusOK, sdk) } @@ -582,51 +582,43 @@ func buildHostnameCollisionMap(rows []database.AIProvider) map[string][]string { return namesByHost } -// aiProviderHostnameWarningFromMap is the pure helper for the list -// handler. namesByHost is the pre-built collision map from the outer -// rows, in database order (ORDER BY name ASC). Only providers whose -// name appears after another enabled provider on the same hostname -// get a warning; the first provider in database order does not. -func aiProviderHostnameWarningFromMap(provider database.AIProvider, namesByHost map[string][]string) *codersdk.AIProviderStatus { - if !provider.Enabled || provider.Deleted { - return nil - } - host := aibridged.BaseURLHostname(provider.BaseUrl) - if host == "" { - return nil - } - names := namesByHost[host] - if len(names) < 2 { - return nil +// aiProviderStatus collects warnings for an AI provider. +func (api *API) aiProviderStatus(provider database.AIProvider, namesByHost map[string][]string) *codersdk.AIProviderStatus { + var warnings []string + if warning := api.proxyCollisionWarning(provider, namesByHost); warning != "" { + warnings = append(warnings, warning) } - // The first name in database order is the proxy winner. - if provider.Name == names[0] { + if len(warnings) == 0 { return nil } - return &codersdk.AIProviderStatus{Warnings: []string{ - fmt.Sprintf("hostname %q is claimed by provider %q; not reachable via the AI Gateway Proxy, use direct routing (/api/v2/ai-gateway/%s/...) instead", host, names[0], provider.Name), - }} + return &codersdk.AIProviderStatus{Warnings: warnings} } -// aiProviderHostnameWarningFromDB fetches enabled, non-deleted -// providers to determine whether the given provider is excluded from -// proxy routing by hostname collision. Used by the single-row handlers -// (Get, Create, Update) where one extra query is not N+1. -func aiProviderHostnameWarningFromDB(ctx context.Context, logger slog.Logger, store database.Store, provider database.AIProvider) *codersdk.AIProviderStatus { - if !provider.Enabled || provider.Deleted { - return nil +// proxyCollisionWarning reports when another provider claims the proxy +// hostname first in database order. +func (api *API) proxyCollisionWarning(provider database.AIProvider, namesByHost map[string][]string) string { + if !api.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() || !provider.Enabled || provider.Deleted { + return "" } host := aibridged.BaseURLHostname(provider.BaseUrl) if host == "" { - return nil + return "" } - enabledProviders, err := store.GetAIProviders(ctx, database.GetAIProvidersParams{}) + names := namesByHost[host] + if len(names) < 2 || provider.Name == names[0] { + return "" + } + return fmt.Sprintf("Hostname %q is claimed by provider %q. AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/%s/... endpoint).", host, names[0], provider.Name) +} + +// aiProviderStatusFromDB loads status data for a single provider response. +func (api *API) aiProviderStatusFromDB(ctx context.Context, provider database.AIProvider) *codersdk.AIProviderStatus { + rows, err := api.Database.GetAIProviders(ctx, database.GetAIProvidersParams{}) if err != nil { - logger.Error(ctx, "load AI providers for hostname warnings", slog.Error(err)) + api.Logger.Error(ctx, "load AI providers for status", slog.Error(err)) return nil } - namesByHost := buildHostnameCollisionMap(enabledProviders) - return aiProviderHostnameWarningFromMap(provider, namesByHost) + return api.aiProviderStatus(provider, buildHostnameCollisionMap(rows)) } // writeAIProviderError translates an error from the AI provider diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go index ed490494f77..e97c74696ee 100644 --- a/coderd/ai_providers_test.go +++ b/coderd/ai_providers_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" ) // keyIDs extracts the IDs from a slice of AIProviderKey responses, in @@ -1892,12 +1893,23 @@ func TestAIProvidersBedrockExternalID(t *testing.T) { func TestAIProviderHostnameCollisionWarnings(t *testing.T) { t.Parallel() + newClient := func(t *testing.T, proxyEnabled bool) *codersdk.Client { + t.Helper() + return coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: coderdtest.DeploymentValues(t, func(values *codersdk.DeploymentValues) { + values.AI.BridgeProxyConfig.Enabled = serpent.Bool(proxyEnabled) + }), + }) + } + t.Run("CreateGetListReturnsWarning", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + client := newClient(t, true) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) + wantWarnings := []string{`Hostname "api.openai.com" is claimed by provider "first". AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/second/... endpoint).`} + //nolint:gocritic // Owner role is the audience for this endpoint. first, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, @@ -1917,16 +1929,13 @@ func TestAIProviderHostnameCollisionWarnings(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, second.Status) - require.Len(t, second.Status.Warnings, 1) - require.Contains(t, second.Status.Warnings[0], `"first"`) - require.Contains(t, second.Status.Warnings[0], "AI Gateway Proxy") - require.Contains(t, second.Status.Warnings[0], "/api/v2/ai-gateway/second/...") + require.Equal(t, wantWarnings, second.Status.Warnings) //nolint:gocritic // Owner role is the audience for this endpoint. got, err := client.AIProvider(ctx, second.ID.String()) require.NoError(t, err) require.NotNil(t, got.Status) - require.Len(t, got.Status.Warnings, 1) + require.Equal(t, wantWarnings, got.Status.Warnings) //nolint:gocritic // Owner role is the audience for this endpoint. winner, err := client.AIProvider(ctx, first.ID.String()) @@ -1948,15 +1957,17 @@ func TestAIProviderHostnameCollisionWarnings(t *testing.T) { } require.Nil(t, firstListed.Status, "first provider in database order should not get a warning in list") require.NotNil(t, secondListed.Status) - require.Len(t, secondListed.Status.Warnings, 1) + require.Equal(t, wantWarnings, secondListed.Status.Warnings) }) t.Run("UpdateReturnsWarningOnEnable", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + client := newClient(t, true) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) + wantWarnings := []string{`Hostname "api.openai.com" is claimed by provider "first". AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/second/... endpoint).`} + //nolint:gocritic // Owner role is the audience for this endpoint. _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, @@ -1982,15 +1993,82 @@ func TestAIProviderHostnameCollisionWarnings(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, updated.Status) - require.Len(t, updated.Status.Warnings, 1) - require.Contains(t, updated.Status.Warnings[0], `"first"`) - require.Contains(t, updated.Status.Warnings[0], "AI Gateway Proxy") - require.Contains(t, updated.Status.Warnings[0], "/api/v2/ai-gateway/second/...") + require.Equal(t, wantWarnings, updated.Status.Warnings) + }) + + t.Run("UpdateBaseURLReturnsWarning", func(t *testing.T) { + t.Parallel() + client := newClient(t, true) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + wantWarnings := []string{`Hostname "api.openai.com" is claimed by provider "first". AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/second/... endpoint).`} + + //nolint:gocritic // Owner role is the audience for this endpoint. + first, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "first", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.NoError(t, err) + require.Nil(t, first.Status) + + //nolint:gocritic // Owner role is the audience for this endpoint. + second, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "second", + Enabled: true, + BaseURL: "https://api.openai-compat.com/v1", + }) + require.NoError(t, err) + require.Nil(t, second.Status, "distinct hostnames should not collide") + + //nolint:gocritic // Owner role is the audience for this endpoint. + updated, err := client.UpdateAIProvider(ctx, second.ID.String(), codersdk.UpdateAIProviderRequest{ + BaseURL: ptr.Ref("https://api.openai.com/v2"), + }) + require.NoError(t, err) + require.NotNil(t, updated.Status) + require.Equal(t, wantWarnings, updated.Status.Warnings) + }) + + t.Run("ProxyDisabledReturnsNoWarning", func(t *testing.T) { + t.Parallel() + client := newClient(t, false) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "first", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.NoError(t, err) + + //nolint:gocritic // Owner role is the audience for this endpoint. + second, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "second", + Enabled: true, + BaseURL: "https://api.openai.com/v2", + }) + require.NoError(t, err) + require.Nil(t, second.Status) + + //nolint:gocritic // Owner role is the audience for this endpoint. + providers, err := client.AIProviders(ctx) + require.NoError(t, err) + for _, provider := range providers { + require.Nil(t, provider.Status) + } }) t.Run("NoWarningWhenNoCollision", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + client := newClient(t, true) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) @@ -2017,7 +2095,7 @@ func TestAIProviderHostnameCollisionWarnings(t *testing.T) { t.Run("UpdateSelfNoWarning", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + client := newClient(t, true) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitLong) diff --git a/docs/ai-coder/ai-gateway/providers.md b/docs/ai-coder/ai-gateway/providers.md index f912a66610e..38523f9a0a7 100644 --- a/docs/ai-coder/ai-gateway/providers.md +++ b/docs/ai-coder/ai-gateway/providers.md @@ -279,8 +279,8 @@ an API key. ## Provider lifecycle -Every provider carries an explicit status, surfaced through the -[`provider_info`](./monitoring.md#prometheus-metrics) metric and the API: +Every provider carries an explicit status, reported by the [`provider_info`](./monitoring.md#prometheus-metrics) metrics. +`coder_ai_gateway_provider_info` reports `enabled`, `disabled`, and `error`, and `coder_ai_gateway_proxy_provider_info` also reports `proxy_excluded`: | Status | Meaning | Effect on requests | |------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------| diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx index 314fb2216ed..1458666c494 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx @@ -110,7 +110,7 @@ export const WithHostnameCollisionWarning: Story = { enabled: true, status: { warnings: [ - 'hostname "api.openai.com" is claimed by provider "first"; not reachable via the AI Gateway Proxy, use direct routing (/api/v2/ai-gateway/openai/...) instead', + 'Hostname "api.openai.com" is claimed by provider "first". AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/openai/... endpoint).', ], }, },