fix: surface duplicate AI provider hostname as warning, not error (#28208)

Two live `ai_providers` rows can share the same `base_url` hostname.
This is an intended configuration (AIGOV-97): the direct routing path
(`/api/v2/ai-gateway/{name}/...`) routes by provider name and fully
supports it. Only the MITM proxy path (`aibridgeproxyd`) cannot
disambiguate, because its `nameByHost` map is 1:1.

`classifyProviderRow` marked the second provider as
`ProviderStatusError`, which surfaced as a misleading log warning and a
`status=error` Prometheus label. The provider is not actually broken; it
is fully routable via the direct path. The error was only visible in
server logs and Prometheus, not in the API or UI.

This change:

- Extracts `BaseURLHostname` into `coderd/aibridged` and eliminates
three duplicate implementations (proxy classifier, chatprovider, and the
API layer now uses it for status checks).
- Downgrades the proxy classifier from `ProviderStatusError` to a new
`ProviderStatusProxyExcluded` status. The provider is not broken; it is
just not reachable via the MITM proxy path.
- Adds `Status *AIProviderStatus` to `codersdk.AIProvider` with
`Warnings []string`, matching the `WorkspaceProxy` precedent.
- Populates warnings on create, update, GET, and list when a provider's
hostname collides with another enabled provider.
- Adds an integration test proving that a duplicate provider is routable
via the direct path but not via the proxy path.

Refs AIGOV-596

Generated by Coder Agents



https://github.com/user-attachments/assets/bf323837-5121-4d1b-ab19-593f1d32374e
This commit is contained in:
Cian Johnston
2026-08-24 13:27:22 +01:00
committed by GitHub
parent defd5d48a1
commit 042421694f
25 changed files with 604 additions and 138 deletions
+73
View File
@@ -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
+150
View File
@@ -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")
})
}
+33 -4
View File
@@ -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())
}
+33
View File
@@ -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))
})
}
}
+19
View File
@@ -16844,6 +16844,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"
},
@@ -16910,6 +16918,17 @@ const docTemplate = `{
"codersdk.AIProviderSettings": {
"type": "object"
},
"codersdk.AIProviderStatus": {
"type": "object",
"properties": {
"warnings": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"codersdk.AIProviderType": {
"type": "string",
"enum": [
+19
View File
@@ -15054,6 +15054,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"
},
@@ -15120,6 +15128,17 @@
"codersdk.AIProviderSettings": {
"type": "object"
},
"codersdk.AIProviderStatus": {
"type": "object",
"properties": {
"warnings": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"codersdk.AIProviderType": {
"type": "string",
"enum": [
@@ -4,7 +4,6 @@ import (
"context"
"mime"
"net/http"
neturl "net/url"
"slices"
"strings"
@@ -246,30 +245,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.
@@ -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()
+2 -1
View File
@@ -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")
}
+9
View File
@@ -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
+1 -1
View File
@@ -141,7 +141,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` |
+16 -9
View File
@@ -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.
+6 -5
View File
@@ -282,11 +282,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.
+39 -17
View File
@@ -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"
}
+35 -13
View File
@@ -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
+1 -1
View File
@@ -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{
+11 -7
View File
@@ -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))
@@ -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"),
+45 -15
View File
@@ -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) {
+5 -11
View File
@@ -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
@@ -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")
})
+1 -1
View File
@@ -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.
+13
View File
@@ -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.
@@ -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();
},
};
@@ -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<ProviderRowProps> = ({
});
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 (
<TableRow key={provider.name} {...clickableProps}>
<TableCell className="min-w-0 px-4 py-3">
@@ -62,6 +75,27 @@ export const ProviderRow: React.FC<ProviderRowProps> = ({
Not supported in Agents
</Badge>
)}
{provider.status?.warnings && provider.status.warnings.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="warning"
tabIndex={0}
aria-label={`Warning: ${provider.status.warnings.join("; ")}`}
onClick={stopPropagation}
onKeyDown={stopPropagation}
onKeyUp={stopPropagation}
>
Warning
</Badge>
</TooltipTrigger>
<TooltipContent>
{provider.status.warnings.map((warning) => (
<p key={warning}>{warning}</p>
))}
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
<TableCell className="w-10 text-center">