mirror of
https://github.com/coder/coder.git
synced 2026-10-30 18:12:29 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
Generated
+19
@@ -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": [
|
||||
|
||||
Generated
+19
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+39
-17
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+35
-13
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+13
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user