diff --git a/docs/reference/cli/ai-gateway_start.md b/docs/reference/cli/ai-gateway_start.md
index b3f9d6c663b99..c39b210fc85f0 100644
--- a/docs/reference/cli/ai-gateway_start.md
+++ b/docs/reference/cli/ai-gateway_start.md
@@ -161,6 +161,15 @@ Output JSON logs to a given file.
Output Stackdriver compatible logs to a given file.
+### -c, --config
+
+| | |
+|-------------|---------------------------------|
+| Type | yaml-config-path |
+| Environment | $CODER_CONFIG_PATH |
+
+Specify a YAML file to load configuration from.
+
### --ai-gateway-max-concurrency
| | |
diff --git a/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go
index c32c6bdd51673..2475cffe59ad6 100644
--- a/enterprise/cli/aigatewaystart.go
+++ b/enterprise/cli/aigatewaystart.go
@@ -28,6 +28,7 @@ import (
aibridgemetrics "github.com/coder/coder/v2/aibridge/metrics"
agpl "github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clilog"
+ "github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/aibridged"
coderdtracing "github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
@@ -69,6 +70,9 @@ var aiGatewayInheritedEnvs = map[string]struct{}{
"CODER_TRACE_HONEYCOMB_API_KEY": {},
"CODER_TRACE_LOGS": {},
+ // Config
+ "CODER_CONFIG_PATH": {},
+
// AI Gateway
"CODER_AI_GATEWAY_ALLOW_BYOK": {},
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED": {},
@@ -112,6 +116,10 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command {
signalCtx, stop := inv.SignalNotifyContext(inv.Context(), agpl.StopSignals...)
defer stop()
+ if vals.Config != "" {
+ cliui.Warnf(inv.Stderr, "YAML support is experimental and offers no compatibility guarantees.")
+ }
+
resolvedKey, err := resolveAIGatewayKey(key, keyFile)
if err != nil {
return err
diff --git a/enterprise/cli/aigatewaystart_internal_test.go b/enterprise/cli/aigatewaystart_internal_test.go
index 89e64d69984f3..d30e06fe09a2e 100644
--- a/enterprise/cli/aigatewaystart_internal_test.go
+++ b/enterprise/cli/aigatewaystart_internal_test.go
@@ -592,6 +592,7 @@ func TestAIGatewayStart_InheritedOptions(t *testing.T) {
// Groups the gateway sources options from.
sourceGroups := map[string]struct{}{
+ "Config": {},
"Logging": {},
"Tracing": {},
"AI Gateway": {},
diff --git a/enterprise/cli/aigatewaystart_test.go b/enterprise/cli/aigatewaystart_test.go
index 4df5c856a59ee..b15a4f2aeb161 100644
--- a/enterprise/cli/aigatewaystart_test.go
+++ b/enterprise/cli/aigatewaystart_test.go
@@ -12,6 +12,8 @@ import (
"net/http/httptest"
"net/http/httputil"
"net/url"
+ "os"
+ "path/filepath"
"regexp"
"strings"
"sync"
@@ -239,6 +241,19 @@ func requireEventualAIGatewayStatus(ctx context.Context, t *testing.T, probeURL
}, testutil.WaitLong, testutil.IntervalFast, "%s never returned %d", probeURL, want)
}
+// startUnreachableCoderd starts a coderd stub that answers 503 to every
+// request, so the gateway daemon keeps retrying its control connection instead
+// of completing. It returns the stub's URL.
+func startUnreachableCoderd(t *testing.T) string {
+ t.Helper()
+
+ coderSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }))
+ t.Cleanup(coderSrv.Close)
+ return coderSrv.URL
+}
+
// TestAIGatewayStartE2E drives every part of the standalone gateway plumbing
// once through public surface only: the CLI starts with flags, connects to
// coderd with a gateway key, reports health and readiness, proxies an LLM
@@ -308,15 +323,12 @@ func TestAIGatewayStartE2E_InvalidKey(t *testing.T) {
func TestAIGatewayStart_HealthBeforeReady(t *testing.T) {
t.Parallel()
- // Given: a coderd that always answers 503
- coderSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- }))
- t.Cleanup(coderSrv.Close)
+ // Given: a coderd that always answers 503, so the daemon keeps retrying.
+ coderURL := startUnreachableCoderd(t)
ctx := testutil.Context(t, testutil.WaitShort)
// When: the gateway starts and binds its listener.
- baseURL, _ := startAIGatewayCommand(ctx, t, coderSrv.URL, "test-key")
+ baseURL, _ := startAIGatewayCommand(ctx, t, coderURL, "test-key")
// Then: healthz is already 200 while readyz stays 503.
// The startup log line is emitted after the bind, so no retry is needed.
@@ -679,3 +691,63 @@ func TestAIGatewayStartE2E_InFlightRequestSurvivesDisconnect(t *testing.T) {
// dropped, so this interception's usage rows are expected to be lost. Only
// the caller-visible outcome is asserted.
}
+
+// TestAIGatewayStart_ConfigYAML verifies that a YAML file supplied via
+// --config (CODER_CONFIG_PATH) configures the running standalone Gateway.
+// It enables the inherited Prometheus listener through YAML and asserts that the
+// listener comes up.
+func TestAIGatewayStart_ConfigYAML(t *testing.T) {
+ t.Parallel()
+
+ // Fake coderd that answers 503 so the daemon keeps retrying to connect.
+ coderURL := startUnreachableCoderd(t)
+ configFile := filepath.Join(t.TempDir(), "config.yaml")
+ err := os.WriteFile(configFile, []byte(
+ "introspection:\n prometheus:\n enable: true\n address: 127.0.0.1:0\n",
+ ), 0o600)
+ require.NoError(t, err)
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+ inv, _ := newCLI(t,
+ "ai-gateway", "start",
+ "--url", coderURL,
+ "--key", "test-key",
+ "--http-address", "127.0.0.1:0",
+ "--config", configFile,
+ )
+ inv = inv.WithContext(ctx)
+ pty := ptytest.New(t).Attach(inv)
+ waiter := clitest.StartWithWaiter(t, inv)
+
+ // The Prometheus listener is only started when prometheus.enable
+ // option is set which is enabled through YAML.
+ promLine := pty.ExpectRegexMatch(ctx, `http server listening\s+addr=[0-9.]+:[0-9]+\s+name=prometheus`)
+ matches := regexp.MustCompile(`addr=([0-9.]+:[0-9]+)`).FindStringSubmatch(promLine)
+ require.Len(t, matches, 2, "prometheus address not found in startup log: %q", promLine)
+ promURL := "http://" + matches[1] + "/metrics"
+ requireAIGatewayStatus(ctx, t, promURL, http.StatusOK)
+
+ waiter.Cancel()
+ require.NoError(t, waiter.Wait())
+}
+
+// TestAIGatewayStart_ConfigYAML_Invalid verifies that a YAML file with an
+// unknown option fails the command with a descriptive error.
+func TestAIGatewayStart_ConfigYAML_Invalid(t *testing.T) {
+ t.Parallel()
+
+ configFile := filepath.Join(t.TempDir(), "config.yaml")
+ err := os.WriteFile(configFile, []byte(
+ "introspection:\n prometheus:\n unknown_field: true\n",
+ ), 0o600)
+ require.NoError(t, err)
+
+ inv, _ := newCLI(t,
+ "ai-gateway", "start",
+ "--key", "test-key",
+ "--config", configFile,
+ )
+
+ err = inv.Run()
+ require.ErrorContains(t, err, `unknown option "introspection.prometheus.unknown_field"`)
+}
diff --git a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden
index ee183747dd7d8..5a7c1760cc708 100644
--- a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden
+++ b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden
@@ -63,6 +63,12 @@ AI GATEWAY OPTIONS:
making the request) and X-Ai-Bridge-Actor-Metadata-Username (their
username).
+CONFIG OPTIONS:
+Use a YAML configuration file when your server launch become unwieldy.
+
+ -c, --config yaml-config-path, $CODER_CONFIG_PATH
+ Specify a YAML file to load configuration from.
+
INTROSPECTION / LOGGING OPTIONS:
--log-human string, $CODER_LOGGING_HUMAN (default: /dev/stderr)
Output human-readable logs to a given file.