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
Show all changes
22 commits
Select commit Hold shift + click to select a range
b18a82e
fix: surface duplicate AI provider hostname as warning, not error
johnstcn Aug 17, 2026
80bdaf1
feat(site): show hostname collision warning in provider list
johnstcn Aug 19, 2026
7d3fd3e
refactor: simplify hostname warning code
johnstcn Aug 19, 2026
a39d80e
fix: correct proxy_excluded metric help text and log warning errors
johnstcn Aug 19, 2026
a6092cf
fix: regenerate prometheus docs for proxy_excluded status
johnstcn Aug 19, 2026
6da9500
fix: address deep-review round 3 findings
johnstcn Aug 19, 2026
dd1e3b7
fix: address deep-review round 3 nits
johnstcn Aug 19, 2026
b732ed5
fix: address deep-review round 5 findings
johnstcn Aug 19, 2026
665ba60
fix: address deep-review round 6 findings
johnstcn Aug 19, 2026
9c004d0
fix: address deep-review round 8 findings
johnstcn Aug 19, 2026
22ff79d
fix: address deep-review round 9 findings
johnstcn Aug 19, 2026
060766b
fix: update test assertion messages to say database order
johnstcn Aug 19, 2026
9fdd080
refactor: trim comments and simplify code
johnstcn Aug 19, 2026
4e7620a
fix: assert tabIndex in warning badge story
johnstcn Aug 19, 2026
15704b8
Revert "refactor: trim comments and simplify code"
johnstcn Aug 19, 2026
47c74b1
fix: address deep-review round 12 findings
johnstcn Aug 19, 2026
9c8592c
fix: address deep-review round 13 findings
johnstcn Aug 20, 2026
bf946f9
fix: EmptyWarnings story now catches the 0-leak regression
johnstcn Aug 20, 2026
611dd4c
fix: remove redundant loop-header comment in integration test
johnstcn Aug 20, 2026
103343d
refactor: inline single-use var and remove dead default case
johnstcn Aug 20, 2026
cbd6a52
Merge branch 'main' into aigov-596-hostname-collision-warnings
johnstcn Aug 22, 2026
5ce1db6
fix: address Copilot review findings
johnstcn Aug 22, 2026
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
73 changes: 73 additions & 0 deletions coderd/ai_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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])
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we send the warning even if AI Gateway proxy is disabled? We don't need to check if it is enabled, but at least we should add a note like "If AI Gateway Proxy is enabled, this provider will be excluded from proxy routing.”

}}
}

// 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
Expand Down
150 changes: 150 additions & 0 deletions coderd/ai_providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/...")
Comment on lines +1921 to +1923

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this could probably be simplified to

require.Equal(t, []string{
	`hostname "api.openai.com" is claimed by provider "first"; not reachable via the AI Gateway Proxy, use direct routing (/api/v2/ai-gateway/second/...) instead`,
}, updated.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)

//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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we should assert the warning message here as well.

})

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")
})
}
37 changes: 33 additions & 4 deletions coderd/aibridged/provider.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package aibridged

import (
"net/url"
"strings"
)

// ProviderStatus is the lifecycle state of a configured AI provider.
type ProviderStatus string

Expand All @@ -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"
)

Comment thread
johnstcn marked this conversation as resolved.
// 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 changes: 33 additions & 0 deletions coderd/aibridged/provider_test.go
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading