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.
+143
View File
@@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
@@ -105,6 +106,148 @@ func TestCommandHelp(t *testing.T) {
))
}
func TestResolveClientConnection(t *testing.T) {
t.Parallel()
run := func(t *testing.T, configure func(config.Root), args ...string) (string, http.RoundTripper, error, error) {
t.Helper()
var root cli.RootCmd
var gotURL string
var gotTransport http.RoundTripper
var gotErr error
cmd, err := root.Command([]*serpent.Command{{
Use: "resolve",
Handler: func(*serpent.Invocation) error {
serverURL, transport, err := root.ResolveClientConnection()
if serverURL != nil {
gotURL = serverURL.String()
}
gotTransport = transport
gotErr = err
return nil
},
}})
require.NoError(t, err)
inv, cfg := clitest.NewWithCommand(t, cmd, args...)
if configure != nil {
configure(cfg)
}
runErr := inv.Run()
return gotURL, gotTransport, gotErr, runErr
}
tests := []struct {
name string
args []string
configure func(*testing.T, config.Root)
wantURL string
wantTransport bool
wantErr string
wantRunErr string
checkTransport func(*testing.T, http.RoundTripper)
}{
{
name: "MissingURL",
args: []string{"resolve"},
wantErr: cli.ErrClientURLNotConfigured.Error(),
},
{
name: "URLFlag",
args: []string{"--url", "https://example.com", "resolve"},
wantURL: "https://example.com",
wantTransport: true,
},
{
name: "ConfiguredURL",
args: []string{"resolve"},
configure: func(t *testing.T, cfg config.Root) {
t.Helper()
require.NoError(t, cfg.URL().Write("https://configured.example.com"))
},
wantURL: "https://configured.example.com",
wantTransport: true,
},
{
name: "URLFlagOverridesConfig",
args: []string{"--url", "https://flag.example.com", "resolve"},
configure: func(t *testing.T, cfg config.Root) {
t.Helper()
require.NoError(t, cfg.URL().Write("https://configured.example.com"))
},
wantURL: "https://flag.example.com",
wantTransport: true,
},
{
name: "InvalidURLFlag",
args: []string{"--url", "%zz", "resolve"},
wantRunErr: "invalid URL escape",
},
{
name: "ClientTLSConfig",
args: func() []string {
certPath, keyPath := generateTLSCertificate(t)
return []string{
"--url", "https://example.com",
"--client-tls-cert-file", certPath,
"--client-tls-key-file", keyPath,
"resolve",
}
}(),
wantURL: "https://example.com",
wantTransport: true,
checkTransport: func(t *testing.T, transport http.RoundTripper) {
t.Helper()
httpTransport, ok := transport.(*http.Transport)
require.True(t, ok)
require.NotNil(t, httpTransport.TLSClientConfig)
require.Len(t, httpTransport.TLSClientConfig.Certificates, 1)
},
},
{
name: "TLSConfigError",
args: []string{
"--url", "https://example.com",
"--client-tls-cert-file", "/tmp/missing-cert.pem",
"resolve",
},
wantErr: "load client TLS config: --client-tls-cert-file and --client-tls-key-file must be specified together",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var configure func(config.Root)
if tc.configure != nil {
configure = func(cfg config.Root) {
tc.configure(t, cfg)
}
}
serverURL, transport, err, runErr := run(t, configure, tc.args...)
if tc.wantRunErr != "" {
require.ErrorContains(t, runErr, tc.wantRunErr)
return
}
require.NoError(t, runErr)
if tc.wantErr != "" {
require.ErrorContains(t, err, tc.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, tc.wantURL, serverURL)
require.Equal(t, tc.wantTransport, transport != nil)
if tc.checkTransport != nil {
tc.checkTransport(t, transport)
}
})
}
}
func TestRoot(t *testing.T) {
t.Parallel()
t.Run("MissingRootCommand", func(t *testing.T) {