mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Danny Kopping
parent
195dffc651
commit
ccba3969ab
@@ -20,6 +20,7 @@ func (r *RootCmd) aiGateway() *serpent.Command {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
r.aiGatewayStart(),
|
||||
r.aiGatewayKeys(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"cdr.dev/slog/v3/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/aibridge"
|
||||
agpl "github.com/coder/coder/v2/cli"
|
||||
"github.com/coder/coder/v2/coderd/aibridged"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/enterprise/coderd"
|
||||
"github.com/coder/retry"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
const (
|
||||
shutdownTimeout = 5 * time.Minute
|
||||
|
||||
keyFlagsExclusiveErr = "--key and --key-file options are mutually exclusive"
|
||||
keyFlagsMissingErr = "an AI Gateway key is required, set --key (CODER_AI_GATEWAY_KEY) or --key-file (CODER_AI_GATEWAY_KEY_FILE)"
|
||||
)
|
||||
|
||||
// aiGatewayStart runs the AI Gateway as a standalone process.
|
||||
func (r *RootCmd) aiGatewayStart() *serpent.Command {
|
||||
var (
|
||||
key string
|
||||
keyFile string
|
||||
httpAddress string
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
verbose bool
|
||||
)
|
||||
|
||||
vals := new(codersdk.DeploymentValues)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "start",
|
||||
Short: "Run a standalone AI Gateway server",
|
||||
Long: "Runs a standalone replica of the AI Gateway. Standalone replicas " +
|
||||
"serve LLM client traffic on a dedicated HTTP listener and connect " +
|
||||
"to coderd using the Coder deployment URL and an AI Gateway key.\n\n" +
|
||||
"Set --url or CODER_URL to the Coder deployment address, and set " +
|
||||
"--key (CODER_AI_GATEWAY_KEY) or --key-file " +
|
||||
"(CODER_AI_GATEWAY_KEY_FILE). A user login or session token is " +
|
||||
"not required.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
signalCtx, stop := inv.SignalNotifyContext(inv.Context(), agpl.StopSignals...)
|
||||
defer stop()
|
||||
|
||||
resolvedKey, err := resolveAIGatewayKey(key, keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TLS is opt-in and requires both files; setting only one is
|
||||
// an error. Default is plain HTTP.
|
||||
if (tlsCertFile == "") != (tlsKeyFile == "") {
|
||||
return xerrors.New("--tls-cert-file and --tls-key-file options must be provided together")
|
||||
}
|
||||
|
||||
serverURL, transport, err := r.ResolveClientConnection()
|
||||
if err != nil {
|
||||
if errors.Is(err, agpl.ErrClientURLNotConfigured) {
|
||||
return xerrors.New("AI Gateway requires --url or CODER_URL to point at the Coder deployment")
|
||||
}
|
||||
return xerrors.Errorf("configure Coder deployment connection: %w", err)
|
||||
}
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stderr))
|
||||
if verbose {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
|
||||
// Metrics and tracing are not exposed by standalone mode yet
|
||||
// (TODO AIGOV-317), but the pool and the reloader require a metrics
|
||||
// object and a tracer.
|
||||
registry := prometheus.NewRegistry()
|
||||
metrics := aibridge.NewMetrics(registry)
|
||||
providerMetrics := aibridged.NewMetrics(registry)
|
||||
tracer := tracenoop.NewTracerProvider().Tracer("aibridged")
|
||||
|
||||
// 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, logger.Named("pool"), metrics, tracer)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create request pool: %w", err)
|
||||
}
|
||||
|
||||
dialer := aibridged.NewWebsocketDialer(serverURL, transport, resolvedKey)
|
||||
aibridgedCtx, aibridgedCancel := context.WithCancel(context.Background())
|
||||
defer aibridgedCancel()
|
||||
srv, err := aibridged.New(aibridgedCtx, pool, dialer, logger.Named("aibridged"), tracer)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start AI Gateway daemon: %w", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
// Fetch the initial provider set from coderd, retrying until
|
||||
// success.
|
||||
// TODO(AIGOV-465): the standalone gateway has no refresh trigger
|
||||
// yet, so this runs once on startup.
|
||||
clientFn := func() (aibridged.DRPCClient, error) {
|
||||
return srv.ClientContext(signalCtx)
|
||||
}
|
||||
providerLogger := logger.Named("aibridge.providers")
|
||||
reloader := agpl.NewPoolRPCReloader(pool, clientFn, vals.AI.BridgeConfig, providerLogger, metrics, providerMetrics)
|
||||
if err := loadProviders(signalCtx, reloader, providerLogger, srv.Done()); err != nil {
|
||||
if signalCtx.Err() != nil {
|
||||
logger.Info(signalCtx, "shutting down standalone AI Gateway")
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("initialize ai providers: %w", err)
|
||||
}
|
||||
|
||||
mw := coderd.AIGatewayDataPlaneMiddleware(vals.AI.BridgeConfig)
|
||||
|
||||
// The standalone listener is dedicated to Gateway traffic, so
|
||||
// the daemon is served at the root. The /api/v2/ai-gateway
|
||||
// and /api/v2/aibridge/ aliases are added for compatibility
|
||||
// with the embedded route.
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/api/v2/aibridge/", mw(http.StripPrefix("/api/v2/aibridge", srv)))
|
||||
mux.Handle("/api/v2/ai-gateway/", mw(http.StripPrefix("/api/v2/ai-gateway", srv)))
|
||||
mux.Handle("/", mw(srv))
|
||||
|
||||
listener, err := net.Listen("tcp", httpAddress)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listen on %q: %w", httpAddress, err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
logger.Info(signalCtx, "standalone AI Gateway listening",
|
||||
slog.F("address", listener.Addr().String()),
|
||||
slog.F("coder_url", serverURL.String()),
|
||||
slog.F("tls", tlsCertFile != ""),
|
||||
)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: time.Minute,
|
||||
}
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
if tlsCertFile != "" {
|
||||
serveErr <- httpServer.ServeTLS(listener, tlsCertFile, tlsKeyFile)
|
||||
} else {
|
||||
serveErr <- httpServer.Serve(listener)
|
||||
}
|
||||
}()
|
||||
|
||||
var aibridgedErr error
|
||||
select {
|
||||
case <-signalCtx.Done():
|
||||
logger.Info(signalCtx, "shutting down standalone AI Gateway")
|
||||
case <-srv.Done():
|
||||
aibridgedErr = srv.Err()
|
||||
case err := <-serveErr:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return xerrors.Errorf("serve: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer shutdownCancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
return xerrors.Errorf("shutdown http server: %w", err)
|
||||
}
|
||||
if aibridgedErr != nil {
|
||||
return xerrors.Errorf("AI Gateway daemon exited: %w", aibridgedErr)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "key",
|
||||
Env: "CODER_AI_GATEWAY_KEY",
|
||||
Description: "The AI Gateway key used to authenticate to coderd.",
|
||||
Value: serpent.StringOf(&key),
|
||||
},
|
||||
{
|
||||
Flag: "key-file",
|
||||
Env: "CODER_AI_GATEWAY_KEY_FILE",
|
||||
Description: "Path to a file containing the AI Gateway key used to authenticate to coderd.",
|
||||
Value: serpent.StringOf(&keyFile),
|
||||
},
|
||||
{
|
||||
Flag: "http-address",
|
||||
Env: "CODER_AI_GATEWAY_HTTP_ADDRESS",
|
||||
Description: "The bind address to serve incoming AI Gateway client traffic.",
|
||||
Default: "127.0.0.1:4001",
|
||||
Value: serpent.StringOf(&httpAddress),
|
||||
},
|
||||
{
|
||||
Flag: "tls-cert-file",
|
||||
Env: "CODER_AI_GATEWAY_TLS_CERT_FILE",
|
||||
Description: "Path to a PEM-encoded TLS certificate. Enables TLS termination when set together with --tls-key-file.",
|
||||
Value: serpent.StringOf(&tlsCertFile),
|
||||
},
|
||||
{
|
||||
Flag: "tls-key-file",
|
||||
Env: "CODER_AI_GATEWAY_TLS_KEY_FILE",
|
||||
Description: "Path to a PEM-encoded TLS private key. Enables TLS termination when set together with --tls-cert-file.",
|
||||
Value: serpent.StringOf(&tlsKeyFile),
|
||||
},
|
||||
{
|
||||
Flag: "verbose",
|
||||
Env: "CODER_AI_GATEWAY_VERBOSE",
|
||||
Description: "Output debug-level logs.",
|
||||
Value: serpent.BoolOf(&verbose),
|
||||
Default: "false",
|
||||
},
|
||||
}
|
||||
|
||||
// Standalone Gateway only uses part of the options from "AI Gateway" group.
|
||||
// Other options from the group are coderd-only (eg. budget, provider-seeding).
|
||||
standaloneOpts := map[string]struct{}{
|
||||
"CODER_AI_GATEWAY_ALLOW_BYOK": {},
|
||||
"CODER_AI_GATEWAY_SEND_ACTOR_HEADERS": {},
|
||||
"CODER_AI_GATEWAY_DUMP_DIR": {},
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED": {},
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_FAILURE_THRESHOLD": {},
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_INTERVAL": {},
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_TIMEOUT": {},
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_MAX_REQUESTS": {},
|
||||
"CODER_AI_GATEWAY_MAX_CONCURRENCY": {},
|
||||
"CODER_AI_GATEWAY_RATE_LIMIT": {},
|
||||
}
|
||||
|
||||
var aiGatewayOpts serpent.OptionSet
|
||||
for _, opt := range vals.Options() {
|
||||
if opt.Group == nil || opt.Group.Name != "AI Gateway" {
|
||||
continue
|
||||
}
|
||||
if _, ok := standaloneOpts[opt.Env]; !ok {
|
||||
continue
|
||||
}
|
||||
aiGatewayOpts = append(aiGatewayOpts, opt)
|
||||
}
|
||||
|
||||
cmd.Options = append(cmd.Options, aiGatewayOpts...)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// resolveAIGatewayKey resolves key from --key or --key-file flags.
|
||||
// If both are set, an error is returned. If neither is set, an empty string is returned.
|
||||
func resolveAIGatewayKey(key string, keyFile string) (string, error) {
|
||||
if key != "" && keyFile != "" {
|
||||
return "", xerrors.New(keyFlagsExclusiveErr)
|
||||
}
|
||||
if key == "" && keyFile == "" {
|
||||
return "", xerrors.New(keyFlagsMissingErr)
|
||||
}
|
||||
if keyFile == "" {
|
||||
return key, nil
|
||||
}
|
||||
data, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("read AI Gateway key file %q: %w", keyFile, err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// loadProviders performs the standalone gateway's initial provider
|
||||
// load by driving reloader until it succeeds or ctx is canceled. The reloader
|
||||
// owns the actual fetch/build/replace/metrics work; the reloader's underlying
|
||||
// client blocks until the daemon connects to coderd, and the fetch may still
|
||||
// fail transiently (e.g. mid-seed contention or a dropped connection), so the
|
||||
// reload is retried with backoff. A successful empty provider list is a valid
|
||||
// result and ends the loop.
|
||||
//
|
||||
// TODO(AIGOV-465): the standalone gateway has no provider-change refresh
|
||||
// trigger yet, so this runs once on startup; provider add/enable will not
|
||||
// propagate to a running standalone gateway.
|
||||
func loadProviders(ctx context.Context, reloader aibridged.ProviderReloader, logger slog.Logger, aibridgedDone <-chan struct{}) error {
|
||||
for r := retry.New(50*time.Millisecond, 10*time.Second); r.Wait(ctx); {
|
||||
if err := reloader.Reload(ctx); err != nil {
|
||||
select {
|
||||
case <-aibridgedDone:
|
||||
return err
|
||||
default:
|
||||
}
|
||||
logger.Warn(ctx, "failed to load ai providers, will retry", slog.Error(err))
|
||||
continue
|
||||
}
|
||||
logger.Info(ctx, "loaded ai providers from coderd")
|
||||
return nil
|
||||
}
|
||||
if cause := context.Cause(ctx); cause != nil {
|
||||
return cause
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// blockingReloader blocks in Reload until the context is canceled, then
|
||||
// returns its error. It models the standalone gateway's initial reload
|
||||
// waiting on a daemon connection to an unreachable coderd.
|
||||
type blockingReloader struct {
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func (r *blockingReloader) Reload(ctx context.Context) error {
|
||||
select {
|
||||
case r.started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// failThenSucceedReloader fails the first failUntil reloads, then succeeds,
|
||||
// modeling a coderd connection or provider fetch that recovers after a few
|
||||
// transient failures.
|
||||
type failThenSucceedReloader struct {
|
||||
calls atomic.Int32
|
||||
failUntil int32
|
||||
}
|
||||
|
||||
func (r *failThenSucceedReloader) Reload(_ context.Context) error {
|
||||
if r.calls.Add(1) <= r.failUntil {
|
||||
return xerrors.New("transient failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// alwaysFailReloader returns the same error every time Reload is called.
|
||||
type alwaysFailReloader struct {
|
||||
calls atomic.Int32
|
||||
err error
|
||||
after func()
|
||||
called chan struct{}
|
||||
}
|
||||
|
||||
func (r *alwaysFailReloader) Reload(context.Context) error {
|
||||
r.calls.Add(1)
|
||||
if r.after != nil {
|
||||
r.after()
|
||||
}
|
||||
select {
|
||||
case r.called <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return r.err
|
||||
}
|
||||
|
||||
// TestLoadProviders_Interruptible verifies that a stop signal,
|
||||
// modeled by canceling the context, unblocks the initial provider load even
|
||||
// when the reloader is stuck waiting for coderd. This guards the standalone
|
||||
// "ai-gateway start" command against the regression where startup could not
|
||||
// be interrupted.
|
||||
func TestLoadProviders_Interruptible(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// testCtx bounds the test and drives the channel receives; runCtx is the
|
||||
// context handed to loadProviders and is canceled to model a
|
||||
// stop signal. They are distinct so the receives still work after the
|
||||
// signal context is canceled.
|
||||
testCtx := testutil.Context(t, testutil.WaitShort)
|
||||
runCtx, cancel := context.WithCancel(testCtx)
|
||||
defer cancel()
|
||||
|
||||
reloader := &blockingReloader{started: make(chan struct{}, 1)}
|
||||
logger := slog.Make()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- loadProviders(runCtx, reloader, logger, nil)
|
||||
}()
|
||||
|
||||
// Wait for the reload to be in-flight, then cancel as a signal would.
|
||||
testutil.RequireReceive(testCtx, t, reloader.started)
|
||||
cancel()
|
||||
|
||||
err := testutil.RequireReceive(testCtx, t, done)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
// TestLoadProviders_RetrySucceeds verifies loadProviders keeps retrying past
|
||||
// transient failures and returns nil once a reload succeeds. This guards the
|
||||
// retry contract: replacing the loop's continue with a return would fail here.
|
||||
func TestLoadProviders_RetrySucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
reloader := &failThenSucceedReloader{failUntil: 2}
|
||||
|
||||
require.NoError(t, loadProviders(ctx, reloader, slog.Make(), nil))
|
||||
require.GreaterOrEqual(t, reloader.calls.Load(), int32(3))
|
||||
}
|
||||
|
||||
func TestLoadProviders_AIBridgedDoneStopsRetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errMsg := "aibridged fatal"
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
aibridgedDone := make(chan struct{})
|
||||
reloader := &alwaysFailReloader{
|
||||
err: xerrors.New(errMsg),
|
||||
called: make(chan struct{}, 1),
|
||||
after: func() {
|
||||
close(aibridgedDone)
|
||||
},
|
||||
}
|
||||
|
||||
err := loadProviders(ctx, reloader, slog.Make(), aibridgedDone)
|
||||
require.ErrorContains(t, err, errMsg)
|
||||
require.Equal(t, int32(1), reloader.calls.Load())
|
||||
}
|
||||
|
||||
func TestResolveAIGatewayKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyFile := filepath.Join(t.TempDir(), "gateway.key")
|
||||
require.NoError(t, os.WriteFile(keyFile, []byte("file-key\n"), 0o600))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
keyFile string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "Nothing set",
|
||||
wantErr: keyFlagsMissingErr,
|
||||
},
|
||||
{
|
||||
name: "Key",
|
||||
key: "flag-key",
|
||||
want: "flag-key",
|
||||
},
|
||||
{
|
||||
name: "KeyFile",
|
||||
keyFile: keyFile,
|
||||
want: "file-key",
|
||||
},
|
||||
{
|
||||
name: "MutuallyExclusive",
|
||||
key: "flag-key",
|
||||
keyFile: keyFile,
|
||||
wantErr: keyFlagsExclusiveErr,
|
||||
},
|
||||
{
|
||||
name: "MissingKeyFile",
|
||||
keyFile: filepath.Join(t.TempDir(), "missing.key"),
|
||||
wantErr: "read AI Gateway key file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resolveAIGatewayKey(tc.key, tc.keyFile)
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIGatewayStart_DeploymentOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := (&RootCmd{}).aiGatewayStart()
|
||||
|
||||
// Standalone Gateway only consumes deployment options used in LLM traffic.
|
||||
// Coderd-only settings such as provider seeds, retention,
|
||||
// structured logging, and Coder MCP injection must stay server-only.
|
||||
var got []string
|
||||
for _, opt := range cmd.Options {
|
||||
if opt.Group != nil && opt.Group.Name == "AI Gateway" {
|
||||
got = append(got, opt.Env)
|
||||
}
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"CODER_AI_GATEWAY_ALLOW_BYOK",
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED",
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_FAILURE_THRESHOLD",
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_INTERVAL",
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_MAX_REQUESTS",
|
||||
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_TIMEOUT",
|
||||
"CODER_AI_GATEWAY_DUMP_DIR",
|
||||
"CODER_AI_GATEWAY_MAX_CONCURRENCY",
|
||||
"CODER_AI_GATEWAY_RATE_LIMIT",
|
||||
"CODER_AI_GATEWAY_SEND_ACTOR_HEADERS",
|
||||
}
|
||||
require.ElementsMatch(t, want, got)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
agplcli "github.com/coder/coder/v2/cli"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) aiGatewayStart() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "start",
|
||||
Short: "Run a standalone AI Gateway server",
|
||||
// We accept RawArgs so all commands and flags are accepted.
|
||||
RawArgs: true,
|
||||
Hidden: true,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
agplcli.SlimUnsupported(inv.Stderr, "ai-gateway start")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
+2
-1
@@ -6,7 +6,8 @@ USAGE:
|
||||
Manage AI Gateway
|
||||
|
||||
SUBCOMMANDS:
|
||||
keys Manage AI Gateway keys
|
||||
keys Manage AI Gateway keys
|
||||
start Run a standalone AI Gateway server
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder ai-gateway start [flags]
|
||||
|
||||
Run a standalone AI Gateway server
|
||||
|
||||
Runs a standalone replica of the AI Gateway. Standalone replicas serve LLM
|
||||
client traffic on a dedicated HTTP listener and connect to coderd using the
|
||||
Coder deployment URL and an AI Gateway key.
|
||||
|
||||
Set --url or CODER_URL to the Coder deployment address, and set --key
|
||||
(CODER_AI_GATEWAY_KEY) or --key-file (CODER_AI_GATEWAY_KEY_FILE). A user login
|
||||
or session token is not required.
|
||||
|
||||
OPTIONS:
|
||||
--http-address string, $CODER_AI_GATEWAY_HTTP_ADDRESS (default: 127.0.0.1:4001)
|
||||
The bind address to serve incoming AI Gateway client traffic.
|
||||
|
||||
--key string, $CODER_AI_GATEWAY_KEY
|
||||
The AI Gateway key used to authenticate to coderd.
|
||||
|
||||
--key-file string, $CODER_AI_GATEWAY_KEY_FILE
|
||||
Path to a file containing the AI Gateway key used to authenticate to
|
||||
coderd.
|
||||
|
||||
--tls-cert-file string, $CODER_AI_GATEWAY_TLS_CERT_FILE
|
||||
Path to a PEM-encoded TLS certificate. Enables TLS termination when
|
||||
set together with --tls-key-file.
|
||||
|
||||
--tls-key-file string, $CODER_AI_GATEWAY_TLS_KEY_FILE
|
||||
Path to a PEM-encoded TLS private key. Enables TLS termination when
|
||||
set together with --tls-cert-file.
|
||||
|
||||
--verbose bool, $CODER_AI_GATEWAY_VERBOSE (default: false)
|
||||
Output debug-level logs.
|
||||
|
||||
AI GATEWAY OPTIONS:
|
||||
--ai-gateway-dump-dir string, $CODER_AI_GATEWAY_DUMP_DIR
|
||||
Base directory for dumping AI Gateway request/response pairs to disk
|
||||
for debugging. When set, each provider writes under a subdirectory
|
||||
named after the provider. Sensitive headers are redacted. Leave empty
|
||||
to disable.
|
||||
|
||||
--ai-gateway-allow-byok bool, $CODER_AI_GATEWAY_ALLOW_BYOK (default: true)
|
||||
Allow users to provide their own LLM API keys or subscriptions. When
|
||||
disabled, only centralized key authentication is permitted.
|
||||
|
||||
--ai-gateway-circuit-breaker-enabled bool, $CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED (default: false)
|
||||
Enable the circuit breaker to protect against cascading failures from
|
||||
upstream AI provider overload (503, 529).
|
||||
|
||||
--ai-gateway-max-concurrency int, $CODER_AI_GATEWAY_MAX_CONCURRENCY (default: 0)
|
||||
Maximum number of concurrent AI Gateway requests per replica. Set to 0
|
||||
to disable (unlimited).
|
||||
|
||||
--ai-gateway-rate-limit int, $CODER_AI_GATEWAY_RATE_LIMIT (default: 0)
|
||||
Maximum number of AI Gateway requests per second per replica. Set to 0
|
||||
to disable (unlimited).
|
||||
|
||||
--ai-gateway-send-actor-headers bool, $CODER_AI_GATEWAY_SEND_ACTOR_HEADERS (default: false)
|
||||
Once enabled, extra headers will be added to upstream requests to
|
||||
identify the user (actor) making requests to AI Gateway. This is only
|
||||
needed if you are using a proxy between AI Gateway and an upstream AI
|
||||
provider. This will send X-Ai-Bridge-Actor-Id (the ID of the user
|
||||
making the request) and X-Ai-Bridge-Actor-Metadata-Username (their
|
||||
username).
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
||||
Reference in New Issue
Block a user