diff --git a/coderd/ai_providers.go b/coderd/ai_providers.go index 1d71281a0d9..dee812ede7d 100644 --- a/coderd/ai_providers.go +++ b/coderd/ai_providers.go @@ -15,6 +15,7 @@ import ( "cdr.dev/slog/v3" aibridgeutils "github.com/coder/coder/v2/aibridge/utils" + "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -79,6 +80,7 @@ func (api *API) aiProvidersList(rw http.ResponseWriter, r *http.Request) { return } + namesByHost := buildHostnameCollisionMap(rows) out := make([]codersdk.AIProvider, 0, len(rows)) for _, row := range rows { sdk, err := db2sdk.AIProvider(row, keysByProvider[row.ID]) @@ -90,6 +92,7 @@ func (api *API) aiProvidersList(rw http.ResponseWriter, r *http.Request) { }) return } + sdk.Status = aiProviderHostnameWarningFromMap(row, namesByHost) out = append(out, sdk) } httpapi.Write(ctx, rw, http.StatusOK, out) @@ -131,6 +134,7 @@ func (api *API) aiProvidersGet(rw http.ResponseWriter, r *http.Request) { }) return } + sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, row) httpapi.Write(ctx, rw, http.StatusOK, sdk) } @@ -252,6 +256,7 @@ func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) { }) return } + sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, row) httpapi.Write(ctx, rw, http.StatusCreated, sdk) } @@ -445,6 +450,7 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) { }) return } + sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, updated) httpapi.Write(ctx, rw, http.StatusOK, sdk) } @@ -556,6 +562,73 @@ func lookupAIProvider(ctx context.Context, store database.Store, idOrName string return store.GetAIProviderByName(ctx, idOrName) } +// buildHostnameCollisionMap returns a map from normalized hostname to +// the names of enabled, non-deleted providers sharing that hostname, +// in the order the database returned them (ORDER BY name ASC). The +// first name is the proxy winner and does not get a warning; later +// names do. +func buildHostnameCollisionMap(rows []database.AIProvider) map[string][]string { + namesByHost := make(map[string][]string) + for _, row := range rows { + if !row.Enabled || row.Deleted { + continue + } + host := aibridged.BaseURLHostname(row.BaseUrl) + if host == "" { + continue + } + namesByHost[host] = append(namesByHost[host], row.Name) + } + 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 + } + // The first name in database order is the proxy winner. + if provider.Name == names[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), + }} +} + +// 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 + } + host := aibridged.BaseURLHostname(provider.BaseUrl) + if host == "" { + return nil + } + enabledProviders, err := store.GetAIProviders(ctx, database.GetAIProvidersParams{}) + if err != nil { + logger.Error(ctx, "load AI providers for hostname warnings", slog.Error(err)) + return nil + } + namesByHost := buildHostnameCollisionMap(enabledProviders) + return aiProviderHostnameWarningFromMap(provider, namesByHost) +} + // writeAIProviderError translates an error from the AI provider // lookup/update/delete paths into the right HTTP status code. logMsg // labels the log line for operator debugging, and userMsg is the diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go index d3d2d665c78..ed490494f77 100644 --- a/coderd/ai_providers_test.go +++ b/coderd/ai_providers_test.go @@ -1888,3 +1888,153 @@ func TestAIProvidersBedrockExternalID(t *testing.T) { require.Equal(t, externalIDReadOnlyMsg, sdkErr.Message) }) } + +func TestAIProviderHostnameCollisionWarnings(t *testing.T) { + t.Parallel() + + t.Run("CreateGetListReturnsWarning", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //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, "first provider in database order should not get a warning") + + //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.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/...") + + //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) + + //nolint:gocritic // Owner role is the audience for this endpoint. + winner, err := client.AIProvider(ctx, first.ID.String()) + require.NoError(t, err) + require.Nil(t, winner.Status, "first provider in database order should not get a warning on get") + + //nolint:gocritic // Owner role is the audience for this endpoint. + providers, err := client.AIProviders(ctx) + require.NoError(t, err) + require.Len(t, providers, 2) + var firstListed, secondListed codersdk.AIProvider + for _, p := range providers { + switch p.Name { + case "first": + firstListed = p + case "second": + secondListed = p + } + } + 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) + }) + + t.Run("UpdateReturnsWarningOnEnable", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = 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: false, + 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. + updated, err := client.UpdateAIProvider(ctx, second.ID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: ptr.Ref(true), + }) + 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/...") + }) + + t.Run("NoWarningWhenNoCollision", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //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.AIProviderTypeAnthropic, + Name: "second", + Enabled: true, + BaseURL: "https://api.anthropic.com/v1", + }) + require.NoError(t, err) + require.Nil(t, second.Status) + }) + + t.Run("UpdateSelfNoWarning", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //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) + + //nolint:gocritic // Owner role is the audience for this endpoint. + updated, err := client.UpdateAIProvider(ctx, first.ID.String(), codersdk.UpdateAIProviderRequest{ + BaseURL: ptr.Ref("https://api.openai.com/v2"), + }) + require.NoError(t, err) + require.Nil(t, updated.Status, "update-self should not trigger a warning") + }) +} diff --git a/coderd/aibridged/provider.go b/coderd/aibridged/provider.go index 9d2faa030b5..34604f20a20 100644 --- a/coderd/aibridged/provider.go +++ b/coderd/aibridged/provider.go @@ -1,5 +1,10 @@ package aibridged +import ( + "net/url" + "strings" +) + // ProviderStatus is the lifecycle state of a configured AI provider. type ProviderStatus string @@ -14,15 +19,39 @@ const ( // cannot be constructed (missing keys, unsupported type, malformed // settings). ProviderStatusError ProviderStatus = "error" + // ProviderStatusProxyExcluded means another enabled provider + // already claimed its hostname. The provider is still routable via + // the direct path (/api/v2/ai-gateway/{name}/...). + ProviderStatusProxyExcluded ProviderStatus = "proxy_excluded" ) -// ProviderOutcome classifies one ai_providers row, including disabled -// rows (which the pool keeps as 503 stubs) and errored rows (which the -// pool excludes). Err is populated only when Status == ProviderStatusError; -// the build error is already logged at the call site. +// ProviderOutcome classifies one ai_providers row, including +// disabled rows (503 sentinel), errored rows (excluded from pool), +// and proxy-excluded rows (excluded from proxy routing). +// Err is set when Status is Error or ProxyExcluded; the build error +// is already logged at the call site. type ProviderOutcome struct { Name string Type string Status ProviderStatus Err error } + +// BaseURLHostname returns the normalized hostname from a provider +// base URL. It is the canonical normalization used by the proxy +// classifier and the API status check. Scheme-less inputs (from +// env-config seeding) get https:// prepended. +func BaseURLHostname(baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return "" + } + parsed, err := url.Parse(baseURL) + if err == nil && parsed.Hostname() == "" && !strings.Contains(baseURL, "://") { + parsed, err = url.Parse("https://" + baseURL) + } + if err != nil { + return "" + } + return strings.ToLower(parsed.Hostname()) +} diff --git a/coderd/aibridged/provider_test.go b/coderd/aibridged/provider_test.go new file mode 100644 index 00000000000..5820554ca17 --- /dev/null +++ b/coderd/aibridged/provider_test.go @@ -0,0 +1,33 @@ +package aibridged_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/aibridged" +) + +func TestBaseURLHostname(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + baseURL string + want string + }{ + {name: "URL", baseURL: "https://openrouter.ai/api/v1", want: "openrouter.ai"}, + {name: "BareHost", baseURL: "openrouter.ai", want: "openrouter.ai"}, + {name: "HostWithPort", baseURL: "https://openrouter.ai:443/api/v1", want: "openrouter.ai"}, + {name: "UppercaseHost", baseURL: "https://API.OpenRouter.AI/v1", want: "api.openrouter.ai"}, + {name: "IPv6", baseURL: "https://[::1]:8080/v1", want: "::1"}, + {name: "Empty", baseURL: "", want: ""}, + {name: "Invalid", baseURL: "://", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, aibridged.BaseURLHostname(tt.baseURL)) + }) + } +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a8f25d7cce4..e8ff2e15583 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16352,6 +16352,14 @@ const docTemplate = `{ "settings": { "$ref": "#/definitions/codersdk.AIProviderSettings" }, + "status": { + "description": "Status carries runtime routing status; nil when empty.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIProviderStatus" + } + ] + }, "type": { "$ref": "#/definitions/codersdk.AIProviderType" }, @@ -16418,6 +16426,17 @@ const docTemplate = `{ "codersdk.AIProviderSettings": { "type": "object" }, + "codersdk.AIProviderStatus": { + "type": "object", + "properties": { + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.AIProviderType": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 29f75031056..cf4bb26ffe9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14610,6 +14610,14 @@ "settings": { "$ref": "#/definitions/codersdk.AIProviderSettings" }, + "status": { + "description": "Status carries runtime routing status; nil when empty.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIProviderStatus" + } + ] + }, "type": { "$ref": "#/definitions/codersdk.AIProviderType" }, @@ -14676,6 +14684,17 @@ "codersdk.AIProviderSettings": { "type": "object" }, + "codersdk.AIProviderStatus": { + "type": "object", + "properties": { + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "codersdk.AIProviderType": { "type": "string", "enum": [ diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index b69d2437c99..c0c43cc8abb 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -4,7 +4,6 @@ import ( "context" "mime" "net/http" - neturl "net/url" "slices" "strings" @@ -264,30 +263,6 @@ func (k ProviderAPIKeys) Region(provider string) string { return strings.TrimSpace(k.RegionByProvider[normalized]) } -// ProviderBaseURLHostname returns the normalized hostname from a provider base URL. -func ProviderBaseURLHostname(baseURL string) string { - parsed, ok := parseProviderBaseURL(baseURL) - if !ok { - return "" - } - return strings.ToLower(parsed.Hostname()) -} - -func parseProviderBaseURL(baseURL string) (*neturl.URL, bool) { - baseURL = strings.TrimSpace(baseURL) - if baseURL == "" { - return nil, false - } - parsed, err := neturl.Parse(baseURL) - if err == nil && parsed.Hostname() == "" && !strings.Contains(baseURL, "://") { - parsed, err = neturl.Parse("https://" + baseURL) - } - if err != nil { - return nil, false - } - return parsed, true -} - // setRegion records a normalized, non-empty region for a provider. The // RegionByProvider map is allocated lazily so an unused set stays nil, which // keeps Empty() and value comparisons stable. diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index 92905513c7e..d160a13ff90 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -31,28 +31,6 @@ import ( "github.com/coder/coder/v2/testutil" ) -func TestProviderBaseURLHostname(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - baseURL string - want string - }{ - {name: "URL", baseURL: "https://openrouter.ai/api/v1", want: "openrouter.ai"}, - {name: "BareHost", baseURL: "openrouter.ai", want: "openrouter.ai"}, - {name: "HostWithPort", baseURL: "https://openrouter.ai:443/api/v1", want: "openrouter.ai"}, - {name: "Empty", baseURL: "", want: ""}, - {name: "Invalid", baseURL: "://", want: ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tt.want, chatprovider.ProviderBaseURLHostname(tt.baseURL)) - }) - } -} - func TestResolveUserProviderKeys(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/model_routing_aibridge.go b/coderd/x/chatd/model_routing_aibridge.go index 705b4ea375d..e670fc75041 100644 --- a/coderd/x/chatd/model_routing_aibridge.go +++ b/coderd/x/chatd/model_routing_aibridge.go @@ -14,6 +14,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/aibridge" + "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" @@ -105,7 +106,7 @@ func isOpenRouterLikeAIGatewayProvider(provider database.AIProvider) bool { if strings.EqualFold(strings.TrimSpace(provider.Name), "openrouter") { return true } - host := chatprovider.ProviderBaseURLHostname(provider.BaseUrl) + host := aibridged.BaseURLHostname(provider.BaseUrl) return host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai") } diff --git a/codersdk/aiproviders.go b/codersdk/aiproviders.go index d2a73943ee6..6e81e3616a1 100644 --- a/codersdk/aiproviders.go +++ b/codersdk/aiproviders.go @@ -202,6 +202,15 @@ type AIProvider struct { Settings AIProviderSettings `json:"settings"` CreatedAt time.Time `json:"created_at" format:"date-time"` UpdatedAt time.Time `json:"updated_at" format:"date-time"` + + // Status carries runtime routing status; nil when empty. + Status *AIProviderStatus `json:"status,omitempty"` +} + +// AIProviderStatus carries non-fatal routing warnings. Direct +// routing remains available for the provider. +type AIProviderStatus struct { + Warnings []string `json:"warnings,omitempty"` } // AIProviderKey is a single API key registered on a provider. The diff --git a/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index bb03362fc46..83071bf3204 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -144,7 +144,7 @@ The `coder_ai_gateway_cost_control_*` metrics are exported only by `coderd`. | `coder_ai_gateway_proxy_inflight_mitm_requests` | gauge | Number of MITM requests currently being processed. | `provider` | | `coder_ai_gateway_proxy_mitm_requests_total` | counter | Total number of MITM requests handled by the proxy. | `provider` | | `coder_ai_gateway_proxy_mitm_responses_total` | counter | Total number of MITM responses by HTTP status code class. | `code` `provider` | -| `coder_ai_gateway_proxy_provider_info` | gauge | One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal. | `provider_name` `provider_type` `status` | +| `coder_ai_gateway_proxy_provider_info` | gauge | One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error, proxy_excluded) carries the alertable signal. | `provider_name` `provider_type` `status` | | `coder_ai_gateway_proxy_providers_last_reload_success_timestamp_seconds` | gauge | Unix timestamp of the last provider reload that successfully refreshed the router. A gap against the providers_last_reload_timestamp_seconds gauge means the loop is firing but the refresh function is failing. | | | `coder_ai_gateway_proxy_providers_last_reload_timestamp_seconds` | gauge | Unix timestamp of the last provider reload attempt, success or failure. | | | `coder_ai_gateway_tokens_total` | counter | The number of tokens used by intercepted requests. | `client` `initiator_id` `model` `provider` `type` | diff --git a/docs/ai-coder/ai-gateway/monitoring.md b/docs/ai-coder/ai-gateway/monitoring.md index 95a9f027668..d8ac451d2a7 100644 --- a/docs/ai-coder/ai-gateway/monitoring.md +++ b/docs/ai-coder/ai-gateway/monitoring.md @@ -68,15 +68,22 @@ Standalone replicas do not export them. AI Gateway Proxy exports metrics from the `coderd` Prometheus listener. -| Metric | Type | Labels | Purpose | -|--------------------------------------------------------------------------|---------|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------| -| `coder_ai_gateway_proxy_connect_sessions_total` | counter | `type` | CONNECT sessions established, classified as `mitm` or `tunneled`. | -| `coder_ai_gateway_proxy_mitm_requests_total` | counter | `provider` | MITM requests handled by AI Gateway Proxy. | -| `coder_ai_gateway_proxy_inflight_mitm_requests` | gauge | `provider` | MITM requests currently being processed. | -| `coder_ai_gateway_proxy_mitm_responses_total` | counter | `code`, `provider` | MITM responses by HTTP status code. | -| `coder_ai_gateway_proxy_provider_info` | gauge | `provider_name`, `provider_type`, `status` | Routing status of each configured provider. Value is always `1`; `status` is `enabled`, `disabled`, or `error`. | -| `coder_ai_gateway_proxy_providers_last_reload_timestamp_seconds` | gauge | | Unix timestamp of the last attempt to rebuild the proxy routing snapshot. | -| `coder_ai_gateway_proxy_providers_last_reload_success_timestamp_seconds` | gauge | | Unix timestamp of the last successful rebuild of the proxy routing snapshot. | +A `proxy_excluded` status means another enabled provider with the same base URL hostname claimed the proxy route first. The proxy classifier uses first-wins by database name sort order (`ORDER BY name ASC`), so the first provider in that order wins and subsequent providers with the same hostname are excluded from proxy routing. They remain reachable via direct routing at `/api/v2/ai-gateway/{name}/...`. + +When alerting on `coder_ai_gateway_proxy_provider_info`, exclude +`proxy_excluded` from the alert expression unless you want to be +notified of intended duplicate-hostname configurations: +`status!~"enabled|proxy_excluded"`. + +| Metric | Type | Labels | Purpose | +|--------------------------------------------------------------------------|---------|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| +| `coder_ai_gateway_proxy_connect_sessions_total` | counter | `type` | CONNECT sessions established, classified as `mitm` or `tunneled`. | +| `coder_ai_gateway_proxy_mitm_requests_total` | counter | `provider` | MITM requests handled by AI Gateway Proxy. | +| `coder_ai_gateway_proxy_inflight_mitm_requests` | gauge | `provider` | MITM requests currently being processed. | +| `coder_ai_gateway_proxy_mitm_responses_total` | counter | `code`, `provider` | MITM responses by HTTP status code. | +| `coder_ai_gateway_proxy_provider_info` | gauge | `provider_name`, `provider_type`, `status` | Routing status of each configured provider. Value is always `1`; `status` is `enabled`, `disabled`, `error`, or `proxy_excluded`. | +| `coder_ai_gateway_proxy_providers_last_reload_timestamp_seconds` | gauge | | Unix timestamp of the last attempt to rebuild the proxy routing snapshot. | +| `coder_ai_gateway_proxy_providers_last_reload_success_timestamp_seconds` | gauge | | Unix timestamp of the last successful rebuild of the proxy routing snapshot. | Refer to the [Prometheus reference](../../admin/integrations/prometheus.md) for these metrics alongside the other metrics that Coder components export. diff --git a/docs/ai-coder/ai-gateway/providers.md b/docs/ai-coder/ai-gateway/providers.md index 8e2535879d3..07190cfbfdb 100644 --- a/docs/ai-coder/ai-gateway/providers.md +++ b/docs/ai-coder/ai-gateway/providers.md @@ -283,11 +283,12 @@ an API key. Every provider carries an explicit status, surfaced through the [`provider_info`](./monitoring.md#prometheus-metrics) metric and the API: -| Status | Meaning | Effect on requests | -|------------|-------------------------------------------------------------------------------|--------------------------------------------------| -| `enabled` | Configuration is valid and the provider is serving traffic | Requests are proxied to the upstream | -| `disabled` | The provider exists but has been turned off | Requests are rejected with a non-retryable error | -| `error` | The provider is enabled but cannot be built (missing credentials, bad config) | Requests fail; the error is surfaced in metrics | +| Status | Meaning | Effect on requests | +|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | Configuration is valid and the provider is serving traffic | Requests are proxied to the upstream | +| `disabled` | The provider exists but has been turned off | Requests are rejected with a non-retryable error | +| `error` | The provider is enabled but cannot be built (missing credentials, bad config) | Requests fail; the error is surfaced in metrics | +| `proxy_excluded` | Another enabled provider already claims this hostname; the AI Gateway Proxy routes traffic to the first claimant (by database name sort order, `ORDER BY name ASC`) | Proxy-routed requests go to the first claimant; direct routing (`/api/v2/ai-gateway/{name}/...`) still works for this provider | Disabling a provider does not delete it, its credentials, or its historical interception data. Re-enabling restores it to service. diff --git a/docs/reference/api/aiproviders.md b/docs/reference/api/aiproviders.md index cca295406aa..1f6195122b2 100644 --- a/docs/reference/api/aiproviders.md +++ b/docs/reference/api/aiproviders.md @@ -40,6 +40,11 @@ curl -X GET http://coder-server:8080/api/v2/ai/providers \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "settings": {}, + "status": { + "warnings": [ + "string" + ] + }, "type": "openai", "updated_at": "2019-08-24T14:15:22Z" } @@ -56,23 +61,25 @@ curl -X GET http://coder-server:8080/api/v2/ai/providers \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|------------------|----------------------------------------------------------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» api_keys` | array | false | | | -| `»» created_at` | string(date-time) | false | | | -| `»» id` | string(uuid) | false | | | -| `»» masked` | string | false | | | -| `» base_url` | string | false | | | -| `» created_at` | string(date-time) | false | | | -| `» display_name` | string | false | | | -| `» enabled` | boolean | false | | | -| `» icon` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» name` | string | false | | | -| `» settings` | [codersdk.AIProviderSettings](schemas.md#codersdkaiprovidersettings) | false | | | -| `» type` | [codersdk.AIProviderType](schemas.md#codersdkaiprovidertype) | false | | | -| `» updated_at` | string(date-time) | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------|----------------------------------------------------------------------|----------|--------------|--------------------------------------------------------| +| `[array item]` | array | false | | | +| `» api_keys` | array | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» masked` | string | false | | | +| `» base_url` | string | false | | | +| `» created_at` | string(date-time) | false | | | +| `» display_name` | string | false | | | +| `» enabled` | boolean | false | | | +| `» icon` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» name` | string | false | | | +| `» settings` | [codersdk.AIProviderSettings](schemas.md#codersdkaiprovidersettings) | false | | | +| `» status` | [codersdk.AIProviderStatus](schemas.md#codersdkaiproviderstatus) | false | | Status carries runtime routing status; nil when empty. | +| `»» warnings` | array | false | | | +| `» type` | [codersdk.AIProviderType](schemas.md#codersdkaiprovidertype) | false | | | +| `» updated_at` | string(date-time) | false | | | #### Enumerated Values @@ -140,6 +147,11 @@ curl -X POST http://coder-server:8080/api/v2/ai/providers \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "settings": {}, + "status": { + "warnings": [ + "string" + ] + }, "type": "openai", "updated_at": "2019-08-24T14:15:22Z" } @@ -193,6 +205,11 @@ curl -X GET http://coder-server:8080/api/v2/ai/providers/{idOrName} \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "settings": {}, + "status": { + "warnings": [ + "string" + ] + }, "type": "openai", "updated_at": "2019-08-24T14:15:22Z" } @@ -292,6 +309,11 @@ curl -X PATCH http://coder-server:8080/api/v2/ai/providers/{idOrName} \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "settings": {}, + "status": { + "warnings": [ + "string" + ] + }, "type": "openai", "updated_at": "2019-08-24T14:15:22Z" } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 97c7aa66928..4e6bb4571b0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1225,6 +1225,11 @@ title: Schemas "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "settings": {}, + "status": { + "warnings": [ + "string" + ] + }, "type": "openai", "updated_at": "2019-08-24T14:15:22Z" } @@ -1232,19 +1237,20 @@ title: Schemas ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------|------------------------------------------------------------|----------|--------------|-------------| -| `api_keys` | array of [codersdk.AIProviderKey](#codersdkaiproviderkey) | false | | | -| `base_url` | string | false | | | -| `created_at` | string | false | | | -| `display_name` | string | false | | | -| `enabled` | boolean | false | | | -| `icon` | string | false | | | -| `id` | string | false | | | -| `name` | string | false | | | -| `settings` | [codersdk.AIProviderSettings](#codersdkaiprovidersettings) | false | | | -| `type` | [codersdk.AIProviderType](#codersdkaiprovidertype) | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------|------------------------------------------------------------|----------|--------------|--------------------------------------------------------| +| `api_keys` | array of [codersdk.AIProviderKey](#codersdkaiproviderkey) | false | | | +| `base_url` | string | false | | | +| `created_at` | string | false | | | +| `display_name` | string | false | | | +| `enabled` | boolean | false | | | +| `icon` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `settings` | [codersdk.AIProviderSettings](#codersdkaiprovidersettings) | false | | | +| `status` | [codersdk.AIProviderStatus](#codersdkaiproviderstatus) | false | | Status carries runtime routing status; nil when empty. | +| `type` | [codersdk.AIProviderType](#codersdkaiprovidertype) | false | | | +| `updated_at` | string | false | | | ## codersdk.AIProviderConfig @@ -1314,6 +1320,22 @@ title: Schemas None +## codersdk.AIProviderStatus + +```json +{ + "warnings": [ + "string" + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------|-----------------|----------|--------------|-------------| +| `warnings` | array of string | false | | | + ## codersdk.AIProviderType ```json diff --git a/enterprise/aibridgeproxyd/metrics.go b/enterprise/aibridgeproxyd/metrics.go index 4f169434d34..0f89abc6867 100644 --- a/enterprise/aibridgeproxyd/metrics.go +++ b/enterprise/aibridgeproxyd/metrics.go @@ -76,7 +76,7 @@ func NewMetrics(reg prometheus.Registerer) *Metrics { ProviderInfo: factory.NewGaugeVec(prometheus.GaugeOpts{ Name: "provider_info", - Help: "One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal.", + Help: "One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error, proxy_excluded) carries the alertable signal.", }, []string{"provider_name", "provider_type", "status"}), ProvidersLastReloadTimestampSeconds: factory.NewGauge(prometheus.GaugeOpts{ diff --git a/enterprise/aibridgeproxyd/reload.go b/enterprise/aibridgeproxyd/reload.go index 04b1f5438b0..5498fcdf64c 100644 --- a/enterprise/aibridgeproxyd/reload.go +++ b/enterprise/aibridgeproxyd/reload.go @@ -49,11 +49,17 @@ func (s *Server) Reload(ctx context.Context) error { } s.providerRouter.Store(router) for _, p := range reload.Providers { - if p.Status == aibridged.ProviderStatusError { + switch p.Status { + case aibridged.ProviderStatusError: s.logger.Warn(s.ctx, "provider excluded from routing", slog.F("provider", p.Name), slog.Error(p.Err), ) + case aibridged.ProviderStatusProxyExcluded: + s.logger.Info(s.ctx, "provider excluded from proxy routing", + slog.F("provider", p.Name), + slog.Error(p.Err), + ) } } s.recordReloadSuccess(reload) @@ -112,12 +118,10 @@ func (s *Server) mitmHostsCondition() goproxy.ReqConditionFunc { } // buildProviderRouter constructs a router snapshot from a classified -// provider reload. Only providers with Status == -// aibridged.ProviderStatusEnabled are included in the active routing -// tables; the refresh function is responsible for classifying disabled -// and errored rows. First entry wins on duplicate hostnames as a -// defense-in-depth measure even though the refresh function should -// mark duplicates as errors. +// provider reload. Only enabled providers are included in the active +// routing tables. First entry wins on duplicate hostnames as +// defense-in-depth even though the refresh function should mark +// duplicates as proxy-excluded. func buildProviderRouter(reload ProviderReload, allowedPorts []string) (*providerRouter, error) { nameByHost := make(map[string]string, len(reload.Providers)) domains := make([]string, 0, len(reload.Providers)) diff --git a/enterprise/aibridgeproxyd/reload_internal_test.go b/enterprise/aibridgeproxyd/reload_internal_test.go index 5ccba37ec7b..a209cba038d 100644 --- a/enterprise/aibridgeproxyd/reload_internal_test.go +++ b/enterprise/aibridgeproxyd/reload_internal_test.go @@ -137,9 +137,8 @@ func TestBuildProviderRouter(t *testing.T) { t.Run("DefensiveDeduplicatesSameHost", func(t *testing.T) { t.Parallel() - // Refresh function should mark the duplicate as ProviderStatusError; - // buildProviderRouter is defensive and tolerates an enabled duplicate - // by giving the first entry the host (first wins). + // buildProviderRouter is defensive: it tolerates an enabled + // duplicate by giving the first entry the host (first wins). reload := ProviderReload{Providers: []ReloadedProvider{ enabledProvider("first", "api.example.com"), enabledProvider("second", "api.example.com"), diff --git a/enterprise/aibridgeproxyd/reload_test.go b/enterprise/aibridgeproxyd/reload_test.go index 8d3aeeef1e2..1c08f13339b 100644 --- a/enterprise/aibridgeproxyd/reload_test.go +++ b/enterprise/aibridgeproxyd/reload_test.go @@ -5,7 +5,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "slices" "strings" "sync" @@ -15,8 +14,12 @@ import ( promtest "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" "golang.org/x/xerrors" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/enterprise/aibridgeproxyd" "github.com/coder/coder/v2/testutil" @@ -107,8 +110,8 @@ func (s *providerStore) refresh(context.Context) (aibridgeproxyd.ProviderReload, return reload, nil } -// classifyRaw mirrors the production classifier in enterprise/cli so -// the reload tests exercise the same validation rules end-to-end. +// classifyRaw mirrors the production classifier so reload tests +// exercise the same validation rules end-to-end. func classifyRaw(p rawProvider, seenHost map[string]string) aibridgeproxyd.ReloadedProvider { out := aibridgeproxyd.ReloadedProvider{ ProviderOutcome: aibridged.ProviderOutcome{Name: p.name, Type: "openai"}, @@ -118,21 +121,15 @@ func classifyRaw(p rawProvider, seenHost map[string]string) aibridgeproxyd.Reloa out.Err = xerrors.New("base url is empty") return out } - u, err := url.Parse(p.baseURL) - if err != nil { - out.Status = aibridged.ProviderStatusError - out.Err = xerrors.Errorf("invalid base url %q: %w", p.baseURL, err) - return out - } - host := strings.ToLower(u.Hostname()) + host := aibridged.BaseURLHostname(p.baseURL) if host == "" { out.Status = aibridged.ProviderStatusError out.Err = xerrors.Errorf("base url %q has no hostname", p.baseURL) return out } if claimedBy, taken := seenHost[host]; taken { - out.Status = aibridged.ProviderStatusError - out.Err = xerrors.Errorf("hostname %q already claimed by provider %q", host, claimedBy) + out.Status = aibridged.ProviderStatusProxyExcluded + out.Err = xerrors.Errorf("hostname %q already claimed by provider %q; not reachable via the AI Gateway Proxy, use direct routing (/api/v2/ai-gateway/%s/...) instead", host, claimedBy, p.name) return out } seenHost[host] = p.name @@ -260,7 +257,7 @@ func (h *reloadTestHarness) expectProviderStatus(t *testing.T, name, status stri // clears stale entries. func (h *reloadTestHarness) expectProviderAbsent(t *testing.T, name string) { t.Helper() - for _, status := range []string{"enabled", "disabled", "error"} { + for _, status := range []string{"enabled", "disabled", "error", "proxy_excluded"} { assert.Equal(t, 0.0, promtest.ToFloat64(h.metrics.ProviderInfo.WithLabelValues(name, "openai", status)), "expected no provider_info series for %q, found status %q", name, status) } @@ -527,7 +524,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { h := newReloadTestHarness(t) // Two providers with the same BaseURL host: the second is - // classified as error and excluded; the first routes. + // classified as proxy-excluded; the first routes. h.store.set([]rawProvider{ {name: "first", baseURL: "https://shared.invalid/v1"}, {name: "second", baseURL: "https://shared.invalid/v2"}, @@ -536,7 +533,40 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { h.expectRoutedTo(t, "https://shared.invalid/v1/messages", "/first/v1/messages") h.expectProviderStatus(t, "first", "enabled") - h.expectProviderStatus(t, "second", "error") + h.expectProviderStatus(t, "second", "proxy_excluded") + }) + + // DuplicateHostDirectPathRoutesBoth proves the core invariant of + // https://linear.app/codercom/issue/AIGOV-596: two providers + // sharing a hostname are both routable via the direct path even + // though the proxy can only route one. + t.Run("DuplicateHostDirectPathRoutesBoth", func(t *testing.T) { + t.Parallel() + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + backend.Config.SetKeepAlivesEnabled(false) + t.Cleanup(backend.Close) + + // Two providers sharing the same upstream hostname; + // the bridge routes by name, so both are reachable. + logger := slogtest.Make(t, nil) + providers := []aibridge.Provider{ + aibridge.NewOpenAIProvider(config.OpenAI{Name: "first", BaseURL: backend.URL}), + aibridge.NewOpenAIProvider(config.OpenAI{Name: "second", BaseURL: backend.URL}), + } + bridge, err := aibridge.NewRequestBridge(t.Context(), providers, nil, nil, logger, nil, otel.Tracer("test")) + require.NoError(t, err) + + for _, name := range []string{"first", "second"} { + req := httptest.NewRequest(http.MethodPost, "/"+name+"/v1/models", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + bridge.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code, "direct path must route provider %q", name) + } }) t.Run("AllInvalidYieldsEmptyRouter", func(t *testing.T) { diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 4b1fdefe7c8..cf9cfb85756 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -140,8 +140,8 @@ func refreshProxyProviders(db database.Store) aibridgeproxyd.RefreshProvidersFun } // classifyProviderRow evaluates a single ai_providers row for routing. -// seenHost is mutated to track the first provider that claimed each -// hostname so later duplicates can be flagged as errors. +// seenHost tracks the first provider per hostname so later duplicates +// are flagged as proxy-excluded. func classifyProviderRow(row database.AIProvider, seenHost map[string]string) aibridgeproxyd.ReloadedProvider { out := aibridgeproxyd.ReloadedProvider{ ProviderOutcome: aibridged.ProviderOutcome{ @@ -158,21 +158,15 @@ func classifyProviderRow(row database.AIProvider, seenHost map[string]string) ai out.Err = xerrors.New("base url is empty") return out } - u, err := url.Parse(row.BaseUrl) - if err != nil { - out.Status = aibridged.ProviderStatusError - out.Err = xerrors.Errorf("invalid base url %q: %w", row.BaseUrl, err) - return out - } - host := strings.ToLower(u.Hostname()) + host := aibridged.BaseURLHostname(row.BaseUrl) if host == "" { out.Status = aibridged.ProviderStatusError out.Err = xerrors.Errorf("base url %q has no hostname", row.BaseUrl) return out } if claimedBy, taken := seenHost[host]; taken { - out.Status = aibridged.ProviderStatusError - out.Err = xerrors.Errorf("hostname %q already claimed by provider %q", host, claimedBy) + out.Status = aibridged.ProviderStatusProxyExcluded + out.Err = xerrors.Errorf("hostname %q already claimed by provider %q; not reachable via the AI Gateway Proxy, use direct routing (/api/v2/ai-gateway/%s/...) instead", host, claimedBy, row.Name) return out } seenHost[host] = row.Name diff --git a/enterprise/cli/aibridgeproxyd_internal_test.go b/enterprise/cli/aibridgeproxyd_internal_test.go index 4ff24b42f26..2628b70de15 100644 --- a/enterprise/cli/aibridgeproxyd_internal_test.go +++ b/enterprise/cli/aibridgeproxyd_internal_test.go @@ -119,7 +119,7 @@ func TestClassifyProviderRow(t *testing.T) { seen := map[string]string{} got := classifyProviderRow(enabledRow("bad", "://not-a-url"), seen) assert.Equal(t, aibridged.ProviderStatusError, got.Status) - assert.ErrorContains(t, got.Err, "invalid base url") + assert.ErrorContains(t, got.Err, "no hostname") }) t.Run("BaseURLWithoutHostname", func(t *testing.T) { @@ -139,8 +139,9 @@ func TestClassifyProviderRow(t *testing.T) { assert.Equal(t, aibridged.ProviderStatusEnabled, first.Status) second := classifyProviderRow(enabledRow("second", "https://shared.example.com/v2"), seen) - assert.Equal(t, aibridged.ProviderStatusError, second.Status) + assert.Equal(t, aibridged.ProviderStatusProxyExcluded, second.Status) assert.ErrorContains(t, second.Err, "already claimed by provider \"first\"") + assert.ErrorContains(t, second.Err, "/api/v2/ai-gateway/second/...") assert.Equal(t, "first", seen["shared.example.com"], "first wins must not be overwritten") }) diff --git a/scripts/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index ccce769144d..0242f0cb4cb 100644 --- a/scripts/metricsdocgen/metrics +++ b/scripts/metricsdocgen/metrics @@ -262,7 +262,7 @@ coder_ai_gateway_providers_last_reload_timestamp_seconds 0 # HELP coder_ai_gateway_providers_last_reload_success_timestamp_seconds Unix timestamp of the last provider reload that successfully refreshed the pool. A gap against the providers_last_reload_timestamp_seconds gauge means the loop is firing but the refresh function is failing. # TYPE coder_ai_gateway_providers_last_reload_success_timestamp_seconds gauge coder_ai_gateway_providers_last_reload_success_timestamp_seconds 0 -# HELP coder_ai_gateway_proxy_provider_info One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error) carries the alertable signal. +# HELP coder_ai_gateway_proxy_provider_info One series per configured AI provider. Value is always 1; the status label (enabled, disabled, error, proxy_excluded) carries the alertable signal. # TYPE coder_ai_gateway_proxy_provider_info gauge coder_ai_gateway_proxy_provider_info{provider_name="",provider_type="",status=""} 0 # HELP coder_ai_gateway_proxy_providers_last_reload_timestamp_seconds Unix timestamp of the last provider reload attempt, success or failure. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2e693d2cfc3..6e9f23e03dd 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -413,6 +413,10 @@ export interface AIProvider { readonly settings: AIProviderSettings; readonly created_at: string; readonly updated_at: string; + /** + * Status carries runtime routing status; nil when empty. + */ + readonly status?: AIProviderStatus; } // From codersdk/aiproviders_bedrock.go @@ -567,6 +571,15 @@ export interface AIProviderSettings {} */ export const AIProviderSettingsTypeBedrock = "bedrock"; +// From codersdk/aiproviders.go +/** + * AIProviderStatus carries non-fatal routing warnings. Direct + * routing remains available for the provider. + */ +export interface AIProviderStatus { + readonly warnings?: readonly string[]; +} + // From codersdk/chats.go /** * AIProviderSummary is provider metadata embedded in other API responses. diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx index 82f3607b484..314fb2216ed 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, within } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import { Table, TableBody, @@ -102,3 +102,56 @@ export const SupportedHasNoAgentsLabel: Story = { ).not.toBeInTheDocument(); }, }; + +export const WithHostnameCollisionWarning: Story = { + args: { + provider: { + ...MockAIProviderOpenAI, + 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', + ], + }, + }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const badge = canvas.getByText(/warning/i); + await expect(badge).toBeInTheDocument(); + await expect(badge).toHaveAttribute( + "aria-label", + expect.stringContaining("api.openai.com"), + ); + await expect(badge).toHaveAttribute("tabIndex", "0"); + + // Hover shows the tooltip with the warning text. + await userEvent.hover(badge); + await expect( + await canvas.findByText(/api\.openai\.com/, {}, { timeout: 2000 }), + ).toBeInTheDocument(); + + // Keyboard and mouse activation must not navigate the row. + badge.focus(); + await userEvent.keyboard("{Enter}"); + await userEvent.keyboard(" "); + await userEvent.click(badge); + await expect(args.onClick).not.toHaveBeenCalled(); + }, +}; + +export const EmptyWarnings: Story = { + args: { + provider: { + ...MockAIProviderOpenAI, + enabled: true, + status: { warnings: [] }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByText(/warning/i)).not.toBeInTheDocument(); + // An empty warnings array must not leak a bare "0" into the row. + await expect(canvas.queryByText("0")).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx index 88b5ecd0958..aa71dc22410 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderRow.tsx @@ -7,6 +7,11 @@ import { Avatar } from "#/components/Avatar/Avatar"; import { AvatarData } from "#/components/Avatar/AvatarData"; import { Badge } from "#/components/Badge/Badge"; import { TableCell, TableRow } from "#/components/Table/Table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; import { ProviderIcon } from "./ProviderIcon"; import { getProviderDisplayType } from "./providerFormApiMap"; @@ -25,6 +30,14 @@ export const ProviderRow: React.FC = ({ }); const displayName = provider.display_name || provider.name; + // Stop activation from bubbling to a parent `useClickableTableRow` + // row, which navigates on click, Enter (onKeyDown), and Space + // (onKeyUp). Radix composes its own click handler, so the tooltip + // still opens. + const stopPropagation = (event: React.SyntheticEvent) => { + event.stopPropagation(); + }; + return ( @@ -62,6 +75,27 @@ export const ProviderRow: React.FC = ({ Not supported in Agents )} + {provider.status?.warnings && provider.status.warnings.length > 0 && ( + + + + Warning + + + + {provider.status.warnings.map((warning) => ( +

{warning}

+ ))} +
+
+ )}