diff --git a/cli/server.go b/cli/server.go index 78d874e298..dfd2db1dca 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2824,6 +2824,12 @@ func (s *HTTPServers) Close() { } } +// ConfigureTraceProvider configures tracing for coderd. When tracing is +// disabled, it returns a noop provider, the default postgres driver name, and +// a noop close function. The SQL driver name switches to the tracing driver when +// postgres tracing is available. The close function flushes and shuts down the +// exporter, and this function installs the global OpenTelemetry text map +// propagator as a side effect. func ConfigureTraceProvider( ctx context.Context, logger slog.Logger, @@ -2832,8 +2838,8 @@ func ConfigureTraceProvider( return ConfigureTraceProviderWithService(ctx, logger, cfg, "coderd") } -// ConfigureTraceProviderWithService configures trace provider -// with a specified service name. +// ConfigureTraceProviderWithService is the parameterized variant of +// ConfigureTraceProvider. func ConfigureTraceProviderWithService( ctx context.Context, logger slog.Logger, diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index fccd065f50..8aea61bf6a 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -258,6 +258,11 @@ AI GATEWAY PROXY OPTIONS: Path to the TLS private key file for the AI Gateway Proxy listener. Must be set together with AI Gateway Proxy TLS Certificate File. + --ai-gateway-proxy-target string, $CODER_AI_GATEWAY_PROXY_TARGET + Base URL of the AI Gateway to forward intercepted requests to. + Defaults to the embedded AI Gateway address at the Coder access URL + plus /api/v2/ai-gateway. + --ai-gateway-proxy-upstream string, $CODER_AI_GATEWAY_PROXY_UPSTREAM URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests through. Format: http://[user:pass@]host:port or diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 05aa4738a8..0106cd0dfe 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -1086,6 +1086,10 @@ ai_gateway_proxy: # The address the AI Gateway Proxy will listen on. # (default: :8888, type: string) listen_addr: :8888 + # Base URL of the AI Gateway to forward intercepted requests to. Defaults to the + # embedded AI Gateway address at the Coder access URL plus /api/v2/ai-gateway. + # (default: , type: string) + target: "" # Path to the TLS certificate file for the AI Gateway Proxy listener. Must be set # together with AI Gateway Proxy TLS Key File. # (default: , type: string) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index aae3e1f8a0..637075415d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15041,6 +15041,9 @@ const docTemplate = `{ "listen_addr": { "type": "string" }, + "target": { + "type": "string" + }, "tls_cert_file": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7f170c96ea..75c3481d33 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13383,6 +13383,9 @@ "listen_addr": { "type": "string" }, + "target": { + "type": "string" + }, "tls_cert_file": { "type": "string" }, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index c417b80014..8a9a39aab6 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2131,6 +2131,16 @@ communicating directly.`, Group: &deploymentGroupAIGatewayProxy, YAML: "listen_addr", } + aiGatewayProxyTarget := serpent.Option{ + Name: "AI Gateway Proxy Target", + Description: "Base URL of the AI Gateway to forward intercepted requests to. Defaults to the embedded AI Gateway address at the Coder access URL plus /api/v2/ai-gateway.", + Flag: "ai-gateway-proxy-target", + Env: "CODER_AI_GATEWAY_PROXY_TARGET", + Value: &c.AI.BridgeProxyConfig.Target, + Default: "", + Group: &deploymentGroupAIGatewayProxy, + YAML: "target", + } aiGatewayProxyTLSCertFile := serpent.Option{ Name: "AI Gateway Proxy TLS Certificate File", Description: "Path to the TLS certificate file for the AI Gateway Proxy listener. Must be set together with AI Gateway Proxy TLS Key File.", @@ -4650,6 +4660,7 @@ Write out the current server config as YAML to stdout.`, UseInstead: serpent.OptionSet{aiGatewayProxyListenAddr}, }, aiGatewayProxyListenAddr, + aiGatewayProxyTarget, { Name: "AI Bridge Proxy TLS Certificate File", Description: "Deprecated: use --ai-gateway-proxy-tls-cert-file or CODER_AI_GATEWAY_PROXY_TLS_CERT_FILE instead. Path to the TLS certificate file for the AI Bridge Proxy listener. Must be set together with AI Bridge Proxy TLS Key File.", @@ -4951,6 +4962,7 @@ type AIProviderConfig struct { type AIBridgeProxyConfig struct { Enabled serpent.Bool `json:"enabled" typescript:",notnull"` ListenAddr serpent.String `json:"listen_addr" typescript:",notnull"` + Target serpent.String `json:"target" typescript:",notnull"` TLSCertFile serpent.String `json:"tls_cert_file" typescript:",notnull"` TLSKeyFile serpent.String `json:"tls_key_file" typescript:",notnull"` MITMCertFile serpent.String `json:"cert_file" typescript:",notnull"` diff --git a/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md b/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md index 8cd0c179bf..cb8282d158 100644 --- a/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md +++ b/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md @@ -1,7 +1,7 @@ # Setup AI Gateway Proxy runs inside the Coder control plane (`coderd`), requiring no separate compute to deploy or scale. -Once enabled, `coderd` runs the `aibridgeproxyd` in-memory and intercepts traffic to supported AI providers, forwarding it to AI Gateway. +Once enabled, `coderd` runs the AI Gateway Proxy in-process and intercepts traffic to supported AI providers, forwarding it to AI Gateway. **Required:** @@ -49,6 +49,21 @@ See [Proxy TLS Configuration](#proxy-tls-configuration) for how to generate and The proxy intercepts HTTPS traffic for hostnames matching the base URL of each enabled AI [Provider](../providers.md) configured in AI Gateway. All other traffic is tunneled through without decryption. +### Proxy target + +Intercepted requests are forwarded to the AI Gateway, configured via [`CODER_AI_GATEWAY_PROXY_TARGET`](../../../reference/cli/server.md#--ai-gateway-proxy-target). +By default, this is the embedded AI Gateway at `/api/v2/ai-gateway`, and no configuration is needed. + +To forward intercepted requests to an AI Gateway that is not embedded in this Coder deployment, set: + +```shell +CODER_AI_GATEWAY_PROXY_TARGET=https://ai-gateway.example.com/ +# or via CLI flag: +--ai-gateway-proxy-target=https://ai-gateway.example.com/ +``` + +The target is used as-is: the proxy appends only the provider and request path to it, and the URL must not include query parameters. + For additional configuration options, see the [Coder server configuration](../../../reference/cli/server.md#options). ## Security Considerations @@ -79,7 +94,7 @@ Requests to non-allowlisted domains are tunneled through the proxy, but connecti The IP validation and TCP connect happen atomically, preventing DNS rebinding attacks where the resolved address could change between the check and the connection. To prevent unauthorized use, restrict network access to the proxy so that only authorized clients can connect. -In case the Coder access URL resolves to a private address, it is automatically exempt from this restriction so the proxy can always reach its own deployment. +In case the AI Gateway [proxy target](#proxy-target) hostname (the Coder access URL by default) resolves to a private address, it is automatically exempt from this restriction so the proxy can always reach the configured AI Gateway. If you need to allow access to additional internal networks via the proxy, use the Allowlist CIDRs option ([`CODER_AI_GATEWAY_PROXY_ALLOWED_PRIVATE_CIDRS`](../../../reference/cli/server.md#--ai-gateway-proxy-allowed-private-cidrs)): ```shell @@ -379,7 +394,7 @@ TLS verification can fail on either leg of the connection: between AI Gateway Pr #### AI Gateway Proxy to Coder -When the Coder access URL uses HTTPS, AI Gateway Proxy must trust the TLS certificate served at that URL (either Coder's +When the AI Gateway [proxy target](#proxy-target) URL (the Coder access URL by default) uses HTTPS, AI Gateway Proxy must trust the TLS certificate served at that URL (either Coder's own certificate or a load balancer's, if TLS is terminated there) to forward intercepted requests to AI Gateway. This primarily affects deployments using a self-signed or internal CA, since publicly trusted CAs are typically already in the system trust store. @@ -412,7 +427,7 @@ Gateway. Check that the provider is enabled and its base URL matches the hostnam `HTTPS_PROXY` points at the proxy. When interception is working, coderd logs: ```shell -routing MITM request to aibridged +routing MITM request to AI Gateway ``` for each intercepted request. diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 32a968febe..aa6594d9b0 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -174,6 +174,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "enabled": true, "key_file": "string", "listen_addr": "string", + "target": "string", "tls_cert_file": "string", "tls_key_file": "string", "upstream_proxy": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 53547ca044..82ef396293 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -551,6 +551,7 @@ "enabled": true, "key_file": "string", "listen_addr": "string", + "target": "string", "tls_cert_file": "string", "tls_key_file": "string", "upstream_proxy": "string", @@ -569,6 +570,7 @@ | `enabled` | boolean | false | | | | `key_file` | string | false | | | | `listen_addr` | string | false | | | +| `target` | string | false | | | | `tls_cert_file` | string | false | | | | `tls_key_file` | string | false | | | | `upstream_proxy` | string | false | | | @@ -941,6 +943,7 @@ "enabled": true, "key_file": "string", "listen_addr": "string", + "target": "string", "tls_cert_file": "string", "tls_key_file": "string", "upstream_proxy": "string", @@ -5633,6 +5636,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "enabled": true, "key_file": "string", "listen_addr": "string", + "target": "string", "tls_cert_file": "string", "tls_key_file": "string", "upstream_proxy": "string", @@ -6242,6 +6246,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "enabled": true, "key_file": "string", "listen_addr": "string", + "target": "string", "tls_cert_file": "string", "tls_key_file": "string", "upstream_proxy": "string", diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 5ff296bc02..ca907d4adb 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1987,6 +1987,16 @@ Enable the AI Gateway MITM Proxy for intercepting and decrypting AI provider req The address the AI Gateway Proxy will listen on. +### --ai-gateway-proxy-target + +| | | +|-------------|---------------------------------------------| +| Type | string | +| Environment | $CODER_AI_GATEWAY_PROXY_TARGET | +| YAML | ai_gateway_proxy.target | + +Base URL of the AI Gateway to forward intercepted requests to. Defaults to the embedded AI Gateway address at the Coder access URL plus /api/v2/ai-gateway. + ### --ai-gateway-proxy-tls-cert-file | | | diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 1f7644f4c5..0941c2da4b 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -118,17 +118,17 @@ var blockedIPRanges = func() []net.IPNet { // It is responsible for: // - intercepting HTTPS requests to AI providers // - decrypting requests using the configured MITM CA certificate -// - forwarding requests to aibridged for processing +// - forwarding requests to AI Gateway for processing type Server struct { - ctx context.Context - logger slog.Logger - proxy *goproxy.ProxyHttpServer - httpServer *http.Server - listener net.Listener - tlsEnabled bool - coderAccessURL *url.URL - // coderAccessPort is the resolved port for the Coder access URL. - coderAccessPort string + ctx context.Context + logger slog.Logger + proxy *goproxy.ProxyHttpServer + httpServer *http.Server + listener net.Listener + tlsEnabled bool + gatewayURL *url.URL + // gatewayPort is the resolved port for the AI Gateway URL. + gatewayPort string // refreshProviders fetches the live provider snapshot on Reload. // Nil disables hot-reload. refreshProviders RefreshProvidersFunc @@ -175,7 +175,7 @@ type requestContext struct { // CoderToken is the authentication token extracted from Proxy-Authorization. // Set in authMiddleware during the CONNECT handshake. CoderToken string - // Provider is the aibridge provider name. + // Provider is the AI Gateway provider name. // Set in authMiddleware during the CONNECT handshake. Provider string // RequestID is a unique identifier for this request. @@ -195,9 +195,10 @@ type Options struct { TLSCertFile string // TLSKeyFile is the path to the TLS private key file for the proxy listener. TLSKeyFile string - // CoderAccessURL is the URL of the Coder deployment where aibridged is running. - // Requests to supported AI providers are forwarded here. - CoderAccessURL string + // GatewayURL is the base URL that receives intercepted AI provider + // requests. It may include a path prefix, such as /api/v2/ai-gateway + // when forwarding through coderd. + GatewayURL string // MITMCertFile is the path to the CA certificate file used for MITM. MITMCertFile string // MITMKeyFile is the path to the CA private key file used for MITM. @@ -250,21 +251,24 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) return nil, xerrors.New("tls cert file and tls key file must both be set") } - if strings.TrimSpace(opts.CoderAccessURL) == "" { - return nil, xerrors.New("coder access URL is required") + if strings.TrimSpace(opts.GatewayURL) == "" { + return nil, xerrors.New("AI Gateway URL is required") } - coderAccessURL, err := url.Parse(opts.CoderAccessURL) + gatewayURL, err := url.Parse(opts.GatewayURL) if err != nil { - return nil, xerrors.Errorf("invalid coder access URL %q: %w", opts.CoderAccessURL, err) + return nil, xerrors.Errorf("invalid AI Gateway URL %q: %w", opts.GatewayURL, err) + } + if gatewayURL.RawQuery != "" { + return nil, xerrors.New("AI Gateway URL must not include query parameters") } // Resolve the default port when not explicitly specified in the URL. - coderAccessPort := coderAccessURL.Port() - if coderAccessPort == "" { - switch coderAccessURL.Scheme { + gatewayPort := gatewayURL.Port() + if gatewayPort == "" { + switch gatewayURL.Scheme { case "https": - coderAccessPort = "443" + gatewayPort = "443" default: - coderAccessPort = "80" + gatewayPort = "80" } } @@ -305,9 +309,9 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) } // Override goproxy's default transport, which has InsecureSkipVerify: true. - // This applies to all proxy.Tr traffic: MITM'd requests forwarded to aibridge, + // This applies to all proxy.Tr traffic: MITM'd requests forwarded to AI Gateway, // passthrough requests, and HTTPS upstream proxy connections. Proxy is - // intentionally unset so MITM'd requests go directly to aibridge, never + // intentionally unset so MITM'd requests go directly to AI Gateway, never // through an upstream proxy or HTTPS_PROXY env var. rootCAs, err := x509.SystemCertPool() if err != nil { @@ -325,8 +329,8 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) logger: logger, proxy: proxy, tlsEnabled: opts.TLSCertFile != "", - coderAccessURL: coderAccessURL, - coderAccessPort: coderAccessPort, + gatewayURL: gatewayURL, + gatewayPort: gatewayPort, refreshProviders: opts.RefreshProviders, allowedPorts: allowedPorts, caCert: certPEM, @@ -340,7 +344,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) srv.providerRouter.Store(emptyProviderRouter) // Configure upstream proxy for tunneled (non-provider-host) CONNECT requests. - // Provider-host domains are MITM'd and forwarded to aibridge directly, + // Provider-host domains are MITM'd and forwarded to AI Gateway directly, // bypassing the upstream proxy. if opts.UpstreamProxy != "" { upstreamURL, err := url.Parse(opts.UpstreamProxy) @@ -467,7 +471,7 @@ func New(ctx context.Context, logger slog.Logger, opts Options) (*Server, error) logger.Info(ctx, "aibridgeproxyd configured", slog.F("listen_addr", listener.Addr().String()), slog.F("tls_listener_enabled", srv.tlsEnabled), - slog.F("coder_access_url", coderAccessURL.String()), + slog.F("gateway_url", gatewayURL.String()), slog.F("upstream_proxy", opts.UpstreamProxy), slog.F("allowed_private_cidrs", opts.AllowedPrivateCIDRs), slog.F("api_dump_enabled", opts.NewDumper != nil), @@ -497,9 +501,9 @@ func (s *Server) IsTLSListener() bool { return s.tlsEnabled } -// CoderAccessURL returns the parsed Coder access URL with a normalized port. -func (s *Server) CoderAccessURL() *url.URL { - return s.coderAccessURL +// GatewayURL returns the parsed AI Gateway URL. +func (s *Server) GatewayURL() *url.URL { + return s.gatewayURL } // Close gracefully shuts down the proxy server. @@ -797,13 +801,13 @@ func (s *Server) tunneledMiddleware(host string, _ *goproxy.ProxyCtx) (*goproxy. } // isBlockedIP reports whether the given IP is in a blocked private/reserved range -// and not exempted by AllowedPrivateCIDRs or the Coder access URL hostname. +// and not exempted by AllowedPrivateCIDRs or the AI Gateway URL hostname. func (s *Server) isBlockedIP(ip net.IP, hostname string, port string) bool { - // Always allow the Coder access URL hostname+port so the proxy doesn't - // block connections to its own deployment. Hostname-based (not IP-based) + // Always allow the AI Gateway URL hostname+port so the proxy does not + // block connections to the AI Gateway. Hostname-based (not IP-based) // to handle dynamic IPs (DNS changes, load balancers, k8s rescheduling). // The port is normalized at startup to handle URLs without explicit ports. - if strings.EqualFold(hostname, s.coderAccessURL.Hostname()) && port == s.coderAccessPort { + if strings.EqualFold(hostname, s.gatewayURL.Hostname()) && port == s.gatewayPort { return false } @@ -964,37 +968,37 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. return req, newProxyAuthRequiredResponse(req) } - // Rewrite the request to point to aibridged. - if s.coderAccessURL == nil || s.coderAccessURL.String() == "" { - logger.Error(s.ctx, "coderAccessURL is not configured") + // Rewrite the request to point to the configured AI Gateway target. + if s.gatewayURL == nil || s.gatewayURL.String() == "" { + logger.Error(s.ctx, "ai gateway target URL is not configured") return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusInternalServerError, "Proxy misconfigured") } - aiBridgeURL, err := url.JoinPath(s.coderAccessURL.String(), agplaibridge.AIGatewayRootPath, reqCtx.Provider, originalPath) + gatewayTargetURL, err := url.JoinPath(s.gatewayURL.String(), reqCtx.Provider, originalPath) if err != nil { - logger.Error(s.ctx, "failed to build aibridged URL", slog.Error(err)) + logger.Error(s.ctx, "failed to build AI Gateway target URL", slog.Error(err)) return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusInternalServerError, "Failed to build AI Gateway URL") } - aiBridgeParsedURL, err := url.Parse(aiBridgeURL) + parsedGatewayTargetURL, err := url.Parse(gatewayTargetURL) if err != nil { - logger.Error(s.ctx, "failed to parse aibridged URL", slog.Error(err)) + logger.Error(s.ctx, "failed to parse AI Gateway target URL", slog.Error(err)) return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusInternalServerError, "Failed to parse AI Gateway URL") } // Preserve query parameters from the original request. // Both URL and Host must be set for the request to be properly routed. - aiBridgeParsedURL.RawQuery = req.URL.RawQuery - req.URL = aiBridgeParsedURL - req.Host = aiBridgeParsedURL.Host + parsedGatewayTargetURL.RawQuery = req.URL.RawQuery + req.URL = parsedGatewayTargetURL + req.Host = parsedGatewayTargetURL.Host injectBYOKHeaderIfNeeded(req.Header, reqCtx.CoderToken) // Set request ID header to correlate requests between aibridgeproxyd and aibridged. req.Header.Set(agplaibridge.HeaderCoderRequestID, reqCtx.RequestID.String()) - logger.Info(s.ctx, "routing MITM request to aibridged", - slog.F("aibridged_url", aiBridgeParsedURL.String()), + logger.Info(s.ctx, "routing MITM request to AI Gateway", + slog.F("gateway_target_url", parsedGatewayTargetURL.String()), ) // Dump the outgoing request when API dumping is enabled. diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 2a99015ad4..580b020ce1 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -150,7 +150,7 @@ type testProxyConfig struct { listenAddr string tlsCertFile string tlsKeyFile string - coderAccessURL string + gatewayURL string allowedPorts []string certStore *aibridgeproxyd.CertCache providers []aibridgeproxyd.ReloadedProvider @@ -170,9 +170,9 @@ func withAllowedPorts(ports ...string) testProxyOption { } } -func withCoderAccessURL(coderAccessURL string) testProxyOption { +func withGatewayURL(gatewayURL string) testProxyOption { return func(cfg *testProxyConfig) { - cfg.coderAccessURL = coderAccessURL + cfg.gatewayURL = gatewayURL } } @@ -293,8 +293,8 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server t.Helper() cfg := &testProxyConfig{ - listenAddr: "127.0.0.1:0", - coderAccessURL: "http://localhost:3000", + listenAddr: "127.0.0.1:0", + gatewayURL: "http://localhost:3000", // Allow 127.0.0.1 by default so test servers, which always listen on // loopback, are reachable. Tests that verify IP blocking override this. allowedPrivateCIDRs: []string{"127.0.0.1/32"}, @@ -306,7 +306,6 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server for _, opt := range opts { opt(cfg) } - // If the test did not supply a RefreshProviders, synthesize one // that returns the configured providers verbatim. This populates // the router synchronously below, mirroring how production starts @@ -325,7 +324,7 @@ func newTestProxy(t *testing.T, opts ...testProxyOption) *aibridgeproxyd.Server ListenAddr: cfg.listenAddr, TLSCertFile: cfg.tlsCertFile, TLSKeyFile: cfg.tlsKeyFile, - CoderAccessURL: cfg.coderAccessURL, + GatewayURL: cfg.gatewayURL, MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, AllowedPorts: cfg.allowedPorts, @@ -486,9 +485,9 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "listen address is required") @@ -501,10 +500,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "listen address is required") @@ -517,11 +516,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - TLSCertFile: "cert.pem", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + TLSCertFile: "cert.pem", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "tls cert file and tls key file must both be set") @@ -534,11 +533,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - TLSKeyFile: "key.pem", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + TLSKeyFile: "key.pem", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "tls cert file and tls key file must both be set") @@ -551,18 +550,18 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - TLSCertFile: "/nonexistent/cert.pem", - TLSKeyFile: "/nonexistent/key.pem", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + TLSCertFile: "/nonexistent/cert.pem", + TLSKeyFile: "/nonexistent/key.pem", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) require.Contains(t, err.Error(), "load listener TLS certificate") }) - t.Run("MissingCoderAccessURL", func(t *testing.T) { + t.Run("MissingGatewayURL", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) @@ -574,88 +573,104 @@ func TestNew(t *testing.T) { MITMKeyFile: mitmKeyFile, }) require.Error(t, err) - require.Contains(t, err.Error(), "coder access URL is required") + require.Contains(t, err.Error(), "AI Gateway URL is required") }) - t.Run("EmptyCoderAccessURL", func(t *testing.T) { + t.Run("EmptyGatewayURL", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: " ", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + GatewayURL: " ", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) - require.Contains(t, err.Error(), "coder access URL is required") + require.Contains(t, err.Error(), "AI Gateway URL is required") }) - t.Run("InvalidCoderAccessURL", func(t *testing.T) { + t.Run("InvalidGatewayURL", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "://invalid", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + GatewayURL: "://invalid", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.Error(t, err) - require.Contains(t, err.Error(), "invalid coder access URL") + require.Contains(t, err.Error(), "invalid AI Gateway URL") }) - t.Run("CoderAccessURLDefaultHTTPPort", func(t *testing.T) { + t.Run("GatewayURLWithQuery", func(t *testing.T) { + t.Parallel() + + mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) + logger := slogtest.Make(t, nil) + + _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000?token=secret", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "AI Gateway URL must not include query parameters") + }) + + t.Run("GatewayURLDefaultHTTPPort", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) - require.Equal(t, "localhost", srv.CoderAccessURL().Host) + require.Equal(t, "localhost", srv.GatewayURL().Host) }) - t.Run("CoderAccessURLDefaultHTTPSPort", func(t *testing.T) { + t.Run("GatewayURLDefaultHTTPSPort", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "https://localhost", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + GatewayURL: "https://localhost", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) - require.Equal(t, "localhost", srv.CoderAccessURL().Host) + require.Equal(t, "localhost", srv.GatewayURL().Host) }) - t.Run("CoderAccessURLExplicitPort", func(t *testing.T) { + t.Run("GatewayURLExplicitPort", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) - require.Equal(t, "localhost", srv.CoderAccessURL().Hostname()) - require.Equal(t, "3000", srv.CoderAccessURL().Port()) + require.Equal(t, "localhost", srv.GatewayURL().Hostname()) + require.Equal(t, "3000", srv.GatewayURL().Port()) }) t.Run("MissingCertFile", func(t *testing.T) { @@ -664,9 +679,9 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: ":0", - CoderAccessURL: "http://localhost:3000", - MITMKeyFile: "key.pem", + ListenAddr: ":0", + GatewayURL: "http://localhost:3000", + MITMKeyFile: "key.pem", }) require.Error(t, err) require.Contains(t, err.Error(), "cert file and key file are required") @@ -678,9 +693,9 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: ":0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: "cert.pem", + ListenAddr: ":0", + GatewayURL: "http://localhost:3000", + MITMCertFile: "cert.pem", }) require.Error(t, err) require.Contains(t, err.Error(), "cert file and key file are required") @@ -692,10 +707,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: ":0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: "/nonexistent/cert.pem", - MITMKeyFile: "/nonexistent/key.pem", + ListenAddr: ":0", + GatewayURL: "http://localhost:3000", + MITMCertFile: "/nonexistent/cert.pem", + MITMKeyFile: "/nonexistent/key.pem", }) require.Error(t, err) require.Contains(t, err.Error(), "failed to load MITM certificate") @@ -708,11 +723,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - UpstreamProxy: "://invalid-url", + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "://invalid-url", }) require.Error(t, err) require.Contains(t, err.Error(), "invalid upstream proxy URL") @@ -726,7 +741,7 @@ func TestNew(t *testing.T) { _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", + GatewayURL: "http://localhost:3000", MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, UpstreamProxy: "https://proxy.example.com:8080", @@ -743,11 +758,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - UpstreamProxy: "http://:@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://:@proxy.example.com:8080", }) require.Error(t, err) require.Contains(t, err.Error(), "invalid credentials: both username and password are empty") @@ -761,7 +776,7 @@ func TestNew(t *testing.T) { _, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", + GatewayURL: "http://localhost:3000", MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, AllowedPrivateCIDRs: []string{"not-a-cidr"}, @@ -777,10 +792,10 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) require.NotNil(t, srv) @@ -794,12 +809,12 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - TLSCertFile: listenerCertFile, - TLSKeyFile: listenerKeyFile, - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + TLSCertFile: listenerCertFile, + TLSKeyFile: listenerKeyFile, + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) require.NotNil(t, srv) @@ -812,11 +827,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - UpstreamProxy: "http://proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -831,7 +846,7 @@ func TestNew(t *testing.T) { // Use the shared MITM certificate as the upstream proxy CA (it's a valid PEM cert) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", + GatewayURL: "http://localhost:3000", MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, UpstreamProxy: "https://proxy.example.com:8080", @@ -848,11 +863,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - UpstreamProxy: "http://proxyuser:proxypass@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://proxyuser:proxypass@proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -865,11 +880,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - UpstreamProxy: "http://proxyuser:@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://proxyuser:@proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -883,11 +898,11 @@ func TestNew(t *testing.T) { // Username only (no colon) should also succeed (password is optional) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - UpstreamProxy: "http://proxyuser@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://proxyuser@proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -900,11 +915,11 @@ func TestNew(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - UpstreamProxy: "http://:proxypass@proxy.example.com:8080", + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + UpstreamProxy: "http://:proxypass@proxy.example.com:8080", }) require.NoError(t, err) require.NotNil(t, srv) @@ -921,11 +936,11 @@ func TestNew(t *testing.T) { metrics := aibridgeproxyd.NewMetrics(reg) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - Metrics: metrics, + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + Metrics: metrics, }) require.NoError(t, err) require.NotNil(t, srv) @@ -939,7 +954,7 @@ func TestNew(t *testing.T) { srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", + GatewayURL: "http://localhost:3000", MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, AllowedPrivateCIDRs: []string{"127.0.0.1/32"}, @@ -948,7 +963,7 @@ func TestNew(t *testing.T) { require.NotNil(t, srv) }) - t.Run("CoderAccessURLHostPreserved", func(t *testing.T) { + t.Run("GatewayURLHostPreserved", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) @@ -956,7 +971,7 @@ func TestNew(t *testing.T) { srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", - CoderAccessURL: "https://coder.example.com", + GatewayURL: "https://coder.example.com", MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, AllowedPrivateCIDRs: []string{"127.0.0.1/32"}, @@ -964,11 +979,11 @@ func TestNew(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = srv.Close() }) - require.Equal(t, "coder.example.com", srv.CoderAccessURL().Host, + require.Equal(t, "coder.example.com", srv.GatewayURL().Host, "Host must not have :443 appended") }) - t.Run("CoderAccessURLExplicitPortPreserved", func(t *testing.T) { + t.Run("GatewayURLExplicitPortPreserved", func(t *testing.T) { t.Parallel() mitmCertFile, mitmKeyFile := getSharedTestMITMCert(t) @@ -976,7 +991,7 @@ func TestNew(t *testing.T) { srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ ListenAddr: "127.0.0.1:0", - CoderAccessURL: "https://coder.example.com:8443", + GatewayURL: "https://coder.example.com:8443", MITMCertFile: mitmCertFile, MITMKeyFile: mitmKeyFile, AllowedPrivateCIDRs: []string{"127.0.0.1/32"}, @@ -984,7 +999,7 @@ func TestNew(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = srv.Close() }) - require.Equal(t, "coder.example.com:8443", srv.CoderAccessURL().Host) + require.Equal(t, "coder.example.com:8443", srv.GatewayURL().Host) }) } @@ -998,10 +1013,10 @@ func TestClose(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, }) require.NoError(t, err) @@ -1024,11 +1039,11 @@ func TestClose(t *testing.T) { metrics := aibridgeproxyd.NewMetrics(reg) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: mitmCertFile, - MITMKeyFile: mitmKeyFile, - Metrics: metrics, + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: mitmCertFile, + MITMKeyFile: mitmKeyFile, + Metrics: metrics, }) require.NoError(t, err) @@ -1093,7 +1108,7 @@ func TestProxy_CertCaching(t *testing.T) { // Start the proxy server with the certificate cache. srv := newTestProxy(t, - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(aibridgedServer.URL), withAllowedPorts(targetURL.Port()), withCertStore(certCache), withProviderHosts(providerHosts...), @@ -1156,7 +1171,7 @@ func TestProxy_PortValidation(t *testing.T) { }, { name: "RejectedPort", - // Only allow port 443 which doesn't match the target. + // Only allow port 443 which does not match the target. allowedPorts: func(_ *url.URL) []string { return []string{"443"} }, @@ -1183,7 +1198,7 @@ func TestProxy_PortValidation(t *testing.T) { // Start the proxy server. srv := newTestProxy(t, - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(aibridgedServer.URL), withAllowedPorts(tt.allowedPorts(targetURL)...), withProviderHosts(targetURL.Hostname()), ) @@ -1259,7 +1274,7 @@ func TestProxy_Authentication(t *testing.T) { // Start the proxy server. srv := newTestProxy(t, - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(aibridgedServer.URL), withAllowedPorts(targetURL.Port()), withProviderHosts(targetURL.Hostname()), ) @@ -1280,7 +1295,7 @@ func TestProxy_Authentication(t *testing.T) { require.Equal(t, "hello from aibridged", string(body)) } else { // Verify the proxy returns a 407 challenge with Proxy-Authenticate header. - // A raw CONNECT request is sent because Go's HTTP client doesn't expose + // A raw CONNECT request is sent because Go's HTTP client does not expose // the response when CONNECT fails with a non-2xx status. resp := sendConnect(t, srv.Addr(), targetURL.Host, tt.proxyAuth) defer resp.Body.Close() @@ -1311,7 +1326,10 @@ func TestProxy_MITM(t *testing.T) { allowedPorts []string buildTargetURL func(tunneledURL *url.URL) (string, error) tunneled bool + customGateway bool + gatewayPath string expectedPath string + expectedBody string provider string }{ { @@ -1321,7 +1339,7 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.anthropic.com/v1/messages", nil }, - expectedPath: "/api/v2/ai-gateway/anthropic/v1/messages", + expectedPath: "/anthropic/v1/messages", provider: "anthropic", }, { @@ -1331,7 +1349,7 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.anthropic.com:8443/v1/messages", nil }, - expectedPath: "/api/v2/ai-gateway/anthropic/v1/messages", + expectedPath: "/anthropic/v1/messages", provider: "anthropic", }, { @@ -1341,7 +1359,7 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.openai.com/v1/chat/completions", nil }, - expectedPath: "/api/v2/ai-gateway/openai/v1/chat/completions", + expectedPath: "/openai/v1/chat/completions", provider: "openai", }, { @@ -1351,9 +1369,34 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(_ *url.URL) (string, error) { return "https://api.openai.com:8443/v1/chat/completions", nil }, - expectedPath: "/api/v2/ai-gateway/openai/v1/chat/completions", + expectedPath: "/openai/v1/chat/completions", provider: "openai", }, + { + name: "MitmdCustomGatewayTarget", + providerHosts: []string{aibridgeproxyd.HostAnthropic}, + allowedPorts: []string{"443"}, + buildTargetURL: func(_ *url.URL) (string, error) { + return "https://api.anthropic.com/v1/messages", nil + }, + customGateway: true, + expectedPath: "/anthropic/v1/messages", + expectedBody: "hello from custom gateway", + provider: "anthropic", + }, + { + name: "MitmdCustomGatewayTargetWithPath", + providerHosts: []string{aibridgeproxyd.HostAnthropic}, + allowedPorts: []string{"443"}, + buildTargetURL: func(_ *url.URL) (string, error) { + return "https://api.anthropic.com/v1/messages", nil + }, + customGateway: true, + gatewayPath: agplaibridge.AIGatewayRootPath, + expectedPath: "/api/v2/ai-gateway/anthropic/v1/messages", + expectedBody: "hello from custom gateway", + provider: "anthropic", + }, { name: "TunneledUnknownHost", providerHosts: []string{aibridgeproxyd.HostAnthropic, aibridgeproxyd.HostOpenAI}, @@ -1361,7 +1404,8 @@ func TestProxy_MITM(t *testing.T) { buildTargetURL: func(tunneledURL *url.URL) (string, error) { return url.JoinPath(tunneledURL.String(), "/some/path") }, - tunneled: true, + tunneled: true, + expectedBody: "hello from tunneled", }, } @@ -1405,9 +1449,28 @@ func TestProxy_MITM(t *testing.T) { providerHosts = []string{tunneledURL.Hostname()} } - // Start the proxy server pointing to our mock aibridged. + gatewayURL := aibridgedServer.URL + if tt.customGateway { + customGateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + receivedAuthz = r.Header.Get("Authorization") + receivedBYOK = r.Header.Get(agplaibridge.HeaderCoderToken) + receivedRequestID = r.Header.Get(agplaibridge.HeaderCoderRequestID) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello from custom gateway")) + })) + t.Cleanup(customGateway.Close) + gatewayURL = customGateway.URL + if tt.gatewayPath != "" { + var err error + gatewayURL, err = url.JoinPath(gatewayURL, tt.gatewayPath) + require.NoError(t, err) + } + } + + // Start the proxy server pointing to our mock gateway. srv := newTestProxy(t, - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(gatewayURL), withAllowedPorts(allowedPorts...), withProviderHosts(providerHosts...), withMetrics(metrics), @@ -1430,9 +1493,8 @@ func TestProxy_MITM(t *testing.T) { certPool = getProxyCertPool(t) } - // Simulate the primary proxy use case: the Coder - // token is in Proxy-Authorization, and the user's - // own LLM token is in Authorization. + // Simulate the primary proxy use case: the Coder token is in + // Proxy-Authorization, and the user LLM token is in Authorization. client := newProxyClient(t, srv, makeProxyAuthHeader("coder-token"), certPool, false) req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, targetURL, strings.NewReader(`{}`)) require.NoError(t, err) @@ -1446,6 +1508,10 @@ func TestProxy_MITM(t *testing.T) { body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) + expectedBody := tt.expectedBody + if expectedBody == "" { + expectedBody = "hello from aibridged" + } // Gather metrics for verification. gatheredMetrics, err := reg.Gather() @@ -1453,7 +1519,7 @@ func TestProxy_MITM(t *testing.T) { if tt.tunneled { // Verify request went to target server, not aibridged. - require.Equal(t, "hello from tunneled", string(body)) + require.Equal(t, expectedBody, string(body)) require.Empty(t, receivedPath, "aibridged should not receive tunneled requests") require.Empty(t, receivedAuthz, "tunneled requests should not reach aibridged") require.Empty(t, receivedRequestID, "tunneled requests should not have request ID header") @@ -1467,10 +1533,10 @@ func TestProxy_MITM(t *testing.T) { require.False(t, testutil.PromGaugeGathered(t, gatheredMetrics, "inflight_mitm_requests", tt.provider)) require.False(t, testutil.PromCounterGathered(t, gatheredMetrics, "mitm_responses_total", "200", tt.provider)) } else { - // Verify the request was routed to aibridged correctly. - require.Equal(t, "hello from aibridged", string(body)) + // Verify the request was routed to the gateway correctly. + require.Equal(t, expectedBody, string(body)) require.Equal(t, tt.expectedPath, receivedPath) - require.Equal(t, "Bearer user-llm-token", receivedAuthz, "user's LLM credentials must be forwarded") + require.Equal(t, "Bearer user-llm-token", receivedAuthz, "user LLM credentials must be forwarded") require.Equal(t, "coder-token", receivedBYOK, "proxy must inject BYOK header with Coder token") require.NotEmpty(t, receivedRequestID, "MITM'd requests must include request ID header") _, err := uuid.Parse(receivedRequestID) @@ -1513,7 +1579,7 @@ func TestProxy_MITM_BYOKInjection(t *testing.T) { expectBYOK: false, }, { - // BYOK: Authorization carries the user's token, + // BYOK: Authorization carries the user token, // which differs from the Coder token. The proxy injects // the BYOK header. name: "Authorization differs from Coder token", @@ -1546,7 +1612,7 @@ func TestProxy_MITM_BYOKInjection(t *testing.T) { t.Cleanup(aibridgedServer.Close) srv := newTestProxy(t, - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(aibridgedServer.URL), withProviderHosts(aibridgeproxyd.HostCopilot), ) @@ -1622,7 +1688,7 @@ func TestListenerTLS(t *testing.T) { var proxyOpts []testProxyOption proxyOpts = append(proxyOpts, withListenerTLS(listenerCertFile, listenerKeyFile), - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(aibridgedServer.URL), withAllowedPorts(targetURL.Port()), ) if tt.tunneled { @@ -1674,7 +1740,7 @@ func TestProxy_AIBridgeTLSVerification(t *testing.T) { t.Cleanup(aibridgeServer.Close) srv := newTestProxy(t, - withCoderAccessURL(aibridgeServer.URL), + withGatewayURL(aibridgeServer.URL), withProviderHosts(aibridgeproxyd.HostAnthropic), ) @@ -1761,10 +1827,10 @@ func TestServeCACert_CompoundPEM(t *testing.T) { logger := slogtest.Make(t, nil) srv, err := aibridgeproxyd.New(t.Context(), logger, aibridgeproxyd.Options{ - ListenAddr: "127.0.0.1:0", - CoderAccessURL: "http://localhost:3000", - MITMCertFile: compoundCertFile, - MITMKeyFile: keyFile, + ListenAddr: "127.0.0.1:0", + GatewayURL: "http://localhost:3000", + MITMCertFile: compoundCertFile, + MITMKeyFile: keyFile, }) require.NoError(t, err) t.Cleanup(func() { _ = srv.Close() }) @@ -1889,7 +1955,7 @@ func TestUpstreamProxy(t *testing.T) { buildTargetURL: func(_ *url.URL) string { return "https://api.anthropic.com:443/v1/messages" }, - expectedAIBridgePath: "/api/v2/ai-gateway/anthropic/v1/messages", + expectedAIBridgePath: "/anthropic/v1/messages", }, } @@ -1950,7 +2016,7 @@ func TestUpstreamProxy(t *testing.T) { // Hijack the connection to take over the raw TCP socket. // After responding "200 Connection Established", the proxy stops being // an HTTP server and becomes a transparent tunnel that copies bytes - // bidirectionally. The http package can't handle this mode, so we + // bidirectionally. The http package cannot handle this mode, so we // hijack and manage the connection ourselves. hijacker, ok := w.(http.Hijacker) if !ok { @@ -2038,7 +2104,7 @@ func TestUpstreamProxy(t *testing.T) { // Create aiproxy with upstream proxy configured. proxyOpts := []testProxyOption{ - withCoderAccessURL(aibridgeServer.URL), + withGatewayURL(aibridgeServer.URL), withProviderHosts(providerHosts...), withUpstreamProxy(upstreamProxyURLStr), withAllowedPorts("80", "443", parsedTargetURL.Port()), @@ -2060,7 +2126,7 @@ func TestUpstreamProxy(t *testing.T) { } // Create HTTP client configured to use aiproxy. Coder token - // in Proxy-Authorization, user's LLM token in Authorization. + // in Proxy-Authorization, user LLM token in Authorization. client := newProxyClient(t, srv, makeProxyAuthHeader("test-coder-token"), certPool, false) // Make request through aiproxy. @@ -2100,7 +2166,7 @@ func TestUpstreamProxy(t *testing.T) { require.Equal(t, tt.expectedAIBridgePath, aibridgePath, "aibridge should receive rewritten path") require.Equal(t, "Bearer user-llm-token", aibridgeAuthz, - "user's LLM credentials must be forwarded") + "user LLM credentials must be forwarded") require.Equal(t, "test-coder-token", aibridgeBYOK, "proxy must inject BYOK header with Coder token") require.Equal(t, requestBody, aibridgeBody, @@ -2146,7 +2212,7 @@ func TestProxy_MITM_CustomProvider(t *testing.T) { // withProviders, equivalent to the snapshot the daemon's Reload // builds from classified providers in production. srv := newTestProxy(t, - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(aibridgedServer.URL), withProviders(aibridgeproxyd.ReloadedProvider{ ProviderOutcome: aibridged.ProviderOutcome{ Name: openrouterProvider, @@ -2175,7 +2241,7 @@ func TestProxy_MITM_CustomProvider(t *testing.T) { // The proxy should route through the aibridge path using the custom // provider name. - require.Equal(t, "/api/v2/ai-gateway/"+openrouterProvider+"/api/v1/chat/completions", receivedPath) + require.Equal(t, "/"+openrouterProvider+"/api/v1/chat/completions", receivedPath) require.Equal(t, "coder-token", receivedBYOK) } @@ -2187,7 +2253,7 @@ func TestProxy_PrivateIPBlocking(t *testing.T) { targetHostname string useUpstreamProxy bool allowedCIDRs []string - coderAccessURLFn func(targetHostname, port string) string + gatewayURLFn func(targetHostname, port string) string expectBlocked bool expectDialFail bool }{ @@ -2232,19 +2298,19 @@ func TestProxy_PrivateIPBlocking(t *testing.T) { expectBlocked: false, }, { - // Direct IP: the Coder access URL host:port is always exempt. - name: "AllowedByCoderAccessURL", + // Direct IP: the AI Gateway URL host:port is always exempt. + name: "AllowedByGatewayURL", targetHostname: "127.0.0.1", - coderAccessURLFn: func(targetHostname, port string) string { + gatewayURLFn: func(targetHostname, port string) string { return fmt.Sprintf("http://%s:%s", targetHostname, port) }, expectBlocked: false, }, { - // Hostname: DNS resolves to 127.0.0.1, which is exempt as the Coder access URL. - name: "AllowedByCoderAccessURLByHostname", + // Hostname: DNS resolves to 127.0.0.1, which is exempt as the AI Gateway URL. + name: "AllowedByGatewayURLByHostname", targetHostname: "localhost", - coderAccessURLFn: func(targetHostname, port string) string { + gatewayURLFn: func(targetHostname, port string) string { return fmt.Sprintf("http://%s:%s", targetHostname, port) }, expectBlocked: false, @@ -2292,8 +2358,8 @@ func TestProxy_PrivateIPBlocking(t *testing.T) { // Always override the default allowedPrivateCIDRs so blocked cases // are not accidentally exempted by the loopback default. opts = append(opts, withAllowedPrivateCIDRs(tt.allowedCIDRs...)) - if tt.coderAccessURLFn != nil { - opts = append(opts, withCoderAccessURL(tt.coderAccessURLFn(tt.targetHostname, targetURL.Port()))) + if tt.gatewayURLFn != nil { + opts = append(opts, withGatewayURL(tt.gatewayURLFn(tt.targetHostname, targetURL.Port()))) } srv := newTestProxy(t, opts...) @@ -2360,7 +2426,7 @@ func TestProxy_APIDump(t *testing.T) { ) srv := newTestProxy(t, - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(aibridgedServer.URL), withAllowedPorts("443"), withProviderHosts(aibridgeproxyd.HostAnthropic), withNewDumper(func(provider, requestID string) aibridgeproxyd.RoundTripDumper { @@ -2407,7 +2473,7 @@ func TestProxy_APIDump_ErrorsDoNotAffectProxy(t *testing.T) { t.Cleanup(aibridgedServer.Close) srv := newTestProxy(t, - withCoderAccessURL(aibridgedServer.URL), + withGatewayURL(aibridgedServer.URL), withAllowedPorts("443"), withProviderHosts(aibridgeproxyd.HostAnthropic), withNewDumper(func(_, _ string) aibridgeproxyd.RoundTripDumper { diff --git a/enterprise/aibridgeproxyd/reload_test.go b/enterprise/aibridgeproxyd/reload_test.go index 70b770f89c..f71075e8ef 100644 --- a/enterprise/aibridgeproxyd/reload_test.go +++ b/enterprise/aibridgeproxyd/reload_test.go @@ -159,7 +159,7 @@ func newReloadTestHarness(t *testing.T) *reloadTestHarness { store := &providerStore{} metrics := aibridgeproxyd.NewMetrics(prometheus.NewRegistry()) srv := newTestProxy(t, - withCoderAccessURL(bridged.URL), + withGatewayURL(bridged.URL), withAllowedPorts("443"), withRefreshProviders(store.refresh), withMetrics(metrics), @@ -219,7 +219,7 @@ func (h *reloadTestHarness) sendRequest(t *testing.T, targetURL string) requestR } // expectRoutedTo asserts the proxy MITM'd the request and forwarded it -// to aibridged with the expected /api/v2/ai-gateway//. +// to aibridged with the expected //. func (h *reloadTestHarness) expectRoutedTo(t *testing.T, targetURL, expectedPath string) { t.Helper() @@ -325,7 +325,7 @@ func TestProxy_StaleTunnelStopsRoutingAfterProviderChange(t *testing.T) { // newTestProxy seeds the router from the store via the // initial Reload, so the first CONNECT is MITM'd as alpha. srv := newTestProxy(t, - withCoderAccessURL(bridged.URL), + withGatewayURL(bridged.URL), withAllowedPorts("443"), withRefreshProviders(store.refresh), ) @@ -361,7 +361,7 @@ func TestProxy_StaleTunnelStopsRoutingAfterProviderChange(t *testing.T) { status, err := sendThroughTunnel("/v1/messages") require.NoError(t, err) require.Equal(t, http.StatusOK, status) - require.Equal(t, "/api/v2/ai-gateway/alpha/v1/messages", recorder.load(), + require.Equal(t, "/alpha/v1/messages", recorder.load(), "first request must be routed to aibridged while alpha is enabled") // Apply the provider change and reload. The atomic router swap @@ -404,7 +404,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/alpha/v1/messages") h.expectProviderStatus(t, "alpha", "enabled") // UpdateProviderName: the same BaseURL with a new name must route @@ -414,7 +414,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "alpha-v2", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha-v2/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/alpha-v2/v1/messages") h.expectProviderStatus(t, "alpha-v2", "enabled") h.expectProviderAbsent(t, "alpha") @@ -424,7 +424,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "alpha-v2", baseURL: "https://alpha-new.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/ai-gateway/alpha-v2/v1/messages") + h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/alpha-v2/v1/messages") h.expectNotRouted(t, "https://alpha.invalid/v1/messages") h.expectProviderStatus(t, "alpha-v2", "enabled") @@ -435,8 +435,8 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/api/v2/ai-gateway/alpha-v2/v1/messages") - h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/ai-gateway/beta/v1/chat/completions") + h.expectRoutedTo(t, "https://alpha-new.invalid/v1/messages", "/alpha-v2/v1/messages") + h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/beta/v1/chat/completions") h.expectProviderStatus(t, "alpha-v2", "enabled") h.expectProviderStatus(t, "beta", "enabled") @@ -446,7 +446,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/api/v2/ai-gateway/beta/v1/chat/completions") + h.expectRoutedTo(t, "https://beta.invalid/v1/chat/completions", "/beta/v1/chat/completions") h.expectNotRouted(t, "https://alpha-new.invalid/v1/messages") h.expectProviderStatus(t, "beta", "enabled") h.expectProviderAbsent(t, "alpha-v2") @@ -466,7 +466,7 @@ func TestProxy_HotReloadRoutingCRUD(t *testing.T) { {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/alpha/v1/messages") h.expectProviderStatus(t, "alpha", "enabled") // Both timestamp gauges must have advanced through this sequence. @@ -495,7 +495,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/api/v2/ai-gateway/valid/v1/messages") + h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/valid/v1/messages") h.expectProviderStatus(t, "no-url", "error") h.expectProviderStatus(t, "valid", "enabled") }) @@ -514,7 +514,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/api/v2/ai-gateway/valid/v1/messages") + h.expectRoutedTo(t, "https://valid.invalid/v1/messages", "/valid/v1/messages") h.expectProviderStatus(t, "malformed", "error") h.expectProviderStatus(t, "no-host", "error") h.expectProviderStatus(t, "valid", "enabled") @@ -532,7 +532,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://shared.invalid/v1/messages", "/api/v2/ai-gateway/first/v1/messages") + h.expectRoutedTo(t, "https://shared.invalid/v1/messages", "/first/v1/messages") h.expectProviderStatus(t, "first", "enabled") h.expectProviderStatus(t, "second", "error") }) @@ -562,7 +562,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { {name: "alpha", baseURL: "https://alpha.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/alpha/v1/messages") // A refresh error must NOT clear the router: dropping the // provider host set on every transient DB hiccup would @@ -571,7 +571,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { err := h.srv.Reload(t.Context()) require.Error(t, err) assert.Contains(t, err.Error(), "refresh ai providers for proxy routing") - h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/api/v2/ai-gateway/alpha/v1/messages") + h.expectRoutedTo(t, "https://alpha.invalid/v1/messages", "/alpha/v1/messages") // Recovery: once the store returns providers again, the next // Reload applies the new snapshot. @@ -579,7 +579,7 @@ func TestProxy_HotReloadRoutingInvalidProviders(t *testing.T) { {name: "beta", baseURL: "https://beta.invalid/v1"}, }) require.NoError(t, h.srv.Reload(t.Context())) - h.expectRoutedTo(t, "https://beta.invalid/v1/messages", "/api/v2/ai-gateway/beta/v1/messages") + h.expectRoutedTo(t, "https://beta.invalid/v1/messages", "/beta/v1/messages") h.expectNotRouted(t, "https://alpha.invalid/v1/messages") }) } diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index 986e448656..4b1fdefe7c 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -12,6 +12,7 @@ import ( "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" @@ -57,11 +58,19 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (io.Closer, error) { } } + 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(), - CoderAccessURL: coderAPI.AccessURL.String(), + GatewayURL: target, MITMCertFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMCertFile.String(), MITMKeyFile: coderAPI.DeploymentValues.AI.BridgeProxyConfig.MITMKeyFile.String(), UpstreamProxy: coderAPI.DeploymentValues.AI.BridgeProxyConfig.UpstreamProxy.String(), @@ -92,6 +101,19 @@ func newAIBridgeProxyDaemon(coderAPI *coderd.API) (io.Closer, error) { }, 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 diff --git a/enterprise/cli/aibridgeproxyd_internal_test.go b/enterprise/cli/aibridgeproxyd_internal_test.go index b6ed0f22c8..4ff24b42f2 100644 --- a/enterprise/cli/aibridgeproxyd_internal_test.go +++ b/enterprise/cli/aibridgeproxyd_internal_test.go @@ -3,14 +3,64 @@ package cli import ( + "net/url" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/coderd/database" ) +func TestResolveAIGatewayProxyTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + accessURL *url.URL + target string + want string + wantErr bool + errContains string + }{ + { + name: "ExplicitTarget", + accessURL: &url.URL{Scheme: "https", Host: "coder.example.com", Path: "/coder"}, + target: "https://gateway.example.com/custom/path", + want: "https://gateway.example.com/custom/path", + }, + { + name: "EmbeddedFallback", + accessURL: &url.URL{Scheme: "https", Host: "coder.example.com", Path: "/coder"}, + want: "https://coder.example.com/coder" + agplaibridge.AIGatewayRootPath, + }, + { + name: "InvalidAccessURL", + accessURL: &url.URL{Scheme: "https", Host: "[::1"}, + wantErr: true, + errContains: "build embedded AI Gateway proxy target", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := resolveAIGatewayProxyTarget(tt.accessURL, tt.target) + if tt.wantErr { + require.Error(t, err) + assert.ErrorContains(t, err, tt.errContains) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + // TestClassifyProviderRow covers every branch of the classifier so the // disabled, error, and enabled paths are exercised through the // production code instead of relying on classifyRaw, the test mirror in diff --git a/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go index f9694cc92a..b45700dc25 100644 --- a/enterprise/cli/aigatewaystart.go +++ b/enterprise/cli/aigatewaystart.go @@ -35,7 +35,8 @@ import ( ) const ( - shutdownTimeout = 5 * time.Minute + shutdownTimeout = 5 * time.Minute + traceShutdownTimeout = 5 * time.Second healthzPath = "/healthz" readyzPath = "/readyz" @@ -127,7 +128,6 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { return xerrors.Errorf("make logger: %w", err) } defer closeLogger() - logger = logger.Named("ai-gateway") logger.Debug(signalCtx, "started debug logging") logger.Sync() @@ -142,7 +142,7 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { tracerProvider, _, closeTracing := agpl.ConfigureTraceProviderWithService(signalCtx, logger, vals, "coder-ai-gateway") defer func() { logger.Debug(signalCtx, "closing tracing") - traceCloseErr := shutdownWithTimeout(closeTracing, 5*time.Second) + traceCloseErr := shutdownWithTimeout(closeTracing, traceShutdownTimeout) logger.Debug(signalCtx, "tracing closed", slog.Error(traceCloseErr)) }() tracer := tracerProvider.Tracer("ai-gateway") @@ -156,7 +156,6 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { } gatewayLogger := logger.Named("ai-gateway") - // Standalone Gateway starts with an empty pool. Providers are // fetched later via GetAIProviders DRPC and pool is updated. pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, nil, gatewayLogger.Named("pool"), metrics, tracer) @@ -303,31 +302,29 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { return cmd } -// gatewayMiddleware composes the standalone gateway's per-request middleware. -// Tracing is outermost so request is traced even when the other guards short-circuit. func gatewayMiddleware(cfg codersdk.AIBridgeConfig, tracer trace.Tracer) func(http.Handler) http.Handler { mw := coderd.AIGatewayDataPlaneMiddleware(cfg) + // Tracing wraps outermost so rejected requests are still traced. traced := tracingMiddleware(tracer) return func(next http.Handler) http.Handler { return traced(mw(next)) } } -// newGatewayMux builds the standalone gateway's HTTP routes. -// The middleware is applied only to the LLM data-plane routes. func newGatewayMux(aibridgedHandler http.Handler, aibridgedReady func() bool, middleware func(http.Handler) http.Handler) *http.ServeMux { mux := http.NewServeMux() mux.Handle("/api/v2/aibridge/", middleware(http.StripPrefix("/api/v2/aibridge", aibridgedHandler))) mux.Handle("/api/v2/ai-gateway/", middleware(http.StripPrefix("/api/v2/ai-gateway", aibridgedHandler))) mux.Handle("/", middleware(aibridgedHandler)) - // healthz: returns 200 once the HTTP server is listening. + // Health probes are registered without middleware. mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) { + // healthz: returns 200 once the HTTP server is listening. w.WriteHeader(http.StatusOK) }) - // readyz: returns 200 only when the DRPC connection to coderd is established. mux.HandleFunc(readyzPath, func(w http.ResponseWriter, _ *http.Request) { + // readyz: returns 200 only when the DRPC connection to coderd is established. if aibridgedReady() { w.WriteHeader(http.StatusOK) return diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index f29a8f84a7..5d934b6b5a 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -259,6 +259,11 @@ AI GATEWAY PROXY OPTIONS: Path to the TLS private key file for the AI Gateway Proxy listener. Must be set together with AI Gateway Proxy TLS Certificate File. + --ai-gateway-proxy-target string, $CODER_AI_GATEWAY_PROXY_TARGET + Base URL of the AI Gateway to forward intercepted requests to. + Defaults to the embedded AI Gateway address at the Coder access URL + plus /api/v2/ai-gateway. + --ai-gateway-proxy-upstream string, $CODER_AI_GATEWAY_PROXY_UPSTREAM URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests through. Format: http://[user:pass@]host:port or diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fb487aaff1..a63fcdc279 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -115,6 +115,7 @@ export interface AIBridgeOpenAIConfig { export interface AIBridgeProxyConfig { readonly enabled: boolean; readonly listen_addr: string; + readonly target: string; readonly tls_cert_file: string; readonly tls_key_file: string; readonly cert_file: string;