mirror of
https://github.com/coder/coder.git
synced 2026-11-03 14:21:04 +08:00
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
177 lines
6.6 KiB
Go
177 lines
6.6 KiB
Go
//go:build !slim
|
|
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/aibridge/intercept/apidump"
|
|
agplaibridge "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/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/prometheusmetrics"
|
|
"github.com/coder/coder/v2/enterprise/aibridgeproxyd"
|
|
"github.com/coder/coder/v2/enterprise/coderd"
|
|
)
|
|
|
|
// aiBridgeProxyDaemon bundles the proxy server and its pubsub
|
|
// subscription so both are torn down by a single Close call.
|
|
type aiBridgeProxyDaemon struct {
|
|
server *aibridgeproxyd.Server
|
|
unsubscribe func()
|
|
}
|
|
|
|
func (d *aiBridgeProxyDaemon) Close() error {
|
|
if d.unsubscribe != nil {
|
|
d.unsubscribe()
|
|
}
|
|
return d.server.Close()
|
|
}
|
|
|
|
// newAIBridgeProxyDaemon starts the enterprise aibridge proxy daemon,
|
|
// subscribes to ai_providers changes so the proxy's routing snapshot
|
|
// tracks the database, and registers the HTTP handler on the API.
|
|
// The returned io.Closer tears down both the subscription and server.
|
|
func newAIBridgeProxyDaemon(coderAPI *coderd.API) (io.Closer, error) {
|
|
ctx := context.Background()
|
|
coderAPI.Logger.Debug(ctx, "starting in-memory aibridgeproxy daemon")
|
|
|
|
logger := coderAPI.Logger.Named("aibridgeproxyd")
|
|
|
|
// TODO(deprecation): Remove "coder_aibridgeproxyd_" in v2.37.
|
|
// See AIGOV-447:
|
|
// https://linear.app/codercom/issue/AIGOV-447/remove-legacy-ai-gateway-metric-aliases
|
|
reg := prometheusmetrics.NewMetricAliasRegisterer(coderAPI.PrometheusRegistry, "coder_ai_gateway_proxy_", "coder_aibridgeproxyd_")
|
|
metrics := aibridgeproxyd.NewMetrics(reg)
|
|
|
|
var newDumper func(provider, requestID string) aibridgeproxyd.RoundTripDumper
|
|
if dumpDir := coderAPI.DeploymentValues.AI.BridgeProxyConfig.APIDumpDir.String(); dumpDir != "" {
|
|
newDumper = func(provider, requestID string) aibridgeproxyd.RoundTripDumper {
|
|
return apidump.NewDumper(filepath.Join(dumpDir, provider, requestID), logger)
|
|
}
|
|
}
|
|
|
|
target, err := resolveAIGatewayProxyTarget(
|
|
coderAPI.AccessURL,
|
|
coderAPI.DeploymentValues.AI.BridgeProxyConfig.Target.String(),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
srv, err := aibridgeproxyd.New(ctx, logger, aibridgeproxyd.Options{
|
|
ListenAddr: coderAPI.DeploymentValues.AI.BridgeProxyConfig.ListenAddr.String(),
|
|
TLSCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSCertFile.String(),
|
|
TLSKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.TLSKeyFile.String(),
|
|
GatewayURL: target,
|
|
MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(),
|
|
MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(),
|
|
UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(),
|
|
UpstreamProxyCA: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxyCA.String(),
|
|
AllowedPrivateCIDRs: coderAPI.DeploymentValues.AI.BridgeProxyConfig.AllowedPrivateCIDRs.Value(),
|
|
NewDumper: newDumper,
|
|
Metrics: metrics,
|
|
RefreshProviders: refreshProxyProviders(coderAPI.Database),
|
|
})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to start in-memory aibridgeproxy daemon: %w", err)
|
|
}
|
|
|
|
unsubscribe, err := aibridged.SubscribeProviderReload(ctx, coderAPI.Pubsub, srv, logger.Named("provider-reload"))
|
|
if err != nil {
|
|
// Without the subscription the proxy can never track provider changes,
|
|
// so fail startup rather than serve a permanently stale snapshot.
|
|
_ = srv.Close()
|
|
return nil, xerrors.Errorf("subscribe aibridgeproxyd to ai providers change channel: %w", err)
|
|
}
|
|
|
|
// Register the handler so coderd can serve the proxy endpoints.
|
|
coderAPI.RegisterInMemoryAIBridgeProxydHTTPHandler(srv.Handler())
|
|
|
|
return &aiBridgeProxyDaemon{
|
|
server: srv,
|
|
unsubscribe: unsubscribe,
|
|
}, nil
|
|
}
|
|
|
|
// resolveAIGatewayProxyTarget returns the URL to which the aibridgeproxyd should forward requests.
|
|
func resolveAIGatewayProxyTarget(accessURL *url.URL, target string) (string, error) {
|
|
if target != "" {
|
|
return target, nil
|
|
}
|
|
|
|
target, err := url.JoinPath(accessURL.String(), agplaibridge.AIGatewayRootPath)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("build embedded AI Gateway proxy target: %w", err)
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
// refreshProxyProviders classifies every ai_providers row as enabled,
|
|
// disabled, or error so the proxy router and any observers see the full
|
|
// configured set. Disabled rows are excluded from routing; errored rows
|
|
// are excluded from routing and surface their failure reason for
|
|
// metrics and logs.
|
|
func refreshProxyProviders(db database.Store) aibridgeproxyd.RefreshProvidersFunc {
|
|
return func(ctx context.Context) (aibridgeproxyd.ProviderReload, error) {
|
|
//nolint:gocritic // AsAIProviderMetadataReader is the correct subject for routing-only access.
|
|
rows, err := db.GetAIProviders(dbauthz.AsAIProviderMetadataReader(ctx), database.GetAIProvidersParams{
|
|
IncludeDisabled: true,
|
|
})
|
|
if err != nil {
|
|
return aibridgeproxyd.ProviderReload{}, xerrors.Errorf("load ai providers: %w", err)
|
|
}
|
|
reload := aibridgeproxyd.ProviderReload{
|
|
Providers: make([]aibridgeproxyd.ReloadedProvider, 0, len(rows)),
|
|
}
|
|
seenHost := make(map[string]string, len(rows))
|
|
for _, row := range rows {
|
|
reload.Providers = append(reload.Providers, classifyProviderRow(row, seenHost))
|
|
}
|
|
return reload, nil
|
|
}
|
|
}
|
|
|
|
// classifyProviderRow evaluates a single ai_providers row for routing.
|
|
// 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{
|
|
Name: row.Name,
|
|
Type: string(row.Type),
|
|
},
|
|
}
|
|
if !row.Enabled {
|
|
out.Status = aibridged.ProviderStatusDisabled
|
|
return out
|
|
}
|
|
if strings.TrimSpace(row.BaseUrl) == "" {
|
|
out.Status = aibridged.ProviderStatusError
|
|
out.Err = xerrors.New("base url is empty")
|
|
return out
|
|
}
|
|
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.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
|
|
out.Host = host
|
|
out.Status = aibridged.ProviderStatusEnabled
|
|
return out
|
|
}
|