feat: add ai-gateway start command (#26605)

> AI Tools were used to produce this PR

This PR adds `coder ai-gateway start` command that runs the AI Gateway
as an independent process.

- Standalone process doesn't have access to DB. Uses DRPC services under
`/api/v2/ai-gateway/serve`for auth, recording and provider
initialization.
- It only handles LLM traffic, other endpoints (eg. `/sessions`) are
only available though `coderd`.
- The standalone gateway reuses applicable flags from AI Gateway
deployment options. Provider-seeding and coderd-only options are
excluded.
- Only added to fat build, the slim build stub rejects the command.

Some wiring used by this new command is added.

**`NewWebsocketDialer`** - implements the standalone gateway's
connection to coderd's `/api/v2/ai-gateway/serve` endpoint. It upgrades
to a WebSocket, multiplexes with yamux, and wires all DRPC services.

**`AIGatewayDataPlaneMiddleware`** - extracts the per-request middleware
chain (concurrency limiting, rate limiting, BYOK gating) into a shared
function used by both the embedded route and the standalone gateway.

**`RootCmd.ResolveClientConnection`** - resolve the deployment URL and
builds an HTTP transport without requiring a session token. Used in
`ai-gateway start`command as it authenticates using different credential
type.

---------

Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
Paweł Banaszewski
2026-07-08 11:12:53 +02:00
committed by GitHub
co-authored by Danny Kopping
parent 195dffc651
commit ccba3969ab
18 changed files with 1418 additions and 150 deletions
+47 -10
View File
@@ -58,6 +58,8 @@ var (
// anything.
ErrSilent = xerrors.New("silent error")
ErrClientURLNotConfigured = xerrors.New("client URL is not configured")
errKeyringNotSupported = xerrors.New("keyring storage is not supported on this operating system; omit --use-keyring to use file-based storage")
)
@@ -602,23 +604,58 @@ func (r *RootCmd) SetClock(clk quartz.Clock) {
// ensureClientURL loads the client URL from the config file if it
// wasn't provided via --url or CODER_URL.
func (r *RootCmd) ensureClientURL() error {
if r.clientURL != nil && r.clientURL.String() != "" {
return nil
}
rawURL, err := r.createConfig().URL().Read()
// If the configuration files are absent, the user is logged out.
if os.IsNotExist(err) {
binPath, err := os.Executable()
if err != nil {
u, err := r.resolveClientURL()
if errors.Is(err, ErrClientURLNotConfigured) {
binPath, execErr := os.Executable()
if execErr != nil {
binPath = "coder"
}
return xerrors.Errorf(notLoggedInMessage, binPath)
}
if err != nil {
return err
}
r.clientURL, err = url.Parse(strings.TrimSpace(rawURL))
return err
r.clientURL = u
return nil
}
func (r *RootCmd) resolveClientURL() (*url.URL, error) {
if r.clientURL != nil && r.clientURL.String() != "" {
return r.clientURL, nil
}
rawURL, err := r.createConfig().URL().Read()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrClientURLNotConfigured
}
return nil, xerrors.Errorf("read configured URL: %w", err)
}
parsedURL, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, xerrors.Errorf("parse configured URL: %w", err)
}
return parsedURL, nil
}
// ResolveClientConnection resolves the deployment URL and client TLS transport
// without reading or requiring a user session.
func (r *RootCmd) ResolveClientConnection() (*url.URL, http.RoundTripper, error) {
serverURL, err := r.resolveClientURL()
if err != nil {
return nil, nil, err
}
if err := r.ensureTLSConfig(); err != nil {
return nil, nil, xerrors.Errorf("load client TLS config: %w", err)
}
transport, err := newHTTPTransport(r.tlsConfig)
if err != nil {
return nil, nil, xerrors.Errorf("create HTTP transport: %w", err)
}
return serverURL, transport, nil
}
// ensureTLSConfig loads the TLS configuration from files if specified.