Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 29 additions & 37 deletions coderd/ai_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func (api *API) aiProvidersList(rw http.ResponseWriter, r *http.Request) {
})
return
}
sdk.Status = aiProviderHostnameWarningFromMap(row, namesByHost)
sdk.Status = api.aiProviderStatus(row, namesByHost)
out = append(out, sdk)
}
httpapi.Write(ctx, rw, http.StatusOK, out)
Expand Down Expand Up @@ -134,7 +134,7 @@ func (api *API) aiProvidersGet(rw http.ResponseWriter, r *http.Request) {
})
return
}
sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, row)
sdk.Status = api.aiProviderStatusFromDB(ctx, row)
httpapi.Write(ctx, rw, http.StatusOK, sdk)
}

Expand Down Expand Up @@ -256,7 +256,7 @@ func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) {
})
return
}
sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, row)
sdk.Status = api.aiProviderStatusFromDB(ctx, row)
httpapi.Write(ctx, rw, http.StatusCreated, sdk)
}

Expand Down Expand Up @@ -450,7 +450,7 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
})
return
}
sdk.Status = aiProviderHostnameWarningFromDB(ctx, api.Logger, api.Database, updated)
sdk.Status = api.aiProviderStatusFromDB(ctx, updated)
httpapi.Write(ctx, rw, http.StatusOK, sdk)
}

Expand Down Expand Up @@ -582,51 +582,43 @@ func buildHostnameCollisionMap(rows []database.AIProvider) map[string][]string {
return namesByHost
}

// aiProviderHostnameWarningFromMap is the pure helper for the list
// handler. namesByHost is the pre-built collision map from the outer
// rows, in database order (ORDER BY name ASC). Only providers whose
// name appears after another enabled provider on the same hostname
// get a warning; the first provider in database order does not.
func aiProviderHostnameWarningFromMap(provider database.AIProvider, namesByHost map[string][]string) *codersdk.AIProviderStatus {
if !provider.Enabled || provider.Deleted {
return nil
}
host := aibridged.BaseURLHostname(provider.BaseUrl)
if host == "" {
return nil
}
names := namesByHost[host]
if len(names) < 2 {
return nil
// aiProviderStatus collects warnings for an AI provider.
func (api *API) aiProviderStatus(provider database.AIProvider, namesByHost map[string][]string) *codersdk.AIProviderStatus {
var warnings []string
if warning := api.proxyCollisionWarning(provider, namesByHost); warning != "" {
warnings = append(warnings, warning)
}
// The first name in database order is the proxy winner.
if provider.Name == names[0] {
if len(warnings) == 0 {
return nil
}
return &codersdk.AIProviderStatus{Warnings: []string{
fmt.Sprintf("hostname %q is claimed by provider %q; not reachable via the AI Gateway Proxy, use direct routing (/api/v2/ai-gateway/%s/...) instead", host, names[0], provider.Name),
}}
return &codersdk.AIProviderStatus{Warnings: warnings}
}

// aiProviderHostnameWarningFromDB fetches enabled, non-deleted
// providers to determine whether the given provider is excluded from
// proxy routing by hostname collision. Used by the single-row handlers
// (Get, Create, Update) where one extra query is not N+1.
func aiProviderHostnameWarningFromDB(ctx context.Context, logger slog.Logger, store database.Store, provider database.AIProvider) *codersdk.AIProviderStatus {
if !provider.Enabled || provider.Deleted {
return nil
// proxyCollisionWarning reports when another provider claims the proxy
// hostname first in database order.
func (api *API) proxyCollisionWarning(provider database.AIProvider, namesByHost map[string][]string) string {
if !api.DeploymentValues.AI.BridgeProxyConfig.Enabled.Value() || !provider.Enabled || provider.Deleted {
return ""
}
host := aibridged.BaseURLHostname(provider.BaseUrl)
if host == "" {
return nil
return ""
}
enabledProviders, err := store.GetAIProviders(ctx, database.GetAIProvidersParams{})
names := namesByHost[host]
if len(names) < 2 || provider.Name == names[0] {
return ""
}
return fmt.Sprintf("Hostname %q is claimed by provider %q. AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/%s/... endpoint).", host, names[0], provider.Name)
}

// aiProviderStatusFromDB loads status data for a single provider response.
func (api *API) aiProviderStatusFromDB(ctx context.Context, provider database.AIProvider) *codersdk.AIProviderStatus {
rows, err := api.Database.GetAIProviders(ctx, database.GetAIProvidersParams{})
if err != nil {
logger.Error(ctx, "load AI providers for hostname warnings", slog.Error(err))
api.Logger.Error(ctx, "load AI providers for status", slog.Error(err))
return nil
}
namesByHost := buildHostnameCollisionMap(enabledProviders)
return aiProviderHostnameWarningFromMap(provider, namesByHost)
return api.aiProviderStatus(provider, buildHostnameCollisionMap(rows))
}

// writeAIProviderError translates an error from the AI provider
Expand Down
106 changes: 92 additions & 14 deletions coderd/ai_providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)

// keyIDs extracts the IDs from a slice of AIProviderKey responses, in
Expand Down Expand Up @@ -1892,12 +1893,23 @@ func TestAIProvidersBedrockExternalID(t *testing.T) {
func TestAIProviderHostnameCollisionWarnings(t *testing.T) {
t.Parallel()

newClient := func(t *testing.T, proxyEnabled bool) *codersdk.Client {
t.Helper()
return coderdtest.New(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t, func(values *codersdk.DeploymentValues) {
values.AI.BridgeProxyConfig.Enabled = serpent.Bool(proxyEnabled)
}),
})
}

t.Run("CreateGetListReturnsWarning", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
client := newClient(t, true)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

wantWarnings := []string{`Hostname "api.openai.com" is claimed by provider "first". AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/second/... endpoint).`}

//nolint:gocritic // Owner role is the audience for this endpoint.
first, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Expand All @@ -1917,16 +1929,13 @@ func TestAIProviderHostnameCollisionWarnings(t *testing.T) {
})
require.NoError(t, err)
require.NotNil(t, second.Status)
require.Len(t, second.Status.Warnings, 1)
require.Contains(t, second.Status.Warnings[0], `"first"`)
require.Contains(t, second.Status.Warnings[0], "AI Gateway Proxy")
require.Contains(t, second.Status.Warnings[0], "/api/v2/ai-gateway/second/...")
require.Equal(t, wantWarnings, second.Status.Warnings)

//nolint:gocritic // Owner role is the audience for this endpoint.
got, err := client.AIProvider(ctx, second.ID.String())
require.NoError(t, err)
require.NotNil(t, got.Status)
require.Len(t, got.Status.Warnings, 1)
require.Equal(t, wantWarnings, got.Status.Warnings)

//nolint:gocritic // Owner role is the audience for this endpoint.
winner, err := client.AIProvider(ctx, first.ID.String())
Expand All @@ -1948,15 +1957,17 @@ func TestAIProviderHostnameCollisionWarnings(t *testing.T) {
}
require.Nil(t, firstListed.Status, "first provider in database order should not get a warning in list")
require.NotNil(t, secondListed.Status)
require.Len(t, secondListed.Status.Warnings, 1)
require.Equal(t, wantWarnings, secondListed.Status.Warnings)
})

t.Run("UpdateReturnsWarningOnEnable", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
client := newClient(t, true)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

wantWarnings := []string{`Hostname "api.openai.com" is claimed by provider "first". AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/second/... endpoint).`}

//nolint:gocritic // Owner role is the audience for this endpoint.
_, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Expand All @@ -1982,15 +1993,82 @@ func TestAIProviderHostnameCollisionWarnings(t *testing.T) {
})
require.NoError(t, err)
require.NotNil(t, updated.Status)
require.Len(t, updated.Status.Warnings, 1)
require.Contains(t, updated.Status.Warnings[0], `"first"`)
require.Contains(t, updated.Status.Warnings[0], "AI Gateway Proxy")
require.Contains(t, updated.Status.Warnings[0], "/api/v2/ai-gateway/second/...")
require.Equal(t, wantWarnings, updated.Status.Warnings)
})

t.Run("UpdateBaseURLReturnsWarning", func(t *testing.T) {
t.Parallel()
client := newClient(t, true)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

wantWarnings := []string{`Hostname "api.openai.com" is claimed by provider "first". AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/second/... endpoint).`}

//nolint:gocritic // Owner role is the audience for this endpoint.
first, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "first",
Enabled: true,
BaseURL: "https://api.openai.com/v1",
})
require.NoError(t, err)
require.Nil(t, first.Status)

//nolint:gocritic // Owner role is the audience for this endpoint.
second, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "second",
Enabled: true,
BaseURL: "https://api.openai-compat.com/v1",
})
require.NoError(t, err)
require.Nil(t, second.Status, "distinct hostnames should not collide")

//nolint:gocritic // Owner role is the audience for this endpoint.
updated, err := client.UpdateAIProvider(ctx, second.ID.String(), codersdk.UpdateAIProviderRequest{
BaseURL: ptr.Ref("https://api.openai.com/v2"),
})
require.NoError(t, err)
require.NotNil(t, updated.Status)
require.Equal(t, wantWarnings, updated.Status.Warnings)
})

t.Run("ProxyDisabledReturnsNoWarning", func(t *testing.T) {
t.Parallel()
client := newClient(t, false)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

//nolint:gocritic // Owner role is the audience for this endpoint.
_, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "first",
Enabled: true,
BaseURL: "https://api.openai.com/v1",
})
require.NoError(t, err)

//nolint:gocritic // Owner role is the audience for this endpoint.
second, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "second",
Enabled: true,
BaseURL: "https://api.openai.com/v2",
})
require.NoError(t, err)
require.Nil(t, second.Status)

//nolint:gocritic // Owner role is the audience for this endpoint.
providers, err := client.AIProviders(ctx)
require.NoError(t, err)
for _, provider := range providers {
require.Nil(t, provider.Status)
}
})

t.Run("NoWarningWhenNoCollision", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
client := newClient(t, true)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

Expand All @@ -2017,7 +2095,7 @@ func TestAIProviderHostnameCollisionWarnings(t *testing.T) {

t.Run("UpdateSelfNoWarning", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
client := newClient(t, true)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

Expand Down
4 changes: 2 additions & 2 deletions docs/ai-coder/ai-gateway/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ an API key.

## Provider lifecycle

Every provider carries an explicit status, surfaced through the
[`provider_info`](./monitoring.md#prometheus-metrics) metric and the API:
Every provider carries an explicit status, reported by the [`provider_info`](./monitoring.md#prometheus-metrics) metrics.
`coder_ai_gateway_provider_info` reports `enabled`, `disabled`, and `error`, and `coder_ai_gateway_proxy_provider_info` also reports `proxy_excluded`:

| Status | Meaning | Effect on requests |
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export const WithHostnameCollisionWarning: Story = {
enabled: true,
status: {
warnings: [
'hostname "api.openai.com" is claimed by provider "first"; not reachable via the AI Gateway Proxy, use direct routing (/api/v2/ai-gateway/openai/...) instead',
'Hostname "api.openai.com" is claimed by provider "first". AI Gateway Proxy excludes this provider from proxy routing. The hostname collision does not affect direct routing (/api/v2/ai-gateway/openai/... endpoint).',
],
},
},
Expand Down
Loading