diff --git a/.claude/docs/DOCS_STYLE_GUIDE.md b/.claude/docs/DOCS_STYLE_GUIDE.md index ac3e6496072c8..1b12fef33f240 100644 --- a/.claude/docs/DOCS_STYLE_GUIDE.md +++ b/.claude/docs/DOCS_STYLE_GUIDE.md @@ -168,6 +168,37 @@ superseded by the canonical content guidelines. ## Code Examples +### Configuration examples: prefer environment variables + +When showing how to configure `coder server` in admin or setup +documentation, lead with the environment variable form. Production Coder +deployments are typically run as a system service, container, or Helm +chart, all of which set configuration through environment variables (for +example, Helm `values.yaml` for Kubernetes or `/etc/coder.d/coder.env` for +a system service). Showing the CLI flag form first forces operators to +mentally translate every example. + +Show the equivalent CLI flag only when the example is invoking +`coder server` directly (for local development or one-off runs), or as a +supporting note. Point readers at the +[configuration reference](../../docs/admin/setup/configuration-reference.md) +for the full mapping between environment variables, flags, and YAML keys. + +````markdown +```sh +# Preferred for admin/setup docs: +CODER_UPDATE_CHECK=false +``` +```` + +CLI flag form, reserved for ad-hoc invocations: + +````markdown +```sh +coder server --update-check=false +``` +```` + ### Command Examples ````markdown diff --git a/.gitattributes b/.gitattributes index 39e1717ed68e2..58a1bceb877b8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -36,3 +36,7 @@ site/e2e/provisionerGenerated.ts linguist-generated=true site/src/api/countriesGenerated.tsx linguist-generated=true site/src/api/rbacresourcesGenerated.tsx linguist-generated=true site/src/api/typesGenerated.ts linguist-generated=true + +# AI Gateway prices +coderd/aibridge/prices/data/prices.json linguist-generated=true +site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json linguist-generated=true diff --git a/.github/.linkspector.yml b/.github/.linkspector.yml index 88ce877a0a9b8..0cc4d240480bb 100644 --- a/.github/.linkspector.yml +++ b/.github/.linkspector.yml @@ -38,5 +38,8 @@ ignorePatterns: - pattern: "merriam-webster.com" # npmjs.com returns 403 from GitHub runner IPs - pattern: "npmjs.com" + # AWS Bedrock base URL appears as an illustrative placeholder in + # the generated configuration reference, not as a live link. + - pattern: "bedrock-runtime" aliveStatusCodes: - 200 diff --git a/.github/workflows/contrib.yaml b/.github/workflows/contrib.yaml index 2292246988e46..4d76882c5c1ad 100644 --- a/.github/workflows/contrib.yaml +++ b/.github/workflows/contrib.yaml @@ -116,7 +116,7 @@ jobs: # branch should not be protected branch: "main" # Some users have signed a corporate CLA with Coder so are exempt from signing our community one. - allowlist: "coryb,aaronlehmann,dependabot*,blink-so*,blinkagent*" + allowlist: "coryb,aaronlehmann,dependabot*,blink-so*,blinkagent*,coder-backporter-app[bot]" title: runs-on: ubuntu-latest diff --git a/.github/workflows/doc-check.yaml b/.github/workflows/doc-check.yaml index c692b7e2a8bff..935bd77647f8d 100644 --- a/.github/workflows/doc-check.yaml +++ b/.github/workflows/doc-check.yaml @@ -213,7 +213,7 @@ jobs: - name: Run doc-check via Coder Agent Chat if: steps.check-secrets.outputs.skip != 'true' - uses: coder/agents-chat-action@b3fc81d7dae5006dd124e98ef6fada1a36cdd86e # v0.3.0 + uses: coder/agents-chat-action@9b3ac42d568e9206f9cd51a97789820d6935cc8d # v0.5.1 with: coder-url: ${{ secrets.DOC_CHECK_CODER_URL }} coder-token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 95690adb25f0d..6e93dec7ea047 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -541,6 +541,22 @@ jobs: gcloud storage cp "./build/${detached_signature}" "gs://releases.coder.com/coder-cli/${version}/${cli_name}.asc" done + # Mint a short-lived installation token from the dedicated release + # GitHub App. The default GITHUB_TOKEN (github-actions[bot]) cannot be + # added to the "Auto-imported tag create protections" ruleset bypass + # list, so `gh release create` fails to create the tag with a 403. The + # App is added to that ruleset's bypass list as an Integration actor. + - name: Generate release App token + id: release_app_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + # The App is installed at the org level, so resolve the org + # installation (avoids a 404 on the per-repo installation lookup). + owner: ${{ github.repository_owner }} + repositories: coder + - name: Publish release run: | set -euo pipefail @@ -579,7 +595,9 @@ jobs: --release-notes-file "$CODER_RELEASE_NOTES_FILE" \ "${files[@]}" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Use the dedicated release App token (github-actions[bot] is blocked + # from creating tags by the tag-create protection ruleset). + GITHUB_TOKEN: ${{ steps.release_app_token.outputs.token }} CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }} VERSION: ${{ steps.version.outputs.version }} CREATED_LATEST_TAG: ${{ steps.build_docker.outputs.created_latest_tag }} diff --git a/Makefile b/Makefile index fd1af317c6ba4..b7ae184a19eb4 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,7 @@ endif docs/manifest.json \ docs/admin/integrations/prometheus.md \ docs/admin/security/audit-logs.md \ + docs/admin/setup/configuration-reference.md \ docs/reference/cli/index.md \ coderd/apidoc/swagger.json \ coderd/rbac/object_gen.go \ @@ -153,6 +154,12 @@ _gen/bin/clidocgen: $(CLIDOCGEN_INPUTS) | _gen @mkdir -p _gen/bin go build -o $@ ./scripts/clidocgen +# configdocgen reflects over codersdk.DeploymentValues to produce the +# configuration reference page. +_gen/bin/configdocgen: $(wildcard scripts/configdocgen/*.go) $(wildcard codersdk/*.go) | _gen + @mkdir -p _gen/bin + go build -o $@ ./scripts/configdocgen + _gen/bin/dbdump: $(wildcard coderd/database/gen/dump/*.go) $(DBDUMP_INPUTS) | _gen @mkdir -p _gen/bin go build -o $@ ./coderd/database/gen/dump @@ -998,6 +1005,7 @@ GEN_FILES := \ docs/reference/cli/index.md \ docs/admin/security/audit-logs.md \ docs/install/releases/feature-stages.md \ + docs/admin/setup/configuration-reference.md \ coderd/apidoc/swagger.json \ docs/manifest.json \ provisioner/terraform/testdata/version \ @@ -1096,6 +1104,7 @@ gen/mark-fresh: docs/reference/cli/index.md \ docs/admin/security/audit-logs.md \ docs/install/releases/feature-stages.md \ + docs/admin/setup/configuration-reference.md \ coderd/apidoc/swagger.json \ docs/manifest.json \ site/e2e/provisionerGenerated.ts \ @@ -1342,6 +1351,13 @@ docs/install/releases/feature-stages.md: \ pnpm exec markdown-table-formatter "$$tmpfile" && \ mv "$$tmpfile" "$@" && rm -rf "$$tmpdir" +docs/admin/setup/configuration-reference.md: node_modules/.installed $(wildcard scripts/configdocgen/*.go) $(wildcard codersdk/*.go) _gen/bin/configdocgen | _gen + tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \ + _gen/bin/configdocgen --out="$$tmpfile" && \ + pnpm exec markdownlint-cli2 --fix "$$tmpfile" && \ + pnpm exec markdown-table-formatter "$$tmpfile" && \ + mv "$$tmpfile" "$@" && rm -rf "$$tmpdir" + coderd/apidoc/.gen: \ node_modules/.installed \ scripts/apidocgen/node_modules/.installed \ diff --git a/agent/agentsocket/server.go b/agent/agentsocket/server.go index 605feeec05a39..aee3a685a0a4a 100644 --- a/agent/agentsocket/server.go +++ b/agent/agentsocket/server.go @@ -56,7 +56,7 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) { return nil, xerrors.Errorf("failed to register drpc service: %w", err) } - server.drpcServer = drpcserver.NewWithOptions(mux, drpcserver.Options{ + server.drpcServer = drpcsdk.NewServer(logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if errors.Is(err, context.Canceled) || diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index 0f5d83a98f982..457da28ebc5df 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -78,6 +78,7 @@ func NewClientWithSecrets(t testing.TB, fakeAAPI := NewFakeAgentAPI(t, logger, mp, statsChan) err = agentproto.DRPCRegisterAgent(mux, fakeAAPI) require.NoError(t, err) + // Keep panics unrecovered in this test server so they fail tests loudly. server := drpcserver.NewWithOptions(mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/aibridge/bridge.go b/aibridge/bridge.go index 5f3a5fbce9279..2275304a88954 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -18,6 +18,7 @@ import ( "github.com/sony/gobreaker/v2" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + "golang.org/x/net/http/httpguts" "golang.org/x/xerrors" "cdr.dev/slog/v3" @@ -28,8 +29,10 @@ import ( "github.com/coder/coder/v2/aibridge/metrics" "github.com/coder/coder/v2/aibridge/provider" "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/routing" "github.com/coder/coder/v2/aibridge/tracing" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" + "github.com/coder/coder/v2/coderd/util/xurl" "github.com/coder/quartz" ) @@ -248,6 +251,18 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC client := GuessClient(r) sessionID := GuessSessionID(client, r) + if isWebSocketUpgrade(r) { + route := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("/%s", p.Name())) + logger.Debug(ctx, "rejecting unsupported WebSocket upgrade", + slog.F("provider", p.Name()), + slog.F("route", route), + slog.F("client", string(client)), + slog.F("client_session_id", sessionID), + ) + http.Error(w, "WebSocket transport is not supported, use HTTP", http.StatusNotImplemented) + return + } + // Read and validate Agent Firewall correlation headers. The // values are captured here and recorded below; the headers // themselves are stripped from the upstream request by @@ -381,6 +396,13 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC } } +// isWebSocketUpgrade reports whether r is a WebSocket opening handshake. +func isWebSocketUpgrade(r *http.Request) bool { + return r.Method == http.MethodGet && + httpguts.HeaderValuesContainsToken(r.Header.Values("Connection"), "upgrade") && + httpguts.HeaderValuesContainsToken(r.Header.Values("Upgrade"), "websocket") +} + // writeRequestBodyTooLarge writes a human-readable 413 response indicating that // the request body exceeded maxRequestBodyBytes. func writeRequestBodyTooLarge(w http.ResponseWriter) { @@ -418,6 +440,11 @@ func (b *RequestBridge) ServeHTTP(rw http.ResponseWriter, r *http.Request) { // request. ctx := mergeContexts(r.Context(), b.inflightCtx) + if xurl.ContainsEncodedPath(r.URL) { + http.Error(rw, routing.InvalidPathMessage, http.StatusBadRequest) + return + } + // Enforce the request body size limit. MaxBytesReader counts bytes as // they are read from the connection and fails when the limit is exceeded. r.Body = http.MaxBytesReader(rw, r.Body, maxRequestBodyBytes) diff --git a/aibridge/bridge_internal_test.go b/aibridge/bridge_internal_test.go index 561f758de122a..e92d554ec59d9 100644 --- a/aibridge/bridge_internal_test.go +++ b/aibridge/bridge_internal_test.go @@ -10,6 +10,36 @@ import ( agplaibridge "github.com/coder/coder/v2/coderd/aibridge" ) +func TestIsWebSocketUpgrade(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + connection string + upgrade string + want bool + }{ + {name: "websocket upgrade", method: http.MethodGet, connection: "keep-alive, Upgrade", upgrade: "WebSocket", want: true}, + {name: "non-GET request", method: http.MethodPost, connection: "Upgrade", upgrade: "websocket", want: false}, + {name: "missing connection upgrade", method: http.MethodGet, connection: "keep-alive", upgrade: "websocket", want: false}, + {name: "different upgrade protocol", method: http.MethodGet, connection: "Upgrade", upgrade: "h2c", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequestWithContext(t.Context(), tc.method, "/", nil) + require.NoError(t, err) + req.Header.Set("Connection", tc.connection) + req.Header.Set("Upgrade", tc.upgrade) + + assert.Equal(t, tc.want, isWebSocketUpgrade(req)) + }) + } +} + func TestExtractAgentFirewallHeaders(t *testing.T) { t.Parallel() diff --git a/aibridge/bridge_test.go b/aibridge/bridge_test.go index d8e9103a7cb9f..cc42c9fb0a235 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -8,17 +8,21 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/aibridge/aibridgetest" "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/provider" codertestutil "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" @@ -186,11 +190,12 @@ func TestPassthroughRoutesForProviders(t *testing.T) { upstreamRespBody := "upstream response" tests := []struct { - name string - baseURLPath string - requestPath string - provider func(*testing.T, string) provider.Provider - expectPath string + name string + baseURLPath string + requestMethod string + requestPath string + provider func(*testing.T, string) provider.Provider + expectPath string }{ { name: "openAI_no_base_path", @@ -243,6 +248,23 @@ func TestPassthroughRoutesForProviders(t *testing.T) { }, expectPath: "/v1/models", }, + { + name: "copilot_ping", + requestPath: "/copilot/_ping", + provider: func(_ *testing.T, baseURL string) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL}) + }, + expectPath: "/_ping", + }, + { + name: "copilot_auto", + requestMethod: http.MethodPost, + requestPath: "/copilot/auto", + provider: func(_ *testing.T, baseURL string) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL}) + }, + expectPath: "/auto", + }, } for _, tc := range tests { @@ -263,7 +285,7 @@ func TestPassthroughRoutesForProviders(t *testing.T) { bridge, err := aibridge.NewRequestBridge(t.Context(), []provider.Provider{prov}, &rec, nil, logger, nil, bridgeTestTracer) require.NoError(t, err) - req := httptest.NewRequest("", tc.requestPath, nil) + req := httptest.NewRequest(tc.requestMethod, tc.requestPath, nil) resp := httptest.NewRecorder() bridge.ServeHTTP(resp, req) @@ -273,6 +295,120 @@ func TestPassthroughRoutesForProviders(t *testing.T) { } } +// TestPassthroughRejectsEncodedPathTraversal verifies that encoded dot +// segments, which upstreams decode and resolve outside the allowlisted route, +// are rejected before an upstream request is made with a centralized key. +func TestPassthroughRejectsEncodedPathTraversal(t *testing.T) { + t.Parallel() + + providers := []struct { + name string + prefix string + provider func(*testing.T, string, *keypool.Pool) provider.Provider + }{ + { + name: "openai", + prefix: "/openai/v1/models/", + provider: func(_ *testing.T, baseURL string, pool *keypool.Pool) provider.Provider { + return aibridge.NewOpenAIProvider(config.OpenAI{BaseURL: baseURL, KeyPool: pool}) + }, + }, + { + name: "anthropic", + prefix: "/anthropic/v1/models/", + provider: func(t *testing.T, baseURL string, pool *keypool.Pool) provider.Provider { + return aibridgetest.NewAnthropicProvider(t, config.Anthropic{BaseURL: baseURL, KeyPool: pool}, nil) + }, + }, + { + name: "copilot", + prefix: "/copilot/models/", + provider: func(_ *testing.T, baseURL string, _ *keypool.Pool) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL}) + }, + }, + } + + for _, p := range providers { + t.Run(p.name, func(t *testing.T) { + t.Parallel() + + var upstreamPaths []string + var mu sync.Mutex + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + upstreamPaths = append(upstreamPaths, r.URL.EscapedPath()) + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstream.Close) + + pool, err := keypool.New(p.name, []string{"centralized-key"}, quartz.NewReal(), nil) + require.NoError(t, err) + logger := slogtest.Make(t, nil) + rec := testutil.MockRecorder{} + bridge, err := aibridge.NewRequestBridge(t.Context(), []provider.Provider{p.provider(t, upstream.URL+"/v1", pool)}, &rec, nil, logger, nil, bridgeTestTracer) + require.NoError(t, err) + + for _, suffix := range []string{ + "%2e%2e/files", + "%2E%2E/%2E%2E/admin", + "..%2F..%2Fadmin", + "%252e%252e/files", + "../files", + } { + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, p.prefix+suffix, nil)) + assert.Equal(t, http.StatusBadRequest, resp.Code, suffix) + assert.Contains(t, resp.Body.String(), "invalid request path", suffix) + } + mu.Lock() + require.Empty(t, upstreamPaths, "rejected requests must not reach the upstream") + mu.Unlock() + + // Escaped separators in ordinary segments keep their escaping. + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, p.prefix+"org%2Fmodel", nil)) + require.Equal(t, http.StatusOK, resp.Code) + mu.Lock() + defer mu.Unlock() + require.Len(t, upstreamPaths, 1) + assert.True(t, strings.HasSuffix(upstreamPaths[0], "/models/org%2Fmodel"), upstreamPaths[0]) + }) + } +} + +func TestWebSocketUpgradeRejected(t *testing.T) { + t.Parallel() + + interceptorCalled := false + prov := &testutil.MockProvider{ + NameStr: "test", + Bridged: []string{"/responses"}, + InterceptorFunc: func(http.ResponseWriter, *http.Request, trace.Tracer) (intercept.Interceptor, error) { + interceptorCalled = true + return nil, nil //nolint:nilnil // The interceptor must not be reached. + }, + } + bridge, err := aibridge.NewRequestBridge( + t.Context(), + []provider.Provider{prov}, + nil, nil, slogtest.Make(t, nil), nil, bridgeTestTracer, + ) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/test/responses", nil) + req.Header.Set("Connection", "keep-alive, Upgrade") + req.Header.Set("Upgrade", "WebSocket") + resp := httptest.NewRecorder() + + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNotImplemented, resp.Code) + assert.Contains(t, resp.Body.String(), "WebSocket transport is not supported, use HTTP") + assert.False(t, interceptorCalled) +} + func TestRequestBodySizeLimit(t *testing.T) { t.Parallel() diff --git a/aibridge/fixtures/fixtures.go b/aibridge/fixtures/fixtures.go index 7a30ccbd631a1..9b2e8ad7f6870 100644 --- a/aibridge/fixtures/fixtures.go +++ b/aibridge/fixtures/fixtures.go @@ -67,6 +67,9 @@ var ( //go:embed openai/chatcompletions/streaming_injected_tool_nonzero_index.txtar OaiChatStreamingInjectedToolNonzeroIndex []byte + + //go:embed openai/chatcompletions/streaming_cumulative_usage_injected_tool.txtar + OaiChatStreamingCumulativeUsageInjectedTool []byte ) var ( diff --git a/aibridge/fixtures/openai/chatcompletions/simple.txtar b/aibridge/fixtures/openai/chatcompletions/simple.txtar index 8f07d0c8ffae2..2f458c3af9854 100644 --- a/aibridge/fixtures/openai/chatcompletions/simple.txtar +++ b/aibridge/fixtures/openai/chatcompletions/simple.txtar @@ -492,7 +492,7 @@ data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.c data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} -data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[],"usage":{"prompt_tokens":19,"completion_tokens":238,"total_tokens":257,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[],"usage":{"prompt_tokens":19,"completion_tokens":238,"total_tokens":257,"prompt_tokens_details":{"cached_tokens":0,"cache_write_tokens":5,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} data: [DONE] @@ -521,6 +521,7 @@ data: [DONE] "total_tokens": 219, "prompt_tokens_details": { "cached_tokens": 0, + "cache_write_tokens": 5, "audio_tokens": 0 }, "completion_tokens_details": { diff --git a/aibridge/fixtures/openai/chatcompletions/single_injected_tool.txtar b/aibridge/fixtures/openai/chatcompletions/single_injected_tool.txtar index b89aac648a13b..61b4c1c321209 100644 --- a/aibridge/fixtures/openai/chatcompletions/single_injected_tool.txtar +++ b/aibridge/fixtures/openai/chatcompletions/single_injected_tool.txtar @@ -72,7 +72,7 @@ data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.c data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null,"obfuscation":"sDj"} -data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[],"usage":{"prompt_tokens":4862,"completion_tokens":45,"total_tokens":4907,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"8sIWE1chOW"} +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[],"usage":{"prompt_tokens":4862,"completion_tokens":45,"total_tokens":4907,"prompt_tokens_details":{"cached_tokens":100,"cache_write_tokens":20,"audio_tokens":3},"completion_tokens_details":{"reasoning_tokens":4,"audio_tokens":5,"accepted_prediction_tokens":6,"rejected_prediction_tokens":7}},"obfuscation":"8sIWE1chOW"} data: [DONE] @@ -200,7 +200,7 @@ data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.c data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"EFeFvdS8m"} -data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[],"usage":{"prompt_tokens":5049,"completion_tokens":60,"total_tokens":5109,"prompt_tokens_details":{"cached_tokens":4864,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"0JQt7Fw"} +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[],"usage":{"prompt_tokens":5049,"completion_tokens":60,"total_tokens":5109,"prompt_tokens_details":{"cached_tokens":4864,"cache_write_tokens":10,"audio_tokens":9},"completion_tokens_details":{"reasoning_tokens":10,"audio_tokens":11,"accepted_prediction_tokens":12,"rejected_prediction_tokens":13}},"obfuscation":"0JQt7Fw"} data: [DONE] @@ -237,16 +237,17 @@ data: [DONE] "usage": { "prompt_tokens": 4862, "completion_tokens": 45, - "total_tokens": 4914, + "total_tokens": 4907, "prompt_tokens_details": { - "cached_tokens": 0, - "audio_tokens": 0 + "cached_tokens": 100, + "cache_write_tokens": 20, + "audio_tokens": 3 }, "completion_tokens_details": { - "reasoning_tokens": 0, - "audio_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 + "reasoning_tokens": 4, + "audio_tokens": 5, + "accepted_prediction_tokens": 6, + "rejected_prediction_tokens": 7 } }, "service_tier": "default", @@ -276,16 +277,17 @@ data: [DONE] "usage": { "prompt_tokens": 5049, "completion_tokens": 60, - "total_tokens": 5119, + "total_tokens": 5109, "prompt_tokens_details": { "cached_tokens": 4864, - "audio_tokens": 0 + "cache_write_tokens": 10, + "audio_tokens": 9 }, "completion_tokens_details": { - "reasoning_tokens": 0, - "audio_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 + "reasoning_tokens": 10, + "audio_tokens": 11, + "accepted_prediction_tokens": 12, + "rejected_prediction_tokens": 13 } }, "service_tier": "default", diff --git a/aibridge/fixtures/openai/chatcompletions/streaming_cumulative_usage_injected_tool.txtar b/aibridge/fixtures/openai/chatcompletions/streaming_cumulative_usage_injected_tool.txtar new file mode 100644 index 0000000000000..5474c4ac97599 --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/streaming_cumulative_usage_injected_tool.txtar @@ -0,0 +1,30 @@ +Streaming response with cumulative usage on every JSON chunk and an injected tool call. + +-- request -- +{ + "model": "gpt-4.1", + "messages": [ + { + "role": "user", + "content": "list my coder workspaces" + } + ] +} + +-- streaming -- +data: {"id":"chatcmpl-cumulative-tool","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1","choices":[{"index":0,"delta":{"content":"Calling"}}],"usage":{"prompt_tokens":6000,"completion_tokens":10,"total_tokens":6010,"prompt_tokens_details":{"cached_tokens":100,"cache_write_tokens":10,"audio_tokens":1},"completion_tokens_details":{"reasoning_tokens":1,"audio_tokens":1,"accepted_prediction_tokens":1,"rejected_prediction_tokens":1}}} + +data: {"id":"chatcmpl-cumulative-tool","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call-cumulative","type":"function","function":{"name":"bmcp_coder_coder_list_workspaces","arguments":"{}"}}]}}],"usage":{"prompt_tokens":6000,"completion_tokens":20,"total_tokens":6020,"prompt_tokens_details":{"cached_tokens":100,"cache_write_tokens":10,"audio_tokens":2},"completion_tokens_details":{"reasoning_tokens":2,"audio_tokens":2,"accepted_prediction_tokens":2,"rejected_prediction_tokens":2}}} + +data: {"id":"chatcmpl-cumulative-tool","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":6000,"completion_tokens":30,"total_tokens":6030,"prompt_tokens_details":{"cached_tokens":100,"cache_write_tokens":10,"audio_tokens":3},"completion_tokens_details":{"reasoning_tokens":4,"audio_tokens":5,"accepted_prediction_tokens":6,"rejected_prediction_tokens":7}}} + +data: [DONE] + +-- streaming/tool-call -- +data: {"id":"chatcmpl-cumulative-final","object":"chat.completion.chunk","created":1754479217,"model":"gpt-4.1","choices":[{"index":0,"delta":{"content":"Done"}}],"usage":{"prompt_tokens":6000,"completion_tokens":10,"total_tokens":6010,"prompt_tokens_details":{"cached_tokens":200,"cache_write_tokens":20,"audio_tokens":7},"completion_tokens_details":{"reasoning_tokens":8,"audio_tokens":9,"accepted_prediction_tokens":10,"rejected_prediction_tokens":11}}} + +data: {"id":"chatcmpl-cumulative-final","object":"chat.completion.chunk","created":1754479217,"model":"gpt-4.1","choices":[{"index":0,"delta":{"content":"."}}],"usage":{"prompt_tokens":6000,"completion_tokens":20,"total_tokens":6020,"prompt_tokens_details":{"cached_tokens":200,"cache_write_tokens":20,"audio_tokens":8},"completion_tokens_details":{"reasoning_tokens":9,"audio_tokens":10,"accepted_prediction_tokens":11,"rejected_prediction_tokens":12}}} + +data: {"id":"chatcmpl-cumulative-final","object":"chat.completion.chunk","created":1754479217,"model":"gpt-4.1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":6000,"completion_tokens":30,"total_tokens":6030,"prompt_tokens_details":{"cached_tokens":200,"cache_write_tokens":20,"audio_tokens":9},"completion_tokens_details":{"reasoning_tokens":10,"audio_tokens":11,"accepted_prediction_tokens":12,"rejected_prediction_tokens":13}}} + +data: [DONE] diff --git a/aibridge/fixtures/openai/responses/blocking/cached_input_tokens.txtar b/aibridge/fixtures/openai/responses/blocking/cached_input_tokens.txtar index 41a6d7ca7e36b..66689d6a48378 100644 --- a/aibridge/fixtures/openai/responses/blocking/cached_input_tokens.txtar +++ b/aibridge/fixtures/openai/responses/blocking/cached_input_tokens.txtar @@ -68,7 +68,8 @@ "usage": { "input_tokens": 12033, "input_tokens_details": { - "cached_tokens": 11904 + "cached_tokens": 11904, + "cache_write_tokens": 15 }, "output_tokens": 44, "output_tokens_details": { diff --git a/aibridge/fixtures/openai/responses/blocking/single_injected_tool.txtar b/aibridge/fixtures/openai/responses/blocking/single_injected_tool.txtar index 028377dcaa9f5..ce46145d78d6f 100644 --- a/aibridge/fixtures/openai/responses/blocking/single_injected_tool.txtar +++ b/aibridge/fixtures/openai/responses/blocking/single_injected_tool.txtar @@ -749,7 +749,8 @@ Coder MCP tools automatically injected. "usage": { "input_tokens": 6371, "input_tokens_details": { - "cached_tokens": 6144 + "cached_tokens": 6144, + "cache_write_tokens": 7 }, "output_tokens": 75, "output_tokens_details": { @@ -1509,7 +1510,8 @@ Coder MCP tools automatically injected. "usage": { "input_tokens": 6756, "input_tokens_details": { - "cached_tokens": 6144 + "cached_tokens": 6144, + "cache_write_tokens": 11 }, "output_tokens": 231, "output_tokens_details": { diff --git a/aibridge/fixtures/openai/responses/blocking/single_injected_tool_error.txtar b/aibridge/fixtures/openai/responses/blocking/single_injected_tool_error.txtar index 9e4c2716f20f1..17c180d7dc509 100644 --- a/aibridge/fixtures/openai/responses/blocking/single_injected_tool_error.txtar +++ b/aibridge/fixtures/openai/responses/blocking/single_injected_tool_error.txtar @@ -749,7 +749,8 @@ Coder MCP tools automatically injected, and errors invoking them are recorded. "usage": { "input_tokens": 6377, "input_tokens_details": { - "cached_tokens": 6144 + "cached_tokens": 6144, + "cache_write_tokens": 5 }, "output_tokens": 119, "output_tokens_details": { @@ -1509,7 +1510,8 @@ Coder MCP tools automatically injected, and errors invoking them are recorded. "usage": { "input_tokens": 6539, "input_tokens_details": { - "cached_tokens": 6144 + "cached_tokens": 6144, + "cache_write_tokens": 9 }, "output_tokens": 144, "output_tokens_details": { diff --git a/aibridge/fixtures/openai/responses/streaming/cached_input_tokens.txtar b/aibridge/fixtures/openai/responses/streaming/cached_input_tokens.txtar index cc908d5abdf5a..8dffa4eabe994 100644 --- a/aibridge/fixtures/openai/responses/streaming/cached_input_tokens.txtar +++ b/aibridge/fixtures/openai/responses/streaming/cached_input_tokens.txtar @@ -43,5 +43,5 @@ event: response.output_item.done data: {"type":"response.output_item.done","item":{"id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Test response with cached tokens."}],"role":"assistant"},"output_index":0,"sequence_number":11} event: response.completed -data: {"type":"response.completed","response":{"id":"resp_05080461b406f3f501696a1409d34c8195a40ff4b092145c35","object":"response","created_at":1768559625,"status":"completed","background":false,"completed_at":1768559627,"error":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.2-codex","output":[{"id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Test response with cached tokens."}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":"019bc657-f77b-7292-b5f4-2e8d6c2b0945","prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":"detailed"},"service_tier":"default","store":false,"temperature":1.0,"tool_choice":"auto","tools":[],"truncation":"disabled","usage":{"input_tokens":16909,"input_tokens_details":{"cached_tokens":15744},"output_tokens":54,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":16963},"user":null,"metadata":{}},"sequence_number":12} +data: {"type":"response.completed","response":{"id":"resp_05080461b406f3f501696a1409d34c8195a40ff4b092145c35","object":"response","created_at":1768559625,"status":"completed","background":false,"completed_at":1768559627,"error":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.2-codex","output":[{"id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Test response with cached tokens."}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":"019bc657-f77b-7292-b5f4-2e8d6c2b0945","prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":"detailed"},"service_tier":"default","store":false,"temperature":1.0,"tool_choice":"auto","tools":[],"truncation":"disabled","usage":{"input_tokens":16909,"input_tokens_details":{"cached_tokens":15744,"cache_write_tokens":30},"output_tokens":54,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":16963},"user":null,"metadata":{}},"sequence_number":12} diff --git a/aibridge/fixtures/openai/responses/streaming/single_injected_tool.txtar b/aibridge/fixtures/openai/responses/streaming/single_injected_tool.txtar index 0e079d1e7a443..7df2ec235b483 100644 --- a/aibridge/fixtures/openai/responses/streaming/single_injected_tool.txtar +++ b/aibridge/fixtures/openai/responses/streaming/single_injected_tool.txtar @@ -25,7 +25,7 @@ event: response.output_item.done data: {"type":"response.output_item.done","item":{"id":"fc_016595fe42aa62ca006972441b4d0081a0bbf6b65aa91022df","type":"function_call","status":"completed","arguments":"{}","call_id":"call_GuuoyhUrVJQbWfHHz0xaX3n9","name":"bmcp_coder_coder_list_templates"},"output_index":0,"sequence_number":5} event: response.completed -data: {"type":"response.completed","response":{"id":"resp_016595fe42aa62ca0069724419c52081a0b7eb479c6bc8109f","object":"response","created_at":1769096217,"status":"completed","background":false,"completed_at":1769096219,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[{"id":"fc_016595fe42aa62ca006972441b4d0081a0bbf6b65aa91022df","type":"function_call","status":"completed","arguments":"{}","call_id":"call_GuuoyhUrVJQbWfHHz0xaX3n9","name":"bmcp_coder_coder_list_templates"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6269,"input_tokens_details":{"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":6287},"user":null,"metadata":{}},"sequence_number":6} +data: {"type":"response.completed","response":{"id":"resp_016595fe42aa62ca0069724419c52081a0b7eb479c6bc8109f","object":"response","created_at":1769096217,"status":"completed","background":false,"completed_at":1769096219,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[{"id":"fc_016595fe42aa62ca006972441b4d0081a0bbf6b65aa91022df","type":"function_call","status":"completed","arguments":"{}","call_id":"call_GuuoyhUrVJQbWfHHz0xaX3n9","name":"bmcp_coder_coder_list_templates"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6269,"input_tokens_details":{"cached_tokens":100,"cache_write_tokens":7},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":3},"total_tokens":6287},"user":null,"metadata":{}},"sequence_number":6} -- streaming/tool-call -- @@ -591,5 +591,5 @@ event: response.output_item.done data: {"type":"response.output_item.done","item":{"id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"You have two Coder templates:\n\n1. Template Name: codex-test\n - Template ID: d85cac35-15a1-4bde-97d9-1f3e4b851246\n - Active Version ID: 22a3face-0c93-4b88-a63a-1ec1651e0199\n - Active User Count: 1\n\n2. Template Name: docker\n - Template ID: 7e799e56-6591-4c44-b575-3c72b55b7217\n - Active Version ID: 8057a565-1c12-489e-a563-8e8bb162c867\n - Active User Count: 1\n\nLet me know if you want more details or want to perform any actions with these templates."}],"role":"assistant"},"output_index":0,"sequence_number":186} event: response.completed -data: {"type":"response.completed","response":{"id":"resp_0bc5f54fce6df69a006972442175908194bb81d31f576e6ca6","object":"response","created_at":1769096225,"status":"completed","background":false,"completed_at":1769096230,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[{"id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"You have two Coder templates:\n\n1. Template Name: codex-test\n - Template ID: d85cac35-15a1-4bde-97d9-1f3e4b851246\n - Active Version ID: 22a3face-0c93-4b88-a63a-1ec1651e0199\n - Active User Count: 1\n\n2. Template Name: docker\n - Template ID: 7e799e56-6591-4c44-b575-3c72b55b7217\n - Active Version ID: 8057a565-1c12-489e-a563-8e8bb162c867\n - Active User Count: 1\n\nLet me know if you want more details or want to perform any actions with these templates."}],"role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6463,"input_tokens_details":{"cached_tokens":6144},"output_tokens":182,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":6645},"user":null,"metadata":{}},"sequence_number":187} +data: {"type":"response.completed","response":{"id":"resp_0bc5f54fce6df69a006972442175908194bb81d31f576e6ca6","object":"response","created_at":1769096225,"status":"completed","background":false,"completed_at":1769096230,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[{"id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"You have two Coder templates:\n\n1. Template Name: codex-test\n - Template ID: d85cac35-15a1-4bde-97d9-1f3e4b851246\n - Active Version ID: 22a3face-0c93-4b88-a63a-1ec1651e0199\n - Active User Count: 1\n\n2. Template Name: docker\n - Template ID: 7e799e56-6591-4c44-b575-3c72b55b7217\n - Active Version ID: 8057a565-1c12-489e-a563-8e8bb162c867\n - Active User Count: 1\n\nLet me know if you want more details or want to perform any actions with these templates."}],"role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6463,"input_tokens_details":{"cached_tokens":6144,"cache_write_tokens":11},"output_tokens":182,"output_tokens_details":{"reasoning_tokens":5},"total_tokens":6645},"user":null,"metadata":{}},"sequence_number":187} diff --git a/aibridge/fixtures/openai/responses/streaming/single_injected_tool_error.txtar b/aibridge/fixtures/openai/responses/streaming/single_injected_tool_error.txtar index 95dd43e543307..a6ea96c8c3e0e 100644 --- a/aibridge/fixtures/openai/responses/streaming/single_injected_tool_error.txtar +++ b/aibridge/fixtures/openai/responses/streaming/single_injected_tool_error.txtar @@ -58,7 +58,7 @@ event: response.output_item.done data: {"type":"response.output_item.done","item":{"id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","type":"function_call","status":"completed","arguments":"{\"transition\":\"start\",\"workspace_id\":\"non_existing_id\"}","call_id":"call_1wHAlwmnxtbUzowDJkmlcpJ4","name":"bmcp_coder_coder_create_workspace_build"},"output_index":0,"sequence_number":16} event: response.completed -data: {"type":"response.completed","response":{"id":"resp_0dfed48e1052ad7f0069725ca129f88193b97d6deff1760524","object":"response","created_at":1769102497,"status":"completed","background":false,"completed_at":1769102499,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","type":"function_call","status":"completed","arguments":"{\"transition\":\"start\",\"workspace_id\":\"non_existing_id\"}","call_id":"call_1wHAlwmnxtbUzowDJkmlcpJ4","name":"bmcp_coder_coder_create_workspace_build"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6280,"input_tokens_details":{"cached_tokens":0},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":6310},"user":null,"metadata":{}},"sequence_number":17} +data: {"type":"response.completed","response":{"id":"resp_0dfed48e1052ad7f0069725ca129f88193b97d6deff1760524","object":"response","created_at":1769102497,"status":"completed","background":false,"completed_at":1769102499,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","type":"function_call","status":"completed","arguments":"{\"transition\":\"start\",\"workspace_id\":\"non_existing_id\"}","call_id":"call_1wHAlwmnxtbUzowDJkmlcpJ4","name":"bmcp_coder_coder_create_workspace_build"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6280,"input_tokens_details":{"cached_tokens":100,"cache_write_tokens":5},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":3},"total_tokens":6310},"user":null,"metadata":{}},"sequence_number":17} -- streaming/tool-call -- @@ -246,5 +246,5 @@ event: response.output_item.done data: {"type":"response.output_item.done","item":{"id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The workspace ID you provided ('non_existing_id') is not valid. Workspace IDs must be valid UUIDs (typically 36 characters long). Please provide a valid workspace ID to create a new workspace build. If you need help finding your workspace ID, let me know!"}],"role":"assistant"},"output_index":0,"sequence_number":60} event: response.completed -data: {"type":"response.completed","response":{"id":"resp_0dfed48e1052ad7f0069725ca39880819390fcc5b2eb8cf8c6","object":"response","created_at":1769102499,"status":"completed","background":false,"completed_at":1769102501,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The workspace ID you provided ('non_existing_id') is not valid. Workspace IDs must be valid UUIDs (typically 36 characters long). Please provide a valid workspace ID to create a new workspace build. If you need help finding your workspace ID, let me know!"}],"role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6346,"input_tokens_details":{"cached_tokens":0},"output_tokens":56,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":6402},"user":null,"metadata":{}},"sequence_number":61} +data: {"type":"response.completed","response":{"id":"resp_0dfed48e1052ad7f0069725ca39880819390fcc5b2eb8cf8c6","object":"response","created_at":1769102499,"status":"completed","background":false,"completed_at":1769102501,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The workspace ID you provided ('non_existing_id') is not valid. Workspace IDs must be valid UUIDs (typically 36 characters long). Please provide a valid workspace ID to create a new workspace build. If you need help finding your workspace ID, let me know!"}],"role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6346,"input_tokens_details":{"cached_tokens":100,"cache_write_tokens":9},"output_tokens":56,"output_tokens_details":{"reasoning_tokens":5},"total_tokens":6402},"user":null,"metadata":{}},"sequence_number":61} diff --git a/aibridge/intercept/chatcompletions/base.go b/aibridge/intercept/chatcompletions/base.go index 4e6db3abe601e..415b4d46bc327 100644 --- a/aibridge/intercept/chatcompletions/base.go +++ b/aibridge/intercept/chatcompletions/base.go @@ -227,14 +227,15 @@ func (i *interceptionBase) hasInjectableTools() bool { } // recordTokenUsage records the token usage for a single completion, accounting -// for cached tokens included in the prompt token count. +// for cache read and write tokens included in the prompt token count. func (i *interceptionBase) recordTokenUsage(ctx context.Context, msgID string, usage openai.CompletionUsage) { _ = i.recorder.RecordTokenUsage(ctx, &recorder.TokenUsageRecord{ - InterceptionID: i.ID().String(), - MsgID: msgID, - Input: calculateActualInputTokenUsage(usage), - Output: usage.CompletionTokens, - CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens, + InterceptionID: i.ID().String(), + MsgID: msgID, + Input: calculateActualInputTokenUsage(usage), + Output: usage.CompletionTokens, + CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens, + CacheWriteInputTokens: usage.PromptTokensDetails.CacheWriteTokens, ExtraTokenTypes: map[string]int64{ "prompt_audio": usage.PromptTokensDetails.AudioTokens, "completion_accepted_prediction": usage.CompletionTokensDetails.AcceptedPredictionTokens, @@ -245,7 +246,7 @@ func (i *interceptionBase) recordTokenUsage(ctx context.Context, msgID string, u }) } -func sumUsage(ref, in openai.CompletionUsage) openai.CompletionUsage { +func sumUsage(ref openai.CompletionUsage, in openai.CompletionUsage) openai.CompletionUsage { return openai.CompletionUsage{ CompletionTokens: ref.CompletionTokens + in.CompletionTokens, PromptTokens: ref.PromptTokens + in.PromptTokens, @@ -257,17 +258,18 @@ func sumUsage(ref, in openai.CompletionUsage) openai.CompletionUsage { RejectedPredictionTokens: ref.CompletionTokensDetails.RejectedPredictionTokens + in.CompletionTokensDetails.RejectedPredictionTokens, }, PromptTokensDetails: openai.CompletionUsagePromptTokensDetails{ - AudioTokens: ref.PromptTokensDetails.AudioTokens + in.PromptTokensDetails.AudioTokens, - CachedTokens: ref.PromptTokensDetails.CachedTokens + in.PromptTokensDetails.CachedTokens, + AudioTokens: ref.PromptTokensDetails.AudioTokens + in.PromptTokensDetails.AudioTokens, + CachedTokens: ref.PromptTokensDetails.CachedTokens + in.PromptTokensDetails.CachedTokens, + CacheWriteTokens: ref.PromptTokensDetails.CacheWriteTokens + in.PromptTokensDetails.CacheWriteTokens, }, } } -// calculateActualInputTokenUsage accounts for cached tokens which are included in [openai.CompletionUsage].PromptTokens. +// calculateActualInputTokenUsage calculates ordinary input tokens. +// in.PromptTokens contains sum of all prompt tokens including +// cache read and write tokens which are priced differently. func calculateActualInputTokenUsage(in openai.CompletionUsage) int64 { - // Input *includes* the cached tokens, so we subtract them here to reflect actual input token usage. - // The original value can be reconstructed by adding CachedTokens back to Input. - // See https://platform.openai.com/docs/api-reference/usage/completions_object#usage/completions_object-input_tokens. - return max(0, in.PromptTokens /* The aggregated number of text input tokens used, including cached tokens. */ - - in.PromptTokensDetails.CachedTokens /* The aggregated number of text input tokens that has been cached from previous requests. */) + return max(0, in.PromptTokens- + in.PromptTokensDetails.CachedTokens- + in.PromptTokensDetails.CacheWriteTokens) } diff --git a/aibridge/intercept/chatcompletions/base_internal_test.go b/aibridge/intercept/chatcompletions/base_internal_test.go index 8d9151fce6e23..2eab3b0b45cda 100644 --- a/aibridge/intercept/chatcompletions/base_internal_test.go +++ b/aibridge/intercept/chatcompletions/base_internal_test.go @@ -42,8 +42,9 @@ func TestRecordTokenUsage(t *testing.T) { CompletionTokens: 50, TotalTokens: 150, PromptTokensDetails: openai.CompletionUsagePromptTokensDetails{ - CachedTokens: 40, - AudioTokens: 3, + CachedTokens: 40, + CacheWriteTokens: 25, + AudioTokens: 3, }, CompletionTokensDetails: openai.CompletionUsageCompletionTokensDetails{ AcceptedPredictionTokens: 7, @@ -53,11 +54,12 @@ func TestRecordTokenUsage(t *testing.T) { }, }, expected: &recorder.TokenUsageRecord{ - InterceptionID: id.String(), - MsgID: "cmpl_full", - Input: 60, // 100 prompt - 40 cached - Output: 50, - CacheReadInputTokens: 40, + InterceptionID: id.String(), + MsgID: "cmpl_full", + Input: 35, // 100 prompt - 40 cache read - 25 cache write + Output: 50, + CacheReadInputTokens: 40, + CacheWriteInputTokens: 25, ExtraTokenTypes: map[string]int64{ "prompt_audio": 3, "completion_accepted_prediction": 7, @@ -97,20 +99,22 @@ func TestRecordTokenUsage(t *testing.T) { // CachedTokens. Input must clamp to 0 so it never panics a // Prometheus counter when used as an increment. name: "cached_tokens_exceed_prompt_tokens_clamps_to_zero", - msgID: "cmpl_clamp", + msgID: "cmpl_cached_exceed", usage: openai.CompletionUsage{ PromptTokens: 40, CompletionTokens: 20, PromptTokensDetails: openai.CompletionUsagePromptTokensDetails{ - CachedTokens: 100, + CachedTokens: 30, + CacheWriteTokens: 30, }, }, expected: &recorder.TokenUsageRecord{ - InterceptionID: id.String(), - MsgID: "cmpl_clamp", - Input: 0, // max(0, 40 prompt - 100 cached) - Output: 20, - CacheReadInputTokens: 100, + InterceptionID: id.String(), + MsgID: "cmpl_cached_exceed", + Input: 0, // max(0, 40 prompt - 60 cached) + Output: 20, + CacheReadInputTokens: 30, + CacheWriteInputTokens: 30, ExtraTokenTypes: map[string]int64{ "prompt_audio": 0, "completion_accepted_prediction": 0, @@ -145,6 +149,60 @@ func TestRecordTokenUsage(t *testing.T) { } } +func TestSumUsage(t *testing.T) { + t.Parallel() + + first := openai.CompletionUsage{ + PromptTokens: 100, + CompletionTokens: 50, + TotalTokens: 150, + PromptTokensDetails: openai.CompletionUsagePromptTokensDetails{ + CachedTokens: 10, + CacheWriteTokens: 20, + AudioTokens: 30, + }, + CompletionTokensDetails: openai.CompletionUsageCompletionTokensDetails{ + AcceptedPredictionTokens: 40, + RejectedPredictionTokens: 50, + AudioTokens: 60, + ReasoningTokens: 70, + }, + } + second := openai.CompletionUsage{ + PromptTokens: 200, + CompletionTokens: 100, + TotalTokens: 300, + PromptTokensDetails: openai.CompletionUsagePromptTokensDetails{ + CachedTokens: 1, + CacheWriteTokens: 2, + AudioTokens: 3, + }, + CompletionTokensDetails: openai.CompletionUsageCompletionTokensDetails{ + AcceptedPredictionTokens: 4, + RejectedPredictionTokens: 5, + AudioTokens: 6, + ReasoningTokens: 7, + }, + } + + require.Equal(t, openai.CompletionUsage{ + PromptTokens: 300, + CompletionTokens: 150, + TotalTokens: 450, + PromptTokensDetails: openai.CompletionUsagePromptTokensDetails{ + CachedTokens: 11, + CacheWriteTokens: 22, + AudioTokens: 33, + }, + CompletionTokensDetails: openai.CompletionUsageCompletionTokensDetails{ + AcceptedPredictionTokens: 44, + RejectedPredictionTokens: 55, + AudioTokens: 66, + ReasoningTokens: 77, + }, + }, sumUsage(first, second)) +} + func TestScanForCorrelatingToolCallID(t *testing.T) { t.Parallel() diff --git a/aibridge/intercept/chatcompletions/streaming.go b/aibridge/intercept/chatcompletions/streaming.go index ccbe1d96ab734..44003e36d17c1 100644 --- a/aibridge/intercept/chatcompletions/streaming.go +++ b/aibridge/intercept/chatcompletions/streaming.go @@ -121,6 +121,10 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re stream *ssestream.Stream[openai.ChatCompletionChunk] lastErr error interceptionErr error + + // Cumulative token usage across all previous iterations. + // Added to the usage of chunk before relaying to the client. + cumulativeUsage openai.CompletionUsage ) // Sum the key attempts across all iterations and record once when the @@ -224,7 +228,7 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re } // Marshal and relay chunk to client. - payload, err := i.marshalChunk(&chunk, i.ID(), processor) + payload, err := i.marshalChunk(&chunk, i.ID(), processor, cumulativeUsage) if err != nil { logger.Warn(ctx, "failed to marshal chunk", slog.Error(err), slog.F("chunk", chunk.RawJSON())) lastErr = xerrors.Errorf("marshal chunk: %w", err) @@ -270,10 +274,10 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re prompt = nil } - if lastUsage := processor.getLastUsage(); lastUsage.CompletionTokens > 0 { - // If the usage information is set, track it. - // The API will send usage information when the response terminates, which will happen if a tool call is invoked. + if processor.hasUsage { + lastUsage := processor.lastUsage i.recordTokenUsage(streamCtx, processor.getMsgID(), lastUsage) + cumulativeUsage = sumUsage(cumulativeUsage, lastUsage) } if iterationStarted { @@ -412,24 +416,19 @@ func (i *StreamingInterception) getInjectedToolByName(name string) *mcp.Tool { return i.mcpProxy.GetTool(name) } -// Mashals received stream chunk. -// Overrides id (since proxy obscures injected tool call invocations). -// If usage field was set in original chunk overrides it to culminative usage. -// // sjson is used instead of normal struct marshaling so forwarded data // is as close to the original as possible. Structs from openai library lack // `omitzero/omitempty` annotations which adds additional empty fields // when marshaling structs. Those additional empty fields can break Codex client. -func (i *StreamingInterception) marshalChunk(chunk *openai.ChatCompletionChunk, id uuid.UUID, prc *streamProcessor) ([]byte, error) { +func (i *StreamingInterception) marshalChunk(chunk *openai.ChatCompletionChunk, id uuid.UUID, prc *streamProcessor, previousUsage openai.CompletionUsage) ([]byte, error) { + // Normalize the response ID because injected tool calls span multiple upstream invocations. sj, err := sjson.Set(chunk.RawJSON(), "id", id.String()) if err != nil { return nil, xerrors.Errorf("marshal chunk id failed: %w", err) } - // If usage information is available, relay the cumulative usage once all tool invocations have completed. if chunk.JSON.Usage.Valid() { - u := prc.getCumulativeUsage() - sj, err = sjson.Set(sj, "usage", u) + sj, err = sjson.Set(sj, "usage", sumUsage(previousUsage, prc.lastUsage)) if err != nil { return nil, xerrors.Errorf("marshal chunk usage failed: %w", err) } @@ -503,9 +502,8 @@ type streamProcessor struct { pendingToolCall bool getInjectedToolFunc func(string) *mcp.Tool - // Token handling. - lastUsage openai.CompletionUsage - cumulativeUsage openai.CompletionUsage + lastUsage openai.CompletionUsage + hasUsage bool } func newStreamProcessor(ctx context.Context, logger slog.Logger, isToolInjectedFunc func(string) *mcp.Tool) *streamProcessor { @@ -525,9 +523,12 @@ func (s *streamProcessor) process(chunk openai.ChatCompletionChunk) bool { // Potentially not fatal, move along in best effort... } - // Accumulate token usage. - s.lastUsage = chunk.Usage - s.cumulativeUsage = sumUsage(s.cumulativeUsage, chunk.Usage) + // Some providers emit cumulative usage snapshots on every chunk, so the + // latest valid usage is authoritative. Never sum across chunks. + if chunk.JSON.Usage.Valid() { + s.lastUsage = chunk.Usage + s.hasUsage = true + } // If the stream has reached a terminal state (i.e. call a tool), and this tool is injected, // then it must not be relayed. @@ -616,14 +617,6 @@ func (s *streamProcessor) getLastCompletion() *openai.ChatCompletionMessage { return &s.acc.Choices[0].Message } -func (s *streamProcessor) getLastUsage() openai.CompletionUsage { - return s.lastUsage -} - -func (s *streamProcessor) getCumulativeUsage() openai.CompletionUsage { - return s.cumulativeUsage -} - // compactToolCalls removes nil/empty tool call entries (without an ID). func compactToolCalls(msg *openai.ChatCompletionMessage) { if msg == nil || len(msg.ToolCalls) == 0 { diff --git a/aibridge/intercept/chatcompletions/streaming_internal_test.go b/aibridge/intercept/chatcompletions/streaming_internal_test.go index 1f6e419555016..b6f36173af112 100644 --- a/aibridge/intercept/chatcompletions/streaming_internal_test.go +++ b/aibridge/intercept/chatcompletions/streaming_internal_test.go @@ -1,6 +1,8 @@ package chatcompletions import ( + "bytes" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -15,8 +17,137 @@ import ( "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/aibridge/intercept" "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/recorder" ) +func TestStreamProcessorUsage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + chunks []string + wantPromptTokens int64 + wantCompletionTokens int64 + wantTotalTokens int64 + }{ + { + name: "cumulative snapshots with trailing usage-less chunk", + chunks: []string{ + `{"id":"chatcmpl-cumulative","choices":[{"index":0,"delta":{"content":"one"}}],"usage":{"prompt_tokens":6000,"completion_tokens":10,"total_tokens":6010}}`, + `{"id":"chatcmpl-cumulative","choices":[{"index":0,"delta":{"content":" two"}}],"usage":{"prompt_tokens":6000,"completion_tokens":20,"total_tokens":6020}}`, + `{"id":"chatcmpl-cumulative","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":6000,"completion_tokens":30,"total_tokens":6030}}`, + `{"id":"chatcmpl-cumulative","choices":[]}`, + }, + wantPromptTokens: 6000, + wantCompletionTokens: 30, + wantTotalTokens: 6030, + }, + { + name: "usage only on final chunk", + chunks: []string{ + `{"id":"chatcmpl-final","choices":[{"index":0,"delta":{"content":"one"}}]}`, + `{"id":"chatcmpl-final","choices":[{"index":0,"delta":{"content":" two"}}]}`, + `{"id":"chatcmpl-final","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":6000,"completion_tokens":30,"total_tokens":6030}}`, + }, + wantPromptTokens: 6000, + wantCompletionTokens: 30, + wantTotalTokens: 6030, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + interceptor := NewStreamingInterceptor(uuid.New(), nil, intercept.Config{}, nil, nil, otel.Tracer("test")) + processor := newStreamProcessor(t.Context(), logger, nil) + + var relayed []byte + for _, rawChunk := range tt.chunks { + var chunk openai.ChatCompletionChunk + require.NoError(t, json.Unmarshal([]byte(rawChunk), &chunk)) + require.True(t, processor.process(chunk)) + if chunk.JSON.Usage.Valid() { + var err error + relayed, err = interceptor.marshalChunk(&chunk, interceptor.ID(), processor, openai.CompletionUsage{}) + require.NoError(t, err) + } + } + + var payload struct { + Usage openai.CompletionUsage `json:"usage"` + } + relayed = bytes.TrimSuffix(bytes.TrimPrefix(relayed, []byte("data: ")), []byte("\n\n")) + require.NoError(t, json.Unmarshal(relayed, &payload)) + assert.Equal(t, tt.wantPromptTokens, payload.Usage.PromptTokens) + assert.Equal(t, tt.wantCompletionTokens, payload.Usage.CompletionTokens) + assert.Equal(t, tt.wantTotalTokens, payload.Usage.TotalTokens) + + usage := processor.lastUsage + assert.Equal(t, tt.wantPromptTokens, usage.PromptTokens) + assert.Equal(t, tt.wantCompletionTokens, usage.CompletionTokens) + assert.Equal(t, tt.wantTotalTokens, usage.TotalTokens) + }) + } +} + +func TestStreamingInterceptionRecordsLatestUsageWithZeroCompletionTokens(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-zero-completion\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"one\"}}],\"usage\":{\"prompt_tokens\":40,\"completion_tokens\":0,\"total_tokens\":40,\"prompt_tokens_details\":{\"cached_tokens\":5,\"cache_write_tokens\":3}}}\n\n")) + _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-zero-completion\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":50,\"completion_tokens\":0,\"total_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":10,\"cache_write_tokens\":5}}}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + t.Cleanup(upstream.Close) + + req := &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Model: "gpt-4", + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("hello"), + }, + }, + Stream: true, + } + httpReq := httptest.NewRequest(http.MethodPost, "/chat/completions", nil) + interceptor := NewStreamingInterceptor( + uuid.New(), + req, + intercept.Config{BaseURL: upstream.URL}, + intercept.BYOK{Secret: "test-key", Header: intercept.AuthHeaderAuthorization}, + httpReq.Header, + otel.Tracer("test"), + ) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + rec := &testutil.MockRecorder{} + interceptor.Setup(logger, rec, nil) + + w := httptest.NewRecorder() + require.NoError(t, interceptor.ProcessRequest(w, httpReq)) + + usages := rec.RecordedTokenUsages() + require.Len(t, usages, 1) + require.Equal(t, &recorder.TokenUsageRecord{ + InterceptionID: interceptor.ID().String(), + MsgID: "chatcmpl-zero-completion", + Input: 35, + Output: 0, + CacheReadInputTokens: 10, + CacheWriteInputTokens: 5, + CreatedAt: usages[0].CreatedAt, + ExtraTokenTypes: map[string]int64{ + "prompt_audio": 0, + "completion_accepted_prediction": 0, + "completion_rejected_prediction": 0, + "completion_audio": 0, + "completion_reasoning": 0, + }, + }, usages[0]) +} + // Test that when the upstream provider returns an error before streaming starts, // the error status code and body are correctly relayed to the client. func TestStreamingInterception_RelaysUpstreamErrorToClient(t *testing.T) { diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index 35e416c63fd65..f3b70c55a621e 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -424,7 +424,7 @@ func (i *interceptionBase) withBedrockMantleOptions(ctx context.Context) ([]opti // augmentRequestForBedrockInvokeModel changes the model used for the request since AWS Bedrock doesn't support // Anthropics' model names. It also converts adaptive thinking to enabled with a budget for models that // don't support adaptive thinking natively, or enabled thinking to adaptive for models that only support -// adaptive (Opus 4.7+). +// adaptive. func (i *interceptionBase) augmentRequestForBedrockInvokeModel() { if i.bedrock == nil { return @@ -440,7 +440,7 @@ func (i *interceptionBase) augmentRequestForBedrockInvokeModel() { switch { case bedrockModelRequiresAdaptiveThinking(model): - // Symmetric conversion for adaptive-only models (Opus 4.7+): rewrite + // Symmetric conversion for adaptive-only models: rewrite // thinking.type "enabled" with budget_tokens to the "adaptive" shape, // since Bedrock returns 400 for these models when the legacy shape is // used. Claude Code falls back to the legacy shape when it cannot @@ -468,7 +468,7 @@ func (i *interceptionBase) augmentRequestForBedrockInvokeModel() { } // Strip body fields that Bedrock does not accept. Adaptive-only models - // (Opus 4.7+) support output_config natively without a beta flag, so + // support output_config natively without a beta flag, so // keep it for those models even when the effort-2025-11-24 flag is // absent from the request. var exemptFields []string @@ -496,8 +496,8 @@ func (i *interceptionBase) augmentRequestForBedrockInvokeModel() { } // bedrockModelSupportsAdaptiveThinking returns true if the given Bedrock model ID -// supports the "adaptive" thinking type natively (i.e. Claude 4.6 models, and -// adaptive-only models such as Opus 4.7+). +// supports the "adaptive" thinking type natively (i.e. Claude 4.6 models and +// adaptive-only models). // See https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-adaptive-thinking.html func bedrockModelSupportsAdaptiveThinking(model string) bool { return strings.Contains(model, "anthropic.claude-opus-4-6") || @@ -507,13 +507,19 @@ func bedrockModelSupportsAdaptiveThinking(model string) bool { // bedrockModelRequiresAdaptiveThinking returns true if the given Bedrock model // ID only supports the "adaptive" thinking type and rejects the legacy -// "enabled" + budget_tokens shape with a 400. Claude Opus 4.7 was the first -// model in this category. +// "enabled" + budget_tokens shape with a 400. The Opus 5, Sonnet 5, Fable 5, +// and Mythos 5 prefixes also match their point releases. // // See https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-4-7.html +// and https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-adaptive-thinking.html func bedrockModelRequiresAdaptiveThinking(model string) bool { return strings.Contains(model, "anthropic.claude-opus-4-7") || - strings.Contains(model, "anthropic.claude-opus-4-8") + strings.Contains(model, "anthropic.claude-opus-4-8") || + strings.Contains(model, "anthropic.claude-opus-5") || + strings.Contains(model, "anthropic.claude-sonnet-5") || + strings.Contains(model, "anthropic.claude-haiku-5-5") || + strings.Contains(model, "anthropic.claude-fable-5") || + strings.Contains(model, "anthropic.claude-mythos-5") } // filterBedrockBetaFlags removes unsupported beta flags from the Anthropic-Beta diff --git a/aibridge/intercept/messages/base_internal_test.go b/aibridge/intercept/messages/base_internal_test.go index 4f1aa416200de..e82fa5d6fabd4 100644 --- a/aibridge/intercept/messages/base_internal_test.go +++ b/aibridge/intercept/messages/base_internal_test.go @@ -716,7 +716,7 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { expectRemovedFields: []string{"output_config", "metadata", "service_tier", "container", "inference_geo", "context_management"}, }, - // Adaptive-only models (Opus 4.7+), see coder/aibridge#280. The + // Adaptive-only models, see coder/aibridge#280. The // conversion drops budget_tokens and flips the type; an explicit // output_config.effort from the caller is preserved, but none is // fabricated when absent. @@ -765,6 +765,77 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, expectThinkingType: "adaptive", }, + { + name: "sonnet_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-sonnet-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "regional_sonnet_5_model_keeps_adaptive_thinking_and_effort_and_strips_output_config_format", + bedrockModel: "us.anthropic.claude-sonnet-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"medium","format":{"type":"json_schema","schema":{"type":"object"}}}}`, + expectThinkingType: "adaptive", + expectEffort: "medium", + expectKeptFields: []string{"output_config", "output_config.effort"}, + expectRemovedFields: []string{"output_config.format"}, + }, + { + name: "opus_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-opus-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "global_opus_5_model_keeps_adaptive_thinking_and_output_config_effort", + bedrockModel: "global.anthropic.claude-opus-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"}}`, + expectThinkingType: "adaptive", + expectEffort: "medium", + expectKeptFields: []string{"output_config"}, + }, + { + name: "haiku_5_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-haiku-5-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "regional_haiku_5_5_model_keeps_adaptive_thinking_and_output_config_effort", + bedrockModel: "us.anthropic.claude-haiku-5-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`, + expectThinkingType: "adaptive", + expectEffort: "low", + expectKeptFields: []string{"output_config"}, + }, + { + name: "fable_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-fable-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "global_fable_5_1_model_keeps_adaptive_thinking_and_output_config_effort", + bedrockModel: "global.anthropic.claude-fable-5-1", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectThinkingType: "adaptive", + expectEffort: "high", + expectKeptFields: []string{"output_config"}, + }, + { + name: "mythos_5_model_with_enabled_thinking_is_converted_to_adaptive_and_drops_budget", + bedrockModel: "anthropic.claude-mythos-5", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "adaptive", + }, + { + name: "global_mythos_5_1_model_keeps_adaptive_thinking_and_output_config_effort", + bedrockModel: "global.anthropic.claude-mythos-5-1", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`, + expectThinkingType: "adaptive", + expectEffort: "high", + expectKeptFields: []string{"output_config"}, + }, { // Opus 4.7 on Bedrock rejects output_config.format (structured // outputs) with a 400 even though it accepts output_config.effort. diff --git a/aibridge/intercept/messages/reqpayload.go b/aibridge/intercept/messages/reqpayload.go index cb58afa6e2076..00360323608da 100644 --- a/aibridge/intercept/messages/reqpayload.go +++ b/aibridge/intercept/messages/reqpayload.go @@ -76,7 +76,7 @@ var ( // If the beta flag is present in the (already-filtered) Anthropic-Beta header, // the field is kept; otherwise it is stripped. Model-specific beta flags must // be removed from the header before this check (see filterBedrockBetaFlags). - // Adaptive-only models (Opus 4.7+) are exempt for output_config since they + // Adaptive-only models are exempt for output_config since they // support it natively without a beta flag, see // bedrockModelRequiresAdaptiveThinking. bedrockBetaGatedFields = map[string]string{ @@ -342,8 +342,9 @@ func (RequestPayload) resultToRawMessage(items []gjson.Result) []json.RawMessage // The two Bedrock thinking-type conversions below are a temporary shim. // AI Gateway relays the Anthropic Messages API shape to Bedrock, whose Claude // models accept a disjoint subset on each generation (older models reject -// "adaptive"; Opus 4.7+ rejects "enabled"). A planned native Bedrock provider -// removes the impedance mismatch and lets us delete this whole block. Hopefully. +// "adaptive"; adaptive-only models reject "enabled"). A planned native +// Bedrock provider removes the impedance mismatch and lets us delete this +// whole block. Hopefully. // bedrockThinkingEffortRatios maps an output_config.effort hint to the fraction // of max_tokens to allocate as thinking budget. The mapping is a heuristic @@ -402,7 +403,7 @@ func (p RequestPayload) convertAdaptiveThinkingForBedrock() (RequestPayload, err // convertEnabledThinkingForBedrock rewrites thinking.type "enabled" to plain // "adaptive", dropping budget_tokens. Needed for Bedrock models that only -// support adaptive thinking (Opus 4.7+). +// support adaptive thinking. // // We deliberately do not derive output_config.effort from the budget. Any // such mapping would be invented (no canonical budget-to-effort relationship @@ -421,7 +422,7 @@ func (p RequestPayload) convertEnabledThinkingForBedrock() (RequestPayload, erro // removeBedrockUnsupportedOutputConfigSubFields drops sub-fields of // output_config that Bedrock rejects even on models where the parent -// output_config object is accepted. Adaptive-only models (Opus 4.7+) accept +// output_config object is accepted. Adaptive-only models accept // output_config.effort but reject output_config.format (structured outputs) // with a 400 "Extra inputs are not permitted." The generic field-strip pass // (removeUnsupportedBedrockFields) operates at top-level granularity only, so @@ -444,7 +445,7 @@ func (p RequestPayload) removeBedrockUnsupportedOutputConfigSubFields() (Request // calling this method (see filterBedrockBetaFlags). // // Fields exempted by exemptFields are always kept regardless of beta flag -// state. Adaptive-only Bedrock models (Opus 4.7+) require output_config +// state. Adaptive-only Bedrock models require output_config // without a beta flag, so callers pass the field through this set to bypass // the effort-2025-11-24 gate. func (p RequestPayload) removeUnsupportedBedrockFields(headers http.Header, exemptFields ...string) (RequestPayload, error) { diff --git a/aibridge/intercept/responses/base.go b/aibridge/intercept/responses/base.go index 6513636fedb89..ec6e128cf0b6d 100644 --- a/aibridge/intercept/responses/base.go +++ b/aibridge/intercept/responses/base.go @@ -57,6 +57,21 @@ type responsesInterceptionBase struct { mcpProxy mcp.ServerProxier } +func sumUsage(ref responses.ResponseUsage, in responses.ResponseUsage) responses.ResponseUsage { + return responses.ResponseUsage{ + InputTokens: ref.InputTokens + in.InputTokens, + OutputTokens: ref.OutputTokens + in.OutputTokens, + TotalTokens: ref.TotalTokens + in.TotalTokens, + InputTokensDetails: responses.ResponseUsageInputTokensDetails{ + CachedTokens: ref.InputTokensDetails.CachedTokens + in.InputTokensDetails.CachedTokens, + CacheWriteTokens: ref.InputTokensDetails.CacheWriteTokens + in.InputTokensDetails.CacheWriteTokens, + }, + OutputTokensDetails: responses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: ref.OutputTokensDetails.ReasoningTokens + in.OutputTokensDetails.ReasoningTokens, + }, + } +} + // newResponsesService builds the SDK service used for upstream calls. func (i *responsesInterceptionBase) newResponsesService(ctx context.Context) responses.ResponseService { var opts []option.RequestOption @@ -264,7 +279,9 @@ func (i *responsesInterceptionBase) recordNonInjectedToolUsage(ctx context.Conte // have no uniform argument representation. switch item.Type { case string(constant.ValueOf[constant.FunctionCall]()): - args = i.parseFunctionCallJSONArgs(ctx, item.Arguments) + // Arguments is a union since openai-go v3.50; function_call + // arguments are always the JSON string variant. + args = i.parseFunctionCallJSONArgs(ctx, item.Arguments.OfString) case string(constant.ValueOf[constant.CustomToolCall]()): args = item.Input case string(constant.ValueOf[constant.WebSearchCall]()), @@ -325,16 +342,19 @@ func (i *responsesInterceptionBase) recordTokenUsage(ctx context.Context, respon usage := response.Usage - // Keeping logic consistent with chat completions - // Input *includes* the cached tokens, so we subtract them here to reflect actual input token usage. - inputNonCacheTokens := max(0, usage.InputTokens-usage.InputTokensDetails.CachedTokens) + // InputTokens include cache read and write tokens, see OpenAI spending controller cookbook for reference: + // https://github.com/openai/openai-cookbook/blob/51c769595490f7513d4bd7c6e7700a7ab8dedbd4/articles/per_run_spending_controller_responses_api.md?plain=1#L197 + inputNonCacheTokens := max(0, usage.InputTokens- + usage.InputTokensDetails.CachedTokens- + usage.InputTokensDetails.CacheWriteTokens) if err := i.recorder.RecordTokenUsage(ctx, &recorder.TokenUsageRecord{ - InterceptionID: i.ID().String(), - MsgID: response.ID, - Input: inputNonCacheTokens, - Output: usage.OutputTokens, - CacheReadInputTokens: usage.InputTokensDetails.CachedTokens, + InterceptionID: i.ID().String(), + MsgID: response.ID, + Input: inputNonCacheTokens, + Output: usage.OutputTokens, + CacheReadInputTokens: usage.InputTokensDetails.CachedTokens, + CacheWriteInputTokens: usage.InputTokensDetails.CacheWriteTokens, ExtraTokenTypes: map[string]int64{ "output_reasoning": usage.OutputTokensDetails.ReasoningTokens, "total_tokens": usage.TotalTokens, @@ -446,6 +466,14 @@ func (r *responseCopier) readAll() ([]byte, error) { // forwardResp writes whole response as received to ResponseWriter func (r *responseCopier) forwardResp(w http.ResponseWriter) error { + b, err := r.readAll() + if err != nil { + return xerrors.Errorf("failed to read response body: %w", err) + } + return r.forwardBytes(w, b) +} + +func (r *responseCopier) forwardBytes(w http.ResponseWriter, b []byte) error { // no response was received, nothing to forward if !r.responseReceived.Load() { return nil @@ -459,11 +487,6 @@ func (r *responseCopier) forwardResp(w http.ResponseWriter) error { } w.WriteHeader(r.responseStatus) - b, err := r.readAll() - if err != nil { - return xerrors.Errorf("failed to read response body: %w", err) - } - if _, err := w.Write(b); err != nil { return xerrors.Errorf("failed to write response body: %w", err) } diff --git a/aibridge/intercept/responses/base_internal_test.go b/aibridge/intercept/responses/base_internal_test.go index 60253bc0d1a46..dcc8e107e912f 100644 --- a/aibridge/intercept/responses/base_internal_test.go +++ b/aibridge/intercept/responses/base_internal_test.go @@ -114,7 +114,7 @@ func TestRecordToolUsage(t *testing.T) { Type: "function_call", CallID: "call_abc", Name: "get_weather", - Arguments: "", + Arguments: oairesponses.ResponseOutputItemUnionArguments{OfString: ""}, }, }, }, @@ -138,13 +138,13 @@ func TestRecordToolUsage(t *testing.T) { Type: "function_call", CallID: "call_1", Name: "get_weather", - Arguments: `{"location": "NYC"}`, + Arguments: oairesponses.ResponseOutputItemUnionArguments{OfString: `{"location": "NYC"}`}, }, { Type: "function_call", CallID: "call_2", Name: "bad_json_args", - Arguments: `{"bad": args`, + Arguments: oairesponses.ResponseOutputItemUnionArguments{OfString: `{"bad": args`}, }, { Type: "message", @@ -161,7 +161,7 @@ func TestRecordToolUsage(t *testing.T) { Type: "function_call", CallID: "call_4", Name: "calculate", - Arguments: `{"a": 1, "b": 2}`, + Arguments: oairesponses.ResponseOutputItemUnionArguments{OfString: `{"a": 1, "b": 2}`}, }, }, }, @@ -211,7 +211,7 @@ func TestRecordToolUsage(t *testing.T) { ID: "fc_item_1", CallID: "call_both", Name: "get_weather", - Arguments: `{"location": "NYC"}`, + Arguments: oairesponses.ResponseOutputItemUnionArguments{OfString: `{"location": "NYC"}`}, }, }, }, @@ -358,6 +358,48 @@ func TestParseJSONArgs(t *testing.T) { } } +func TestSumUsage(t *testing.T) { + t.Parallel() + + first := oairesponses.ResponseUsage{ + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + InputTokensDetails: oairesponses.ResponseUsageInputTokensDetails{ + CachedTokens: 10, + CacheWriteTokens: 20, + }, + OutputTokensDetails: oairesponses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: 30, + }, + } + second := oairesponses.ResponseUsage{ + InputTokens: 200, + OutputTokens: 100, + TotalTokens: 300, + InputTokensDetails: oairesponses.ResponseUsageInputTokensDetails{ + CachedTokens: 1, + CacheWriteTokens: 2, + }, + OutputTokensDetails: oairesponses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: 3, + }, + } + + require.Equal(t, oairesponses.ResponseUsage{ + InputTokens: 300, + OutputTokens: 150, + TotalTokens: 450, + InputTokensDetails: oairesponses.ResponseUsageInputTokensDetails{ + CachedTokens: 11, + CacheWriteTokens: 22, + }, + OutputTokensDetails: oairesponses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: 33, + }, + }, sumUsage(first, second)) +} + func TestRecordTokenUsage(t *testing.T) { t.Parallel() @@ -382,7 +424,8 @@ func TestRecordTokenUsage(t *testing.T) { OutputTokens: 20, TotalTokens: 30, InputTokensDetails: oairesponses.ResponseUsageInputTokensDetails{ - CachedTokens: 5, + CachedTokens: 5, + CacheWriteTokens: 3, }, OutputTokensDetails: oairesponses.ResponseUsageOutputTokensDetails{ ReasoningTokens: 5, @@ -390,11 +433,12 @@ func TestRecordTokenUsage(t *testing.T) { }, }, expected: &recorder.TokenUsageRecord{ - InterceptionID: id.String(), - MsgID: "resp_full", - Input: 5, // 10 input - 5 cached - Output: 20, - CacheReadInputTokens: 5, + InterceptionID: id.String(), + MsgID: "resp_full", + Input: 2, // 10 input - 5 cache read - 3 cache write + Output: 20, + CacheReadInputTokens: 5, + CacheWriteInputTokens: 3, ExtraTokenTypes: map[string]int64{ "output_reasoning": 5, "total_tokens": 30, @@ -413,16 +457,18 @@ func TestRecordTokenUsage(t *testing.T) { OutputTokens: 20, TotalTokens: 30, InputTokensDetails: oairesponses.ResponseUsageInputTokensDetails{ - CachedTokens: 40, + CachedTokens: 20, + CacheWriteTokens: 20, }, }, }, expected: &recorder.TokenUsageRecord{ - InterceptionID: id.String(), - MsgID: "resp_clamp", - Input: 0, // max(0, 10 input - 40 cached) - Output: 20, - CacheReadInputTokens: 40, + InterceptionID: id.String(), + MsgID: "resp_clamp", + Input: 0, // max(0, 10 input - 20 cache read - 20 cache write) + Output: 20, + CacheReadInputTokens: 20, + CacheWriteInputTokens: 20, ExtraTokenTypes: map[string]int64{ "output_reasoning": 0, "total_tokens": 30, diff --git a/aibridge/intercept/responses/blocking.go b/aibridge/intercept/responses/blocking.go index 6038e2e1e6bb3..fd696a1d562f4 100644 --- a/aibridge/intercept/responses/blocking.go +++ b/aibridge/intercept/responses/blocking.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" + "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "golang.org/x/xerrors" @@ -69,10 +70,13 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * i.injectTools() var ( - response *responses.Response - upstreamErr error - respCopy responseCopier - firstResponseID string + response *responses.Response + upstreamErr error + innerLoopErr error + respCopy responseCopier + firstResponseID string + cumulativeUsage responses.ResponseUsage + innerLoopIterations int ) prompt, promptFound, err := i.reqPayload.lastUserPrompt(ctx, i.logger) @@ -126,13 +130,15 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * } i.recordTokenUsage(ctx, response) + cumulativeUsage = sumUsage(cumulativeUsage, response.Usage) + innerLoopIterations++ i.recordModelThoughts(ctx, response) // Check if there any injected tools to invoke. pending := i.getPendingInjectedToolCalls(response) - shouldLoop, err = i.handleInnerAgenticLoop(ctx, pending, response) - if err != nil { - i.sendCustomErr(ctx, w, http.StatusInternalServerError, err) + shouldLoop, innerLoopErr = i.handleInnerAgenticLoop(ctx, pending, response) + if innerLoopErr != nil { + i.sendCustomErr(ctx, w, http.StatusInternalServerError, innerLoopErr) shouldLoop = false } } @@ -142,16 +148,44 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * } i.recordNonInjectedToolUsage(ctx, response) + // The inner-loop error was already sent to the client. Do not append the + // buffered upstream response to it. + if innerLoopErr != nil { + return innerLoopErr + } + if upstreamErr != nil && !respCopy.responseReceived.Load() { // no response received from upstream, return custom error i.sendCustomErr(ctx, w, http.StatusInternalServerError, upstreamErr) return xerrors.Errorf("failed to connect to upstream: %w", upstreamErr) } - err = respCopy.forwardResp(w) + if innerLoopIterations > 1 && upstreamErr == nil && response != nil { + b, readErr := respCopy.readAll() + if readErr != nil { + forwardErr := respCopy.forwardBytes(w, b) + return errors.Join(xerrors.Errorf("failed to read response body: %w", readErr), forwardErr) + } + updated, setErr := i.setUsage(b, cumulativeUsage) + if setErr != nil { + forwardErr := respCopy.forwardBytes(w, b) + return errors.Join(setErr, forwardErr) + } + err = respCopy.forwardBytes(w, updated) + } else { + err = respCopy.forwardResp(w) + } return errors.Join(upstreamErr, err) } +func (*BlockingResponsesInterceptor) setUsage(raw []byte, usage responses.ResponseUsage) ([]byte, error) { + raw, err := sjson.SetBytes(raw, "usage", usage) + if err != nil { + return nil, xerrors.Errorf("set response usage: %w", err) + } + return raw, nil +} + // newResponse routes by credential type, returning the upstream response, the // number of key attempts made for this call, and any error. A centralized key // pool fails over across keys, while BYOK authenticates with a single, fixed diff --git a/aibridge/internal/integrationtest/bridge_internal_test.go b/aibridge/internal/integrationtest/bridge_internal_test.go index 1b515735da4aa..ae15b5aae3e6a 100644 --- a/aibridge/internal/integrationtest/bridge_internal_test.go +++ b/aibridge/internal/integrationtest/bridge_internal_test.go @@ -460,13 +460,15 @@ func TestAWSBedrockIntegration(t *testing.T) { } cases := []struct { - name string - model string - smallFastModel string - expectThinkingType string - expectBudgetTokens int64 // 0 means budget_tokens should not be present - expectKeptFields []string // fields from strippableFields expected to survive - expectedBetaFlags []string // values expected in the anthropic_beta array in the forwarded body + name string + model string + smallFastModel string + expectThinkingType string + expectEffort string + expectBudgetTokens int64 // 0 means budget_tokens should not be present + sendThinkingEnabled bool // send enabled thinking with budget_tokens instead of the fixture's adaptive thinking + expectKeptFields []string // fields from strippableFields expected to survive + expectedBetaFlags []string // values expected in the anthropic_beta array in the forwarded body }{ // "beddel" matches no model prefix, so adaptive thinking is converted // to enabled with budget, and all model-gated beta flags are stripped. @@ -474,6 +476,7 @@ func TestAWSBedrockIntegration(t *testing.T) { name: "beddel", model: "beddel", smallFastModel: "modrock", + expectEffort: "", expectThinkingType: "enabled", expectBudgetTokens: 16000, // 32000 * 0.5 (medium effort) expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"}, @@ -483,6 +486,7 @@ func TestAWSBedrockIntegration(t *testing.T) { name: "opus-4.5", model: "anthropic.claude-opus-4-5-20250514-v1:0", smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectEffort: "medium", expectThinkingType: "enabled", expectBudgetTokens: 16000, expectKeptFields: []string{"output_config"}, @@ -493,6 +497,7 @@ func TestAWSBedrockIntegration(t *testing.T) { name: "sonnet-4.5", model: "anthropic.claude-sonnet-4-5-20241022-v2:0", smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectEffort: "", expectThinkingType: "enabled", expectBudgetTokens: 16000, expectKeptFields: []string{"context_management"}, @@ -503,10 +508,23 @@ func TestAWSBedrockIntegration(t *testing.T) { { name: "opus-4.6", model: "anthropic.claude-opus-4-6-20260619-v1:0", + expectEffort: "", smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", expectThinkingType: "adaptive", expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"}, }, + // Sonnet 5 requires adaptive thinking, so legacy enabled thinking is + // converted and output_config.effort is preserved. + { + name: "sonnet-5", + model: "us.anthropic.claude-sonnet-5", + smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectEffort: "medium", + expectThinkingType: "adaptive", + sendThinkingEnabled: true, + expectKeptFields: []string{"output_config"}, + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"}, + }, } for _, tc := range cases { @@ -535,6 +553,13 @@ func TestAWSBedrockIntegration(t *testing.T) { reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming) require.NoError(t, err) + if tc.sendThinkingEnabled { + reqBody, err = sjson.SetBytes(reqBody, "thinking", map[string]any{ + "type": "enabled", + "budget_tokens": 16000, + }) + require.NoError(t, err) + } // Send with Anthropic-Beta header containing flags that should be filtered. resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody, http.Header{ @@ -562,6 +587,7 @@ func TestAWSBedrockIntegration(t *testing.T) { } else { assert.False(t, gjson.GetBytes(body, "thinking.budget_tokens").Exists(), "budget_tokens should not be present") } + assert.Equal(t, tc.expectEffort, gjson.GetBytes(body, "output_config.effort").String(), "effort mismatch") // The Bedrock SDK middleware moves Anthropic-Beta from the header // into the body as "anthropic_beta". @@ -811,6 +837,87 @@ func TestOpenAIChatCompletions(t *testing.T) { } }) + t.Run("streaming cumulative usage with injected tool", func(t *testing.T) { + t.Parallel() + + bridgeServer, mockMCP, resp := setupInjectedToolTest( + t, + fixtures.OaiChatStreamingCumulativeUsageInjectedTool, + true, + defaultTracer, + pathOpenAIChatCompletions, + nil, + ) + defer resp.Body.Close() + + sp := aibridge.NewSSEParser() + require.NoError(t, sp.Parse(resp.Body)) + + var finalUsage gjson.Result + events := sp.MessageEvents() + for i := len(events) - 1; i >= 0; i-- { + if usage := gjson.Get(events[i].Data, "usage"); usage.Exists() { + finalUsage = usage + break + } + } + + require.True(t, finalUsage.Exists()) + require.EqualValues(t, 12000, finalUsage.Get("prompt_tokens").Int()) + require.EqualValues(t, 60, finalUsage.Get("completion_tokens").Int()) + require.EqualValues(t, 12060, finalUsage.Get("total_tokens").Int()) + require.EqualValues(t, 300, finalUsage.Get("prompt_tokens_details.cached_tokens").Int()) + require.EqualValues(t, 30, finalUsage.Get("prompt_tokens_details.cache_write_tokens").Int()) + require.EqualValues(t, 12, finalUsage.Get("prompt_tokens_details.audio_tokens").Int()) + require.EqualValues(t, 14, finalUsage.Get("completion_tokens_details.reasoning_tokens").Int()) + require.EqualValues(t, 16, finalUsage.Get("completion_tokens_details.audio_tokens").Int()) + require.EqualValues(t, 18, finalUsage.Get("completion_tokens_details.accepted_prediction_tokens").Int()) + require.EqualValues(t, 20, finalUsage.Get("completion_tokens_details.rejected_prediction_tokens").Int()) + + tokenUsages := bridgeServer.Recorder.RecordedTokenUsages() + for i := range tokenUsages { + tokenUsages[i].InterceptionID = "" + tokenUsages[i].CreatedAt = time.Time{} + } + + // Each upstream stream reports cumulative snapshots on every chunk. The + // recorded iteration usage must use only its latest snapshot, while the + // client response above sums those two latest snapshots across iterations. + expectedTokenUsages := []*recorder.TokenUsageRecord{ + { + MsgID: "chatcmpl-cumulative-tool", + Input: 5890, + Output: 30, + CacheReadInputTokens: 100, + CacheWriteInputTokens: 10, + ExtraTokenTypes: map[string]int64{ + "prompt_audio": 3, + "completion_reasoning": 4, + "completion_audio": 5, + "completion_accepted_prediction": 6, + "completion_rejected_prediction": 7, + }, + }, + { + MsgID: "chatcmpl-cumulative-final", + Input: 5780, + Output: 30, + CacheReadInputTokens: 200, + CacheWriteInputTokens: 20, + ExtraTokenTypes: map[string]int64{ + "prompt_audio": 9, + "completion_reasoning": 10, + "completion_audio": 11, + "completion_accepted_prediction": 12, + "completion_rejected_prediction": 13, + }, + }, + } + require.ElementsMatch(t, expectedTokenUsages, tokenUsages) + require.Len(t, mockMCP.getCallsByTool(mockToolName), 1) + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + t.Run("streaming injected tool call edge cases", func(t *testing.T) { t.Parallel() @@ -1385,8 +1492,9 @@ func TestOpenAIInjectedTools(t *testing.T) { require.EqualValues(t, expected, actual) var ( - content *openai.ChatCompletionChoice - message openai.ChatCompletion + content *openai.ChatCompletionChoice + message openai.ChatCompletion + clientUsage openai.CompletionUsage ) if streaming { // Parse the response stream. @@ -1397,6 +1505,9 @@ func TestOpenAIInjectedTools(t *testing.T) { for stream.Next() { chunk := stream.Current() acc.AddChunk(chunk) + if chunk.JSON.Usage.Valid() { + clientUsage = chunk.Usage + } if len(chunk.Choices) == 0 { continue @@ -1427,6 +1538,7 @@ func TestOpenAIInjectedTools(t *testing.T) { body, err := io.ReadAll(resp.Body) require.NoError(t, err, "read response body") require.NoError(t, json.Unmarshal(body, &message), "unmarshal response") + clientUsage = message.Usage // Verify that no injected tools were sent to the client. require.GreaterOrEqual(t, len(message.Choices), 1) @@ -1440,15 +1552,60 @@ func TestOpenAIInjectedTools(t *testing.T) { require.NotNil(t, content) require.Contains(t, content.Message.Content, "dd711d5c-83c6-4c08-a0af-b73055906e8c") // The ID of the workspace to be returned. - // Check the token usage from the client's perspective. - // This *should* work but the openai SDK doesn't accumulate the prompt token details :(. - // See https://github.com/openai/openai-go/blob/v2.7.0/streamaccumulator.go#L145-L147. - // assert.EqualValues(t, 5047, message.Usage.PromptTokens-message.Usage.PromptTokensDetails.CachedTokens) - assert.EqualValues(t, 105, message.Usage.CompletionTokens) - - // Ensure tokens used during injected tool invocation are accounted for. - require.EqualValues(t, 5047, bridgeServer.Recorder.TotalInputTokens()) - require.EqualValues(t, 105, bridgeServer.Recorder.TotalOutputTokens()) + // Check the cumulative token usage from the client's perspective. + require.EqualValues(t, 9911, clientUsage.PromptTokens) + require.EqualValues(t, 105, clientUsage.CompletionTokens) + require.EqualValues(t, 10016, clientUsage.TotalTokens) + require.EqualValues(t, 4964, clientUsage.PromptTokensDetails.CachedTokens) + require.EqualValues(t, 30, clientUsage.PromptTokensDetails.CacheWriteTokens) + require.EqualValues(t, 12, clientUsage.PromptTokensDetails.AudioTokens) + require.EqualValues(t, 14, clientUsage.CompletionTokensDetails.ReasoningTokens) + require.EqualValues(t, 16, clientUsage.CompletionTokensDetails.AudioTokens) + require.EqualValues(t, 18, clientUsage.CompletionTokensDetails.AcceptedPredictionTokens) + require.EqualValues(t, 20, clientUsage.CompletionTokensDetails.RejectedPredictionTokens) + + // Ensure both upstream iterations were recorded exactly. + tokenUsages := bridgeServer.Recorder.RecordedTokenUsages() + for _, usage := range tokenUsages { + usage.InterceptionID = "" + usage.CreatedAt = time.Time{} + } + firstMsgID := "chatcmpl-C1XAKDTVYnmWS7tgvg7vPje00PIiy" + secondMsgID := "chatcmpl-C1XANLwdflVxAjKOjbMP3LJxSlXsS" + if streaming { + firstMsgID = "chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV" + secondMsgID = "chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF" + } + require.ElementsMatch(t, []*recorder.TokenUsageRecord{ + { + MsgID: firstMsgID, + Input: 4742, + Output: 45, + CacheReadInputTokens: 100, + CacheWriteInputTokens: 20, + ExtraTokenTypes: map[string]int64{ + "prompt_audio": 3, + "completion_accepted_prediction": 6, + "completion_rejected_prediction": 7, + "completion_audio": 5, + "completion_reasoning": 4, + }, + }, + { + MsgID: secondMsgID, + Input: 175, + Output: 60, + CacheReadInputTokens: 4864, + CacheWriteInputTokens: 10, + ExtraTokenTypes: map[string]int64{ + "prompt_audio": 9, + "completion_accepted_prediction": 12, + "completion_rejected_prediction": 13, + "completion_audio": 11, + "completion_reasoning": 10, + }, + }, + }, tokenUsages) // Ensure we received exactly one prompt. promptUsages := bridgeServer.Recorder.RecordedPromptUsages() diff --git a/aibridge/internal/integrationtest/metrics_internal_test.go b/aibridge/internal/integrationtest/metrics_internal_test.go index 314c2d97c4a4b..a9bcf93e477b4 100644 --- a/aibridge/internal/integrationtest/metrics_internal_test.go +++ b/aibridge/internal/integrationtest/metrics_internal_test.go @@ -294,10 +294,10 @@ func TestMetrics_TokenUseCount(t *testing.T) { expectProvider: config.ProviderOpenAI, expectModel: "gpt-4.1", expectedLabels: map[string]float64{ - "input": 129, // 12033 - 11904 cached + "input": 114, // 12033 - 11904 cached - 15 cache write "output": 44, "cache_read_input_tokens": 11904, - "cache_write_input_tokens": 0, + "cache_write_input_tokens": 15, "output_reasoning": 0, "total_tokens": 12077, }, @@ -323,10 +323,10 @@ func TestMetrics_TokenUseCount(t *testing.T) { expectProvider: config.ProviderOpenAI, expectModel: "gpt-4.1", expectedLabels: map[string]float64{ - "input": 19, + "input": 14, // 19 prompt - 5 cache write "output": 200, "cache_read_input_tokens": 0, - "cache_write_input_tokens": 0, + "cache_write_input_tokens": 5, "completion_reasoning": 0, "completion_accepted_prediction": 0, "completion_rejected_prediction": 0, diff --git a/aibridge/internal/integrationtest/responses_internal_test.go b/aibridge/internal/integrationtest/responses_internal_test.go index 73fccad6398a7..2a6edfcbb1051 100644 --- a/aibridge/internal/integrationtest/responses_internal_test.go +++ b/aibridge/internal/integrationtest/responses_internal_test.go @@ -1,6 +1,7 @@ package integrationtest import ( + "bufio" "context" "encoding/json" "fmt" @@ -94,10 +95,11 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) { expectModel: "gpt-4.1", expectPromptRecorded: "This was a large input...", expectTokenUsage: &recorder.TokenUsageRecord{ - MsgID: "resp_0cd5d6b8310055d600696a1776b42c81a199fbb02248a8bfa0", - Input: 129, // 12033 input - 11904 cached - Output: 44, - CacheReadInputTokens: 11904, + MsgID: "resp_0cd5d6b8310055d600696a1776b42c81a199fbb02248a8bfa0", + Input: 114, // 12033 input - 11904 cached - 15 cache write + Output: 44, + CacheReadInputTokens: 11904, + CacheWriteInputTokens: 15, ExtraTokenTypes: map[string]int64{ "output_reasoning": 0, "total_tokens": 12077, @@ -256,10 +258,11 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) { expectModel: "gpt-5.2-codex", expectPromptRecorded: "Test cached input tokens.", expectTokenUsage: &recorder.TokenUsageRecord{ - MsgID: "resp_05080461b406f3f501696a1409d34c8195a40ff4b092145c35", - Input: 1165, // 16909 input - 15744 cached - Output: 54, - CacheReadInputTokens: 15744, + MsgID: "resp_05080461b406f3f501696a1409d34c8195a40ff4b092145c35", + Input: 1135, // 16909 input - 15744 cached - 30 cache write + Output: 54, + CacheReadInputTokens: 15744, + CacheWriteInputTokens: 30, ExtraTokenTypes: map[string]int64{ "output_reasoning": 0, "total_tokens": 16963, @@ -803,7 +806,8 @@ func TestResponsesInjectedTool(t *testing.T) { expectToolArgs map[string]any expectToolError string // If non-empty, MCP tool returns this error. expectPrompt string - expectTokenUsages []recorder.TokenUsageRecord + expectTokenUsages []*recorder.TokenUsageRecord + expectClientUsage responses.ResponseUsage }{ { name: "blocking_success", @@ -813,28 +817,42 @@ func TestResponsesInjectedTool(t *testing.T) { "template_version_id": "aa4e30e4-a086-4df6-a364-1343f1458104", }, expectPrompt: "list the template params for version aa4e30e4-a086-4df6-a364-1343f1458104", - expectTokenUsages: []recorder.TokenUsageRecord{ + expectTokenUsages: []*recorder.TokenUsageRecord{ { - MsgID: "resp_012db006225b0ec700696b5de8a01481a28182ea6885448f93", - Input: 227, // 6371 input - 6144 cached - Output: 75, - CacheReadInputTokens: 6144, + MsgID: "resp_012db006225b0ec700696b5de8a01481a28182ea6885448f93", + Input: 220, // 6371 input - 6144 cached - 7 cache write + Output: 75, + CacheReadInputTokens: 6144, + CacheWriteInputTokens: 7, ExtraTokenTypes: map[string]int64{ "output_reasoning": 25, "total_tokens": 6446, }, }, { - MsgID: "resp_012db006225b0ec700696b5dec1d4c81a2a6a416e31af39b90", - Input: 612, // 6756 input - 6144 cached - Output: 231, - CacheReadInputTokens: 6144, + MsgID: "resp_012db006225b0ec700696b5dec1d4c81a2a6a416e31af39b90", + Input: 601, // 6756 input - 6144 cached - 11 cache write + Output: 231, + CacheReadInputTokens: 6144, + CacheWriteInputTokens: 11, ExtraTokenTypes: map[string]int64{ "output_reasoning": 43, "total_tokens": 6987, }, }, }, + expectClientUsage: responses.ResponseUsage{ + InputTokens: 13127, + OutputTokens: 306, + TotalTokens: 13433, + InputTokensDetails: responses.ResponseUsageInputTokensDetails{ + CachedTokens: 12288, + CacheWriteTokens: 18, + }, + OutputTokensDetails: responses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: 68, + }, + }, }, { name: "blocking_tool_error", @@ -845,28 +863,42 @@ func TestResponsesInjectedTool(t *testing.T) { }, expectPrompt: "delete the template with ID 03cb4fdd-8109-4a22-8e22-bb4975171395, don't ask for confirmation", expectToolError: "500 Internal error deleting template: unauthorized: rbac: forbidden", - expectTokenUsages: []recorder.TokenUsageRecord{ + expectTokenUsages: []*recorder.TokenUsageRecord{ { - MsgID: "resp_06e2afba24b6b2ad00696b774d1df0819eaf1ec802bc8a2ca9", - Input: 233, // 6377 input - 6144 cached - Output: 119, - CacheReadInputTokens: 6144, + MsgID: "resp_06e2afba24b6b2ad00696b774d1df0819eaf1ec802bc8a2ca9", + Input: 228, // 6377 input - 6144 cached - 5 cache write + Output: 119, + CacheReadInputTokens: 6144, + CacheWriteInputTokens: 5, ExtraTokenTypes: map[string]int64{ "output_reasoning": 70, "total_tokens": 6496, }, }, { - MsgID: "resp_06e2afba24b6b2ad00696b775044e8819ea14840698ef966e2", - Input: 395, // 6539 input - 6144 cached - Output: 144, - CacheReadInputTokens: 6144, + MsgID: "resp_06e2afba24b6b2ad00696b775044e8819ea14840698ef966e2", + Input: 386, // 6539 input - 6144 cached - 9 cache write + Output: 144, + CacheReadInputTokens: 6144, + CacheWriteInputTokens: 9, ExtraTokenTypes: map[string]int64{ "output_reasoning": 28, "total_tokens": 6683, }, }, }, + expectClientUsage: responses.ResponseUsage{ + InputTokens: 12916, + OutputTokens: 263, + TotalTokens: 13179, + InputTokensDetails: responses.ResponseUsageInputTokensDetails{ + CachedTokens: 12288, + CacheWriteTokens: 14, + }, + OutputTokensDetails: responses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: 98, + }, + }, }, { name: "streaming_success", @@ -875,23 +907,26 @@ func TestResponsesInjectedTool(t *testing.T) { mcpToolName: "coder_list_templates", expectToolArgs: map[string]any{}, expectPrompt: "List my coder templates.", - expectTokenUsages: []recorder.TokenUsageRecord{ + expectTokenUsages: []*recorder.TokenUsageRecord{ { - MsgID: "resp_016595fe42aa62ca0069724419c52081a0b7eb479c6bc8109f", - Input: 6269, // 6269 input - 0 cached - Output: 18, + MsgID: "resp_016595fe42aa62ca0069724419c52081a0b7eb479c6bc8109f", + Input: 6162, // 6269 input - 100 cached - 7 cache write + Output: 18, + CacheReadInputTokens: 100, + CacheWriteInputTokens: 7, ExtraTokenTypes: map[string]int64{ - "output_reasoning": 0, + "output_reasoning": 3, "total_tokens": 6287, }, }, { - MsgID: "resp_0bc5f54fce6df69a006972442175908194bb81d31f576e6ca6", - Input: 319, // 6463 input - 6144 cached - Output: 182, - CacheReadInputTokens: 6144, + MsgID: "resp_0bc5f54fce6df69a006972442175908194bb81d31f576e6ca6", + Input: 308, // 6463 input - 6144 cached - 11 cache write + Output: 182, + CacheReadInputTokens: 6144, + CacheWriteInputTokens: 11, ExtraTokenTypes: map[string]int64{ - "output_reasoning": 0, + "output_reasoning": 5, "total_tokens": 6645, }, }, @@ -908,22 +943,26 @@ func TestResponsesInjectedTool(t *testing.T) { }, expectPrompt: "Create a new workspace build for an workspace with id: 'non_existing_id'", expectToolError: "workspace_id must be a valid UUID: invalid UUID length: 15", - expectTokenUsages: []recorder.TokenUsageRecord{ + expectTokenUsages: []*recorder.TokenUsageRecord{ { - MsgID: "resp_0dfed48e1052ad7f0069725ca129f88193b97d6deff1760524", - Input: 6280, // 6280 input - 0 cached - Output: 30, + MsgID: "resp_0dfed48e1052ad7f0069725ca129f88193b97d6deff1760524", + Input: 6175, // 6280 input - 100 cached - 5 cache write + Output: 30, + CacheReadInputTokens: 100, + CacheWriteInputTokens: 5, ExtraTokenTypes: map[string]int64{ - "output_reasoning": 0, + "output_reasoning": 3, "total_tokens": 6310, }, }, { - MsgID: "resp_0dfed48e1052ad7f0069725ca39880819390fcc5b2eb8cf8c6", - Input: 6346, // 6346 input - 0 cached - Output: 56, + MsgID: "resp_0dfed48e1052ad7f0069725ca39880819390fcc5b2eb8cf8c6", + Input: 6237, // 6346 input - 100 cached - 9 cache write + Output: 56, + CacheReadInputTokens: 100, + CacheWriteInputTokens: 9, ExtraTokenTypes: map[string]int64{ - "output_reasoning": 0, + "output_reasoning": 5, "total_tokens": 6402, }, }, @@ -983,24 +1022,23 @@ func TestResponsesInjectedTool(t *testing.T) { require.Len(t, prompts, 1) require.Equal(t, tc.expectPrompt, prompts[0].Prompt) + // Verify both upstream iterations were recorded exactly. Match by + // content because AsyncRecorder does not guarantee record ordering. tokenUsages := bridgeServer.Recorder.RecordedTokenUsages() - require.Len(t, tokenUsages, len(tc.expectTokenUsages)) for i := range tokenUsages { - tokenUsages[i].InterceptionID = "" // ignore interception ID and time creation when comparing + tokenUsages[i].InterceptionID = "" tokenUsages[i].CreatedAt = time.Time{} } + require.ElementsMatch(t, tc.expectTokenUsages, tokenUsages) - // Match by content, not position, AsyncRecorder may flake. - // See https://github.com/coder/internal/issues/1544. - for _, expected := range tc.expectTokenUsages { - require.Contains(t, tokenUsages, &expected) - } - - // Verify the response is the final tool response (after agentic loop). + // Streaming forwards the final upstream response unchanged. Blocking + // rewrites its usage with the sum from both agentic-loop iterations. if tc.streaming { require.Equal(t, string(fix.StreamingToolCall()), string(body)) } else { - require.Equal(t, string(fix.NonStreamingToolCall()), string(body)) + expectedBody, err := sjson.SetBytes(fix.NonStreamingToolCall(), "usage", tc.expectClientUsage) + require.NoError(t, err) + require.JSONEq(t, string(expectedBody), string(body)) } }) } @@ -1158,10 +1196,12 @@ func startRejectingListener(t *testing.T) (addr string) { return } - // Read at least 1 byte so the client has started writing - // before we RST, ensuring a consistent "connection reset by peer". - buf := make([]byte, 1) - _, _ = c.Read(buf) + // Drain the request before the RST so the client observes a + // read-side reset rather than a racy body-write failure. + if req, err := http.ReadRequest(bufio.NewReader(c)); err == nil { + _, _ = io.Copy(io.Discard, req.Body) + _ = req.Body.Close() + } if tc, ok := c.(*net.TCPConn); ok { _ = tc.SetLinger(0) } diff --git a/aibridge/provider/copilot.go b/aibridge/provider/copilot.go index 79dc37ae4be84..10d634c7bdfbd 100644 --- a/aibridge/provider/copilot.go +++ b/aibridge/provider/copilot.go @@ -91,6 +91,8 @@ func (*Copilot) BridgedRoutes() []string { func (*Copilot) PassthroughRoutes() []string { return []string{ + "/_ping", + "/auto", "/models", "/models/", "/agents/", diff --git a/aibridge/routing/path.go b/aibridge/routing/path.go new file mode 100644 index 0000000000000..bdd3a82baf415 --- /dev/null +++ b/aibridge/routing/path.go @@ -0,0 +1,24 @@ +package routing + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/util/xurl" +) + +// InvalidPathMessage is the response body for requests rejected by +// [RejectInvalidForwardPath]. +const InvalidPathMessage = "invalid request path" + +// RejectInvalidForwardPath wraps next and responds with 400 to requests whose +// path is unsafe to forward upstream, as reported by +// [xurl.ContainsEncodedPath], before any routing or upstream request. +func RejectInvalidForwardPath(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if xurl.ContainsEncodedPath(r.URL) { + http.Error(w, InvalidPathMessage, http.StatusBadRequest) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/aibridge/routing/path_test.go b/aibridge/routing/path_test.go new file mode 100644 index 0000000000000..7268babb37f68 --- /dev/null +++ b/aibridge/routing/path_test.go @@ -0,0 +1,32 @@ +package routing_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/aibridge/routing" +) + +func TestRejectInvalidForwardPath(t *testing.T) { + t.Parallel() + + var called bool + handler := routing.RejectInvalidForwardPath(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/openai/v1/models/%2e%2e/files", nil)) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), routing.InvalidPathMessage) + assert.False(t, called) + + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/openai/v1/models/org%2Fmodel", nil)) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} diff --git a/cli/secret.go b/cli/secret.go index 2fb6d75c4fc5e..fce71374900a7 100644 --- a/cli/secret.go +++ b/cli/secret.go @@ -3,13 +3,19 @@ package cli import ( "fmt" "io" + "os" + "path/filepath" + "strconv" "strings" "time" + "unicode/utf8" "github.com/dustin/go-humanize" + "github.com/dustin/go-humanize/english" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/pretty" "github.com/coder/serpent" @@ -29,6 +35,10 @@ func (r *RootCmd) secrets() *serpent.Command { Description: "Update a secret", Command: "echo -n \"$NEW_SECRET_VALUE\" | coder secret update api-key --description \"Rotated API key\" --env API_KEY --file \"~/.api-key\"", }, + Example{ + Description: "Import secrets from a file", + Command: "coder secret import ./secrets.env", + }, Example{ Description: "List your secrets", Command: "coder secret list", @@ -48,6 +58,9 @@ func (r *RootCmd) secrets() *serpent.Command { Children: []*serpent.Command{ r.secretCreate(), r.secretUpdate(), + r.secretImport(), + r.secretEnable(), + r.secretDisable(), r.secretList(), r.secretDelete(), }, @@ -62,6 +75,7 @@ func (r *RootCmd) secretCreate() *serpent.Command { description string env string file string + enabled bool ) cmd := &serpent.Command{ @@ -96,6 +110,13 @@ func (r *RootCmd) secretCreate() *serpent.Command { Description: "Workspace file path where this secret will be written. Must start with ~/ or /.", Value: serpent.StringOf(&file), }, + { + Name: "enabled", + Flag: "enabled", + Description: "Whether the secret is injected into workspaces. An enabled secret must set --env or --file; pass --enabled=false to store a secret without injecting it.", + Default: "true", + Value: serpent.BoolOf(&enabled), + }, }, Handler: func(inv *serpent.Invocation) error { client, err := r.InitClient(inv) @@ -114,13 +135,18 @@ func (r *RootCmd) secretCreate() *serpent.Command { return xerrors.New("secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)") } - secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, codersdk.CreateUserSecretRequest{ + req := codersdk.CreateUserSecretRequest{ Name: inv.Args[0], Value: resolvedValue, Description: description, EnvName: env, FilePath: file, - }) + } + if userSetOption(inv, "enabled") { + req.Enabled = ptr.Ref(enabled) + } + + secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, req) if err != nil { return xerrors.Errorf("create secret %q: %w", inv.Args[0], err) } @@ -139,13 +165,14 @@ func (r *RootCmd) secretUpdate() *serpent.Command { description string env string file string + enabled bool ) cmd := &serpent.Command{ Use: "update ", Short: "Update a secret", Long: strings.Join([]string{ - "At least one of --value, --description, --env, or --file must be specified.", + "At least one of --value, --description, --env, --file, or --enabled must be specified.", "Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect).", }, " "), Middleware: serpent.Chain( @@ -176,6 +203,12 @@ func (r *RootCmd) secretUpdate() *serpent.Command { Description: "Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.", Value: serpent.StringOf(&file), }, + { + Name: "enabled", + Flag: "enabled", + Description: "Whether the secret is injected into workspaces. An enabled secret must keep at least one of --env or --file; pass --enabled=false to stop injecting it without deleting it.", + Value: serpent.BoolOf(&enabled), + }, }, Handler: func(inv *serpent.Invocation) error { client, err := r.InitClient(inv) @@ -200,6 +233,9 @@ func (r *RootCmd) secretUpdate() *serpent.Command { if userSetOption(inv, "file") { req.FilePath = &file } + if userSetOption(inv, "enabled") { + req.Enabled = ptr.Ref(enabled) + } secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, inv.Args[0], req) if err != nil { @@ -214,6 +250,162 @@ func (r *RootCmd) secretUpdate() *serpent.Command { return cmd } +var secretsFileFormats = []string{ + string(codersdk.SecretsFileFormatEnv), + string(codersdk.SecretsFileFormatJSON), + string(codersdk.SecretsFileFormatYAML), +} + +func (r *RootCmd) secretImport() *serpent.Command { + var inputFormat string + + cmd := &serpent.Command{ + Use: "import ", + Short: "Import secrets from a file", + Long: strings.Join([]string{ + "Every key in the file becomes a secret.", + "Keys allowed as environment variable names are injected into workspaces under the same name.", + "The import is all or nothing, and existing secrets are never overwritten.", + "Pass - to read the file from non-interactive stdin (pipe or redirect).", + }, " "), + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + { + Name: "input-format", + Flag: "input-format", + Description: "Format of the secrets file. Inferred from the file extension when unset, and required when reading from stdin.", + Value: serpent.EnumOf(&inputFormat, secretsFileFormats...), + }, + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + path := inv.Args[0] + // serpent.EnumOf matches case-insensitively but keeps the input + // verbatim, and the parser only accepts lowercase formats. + format := codersdk.SecretsFileFormat(strings.ToLower(inputFormat)) + if format == "" { + format, err = secretsFileFormatFromPath(path) + if err != nil { + return err + } + } + + content, err := readSecretsFile(inv, path) + if err != nil { + return err + } + // Parse and validate before sending so that a file picked by mistake, + // such as a private key, never leaves the machine. + requests, err := codersdk.ParseSecretsFile(format, string(content)) + if err != nil { + return xerrors.Errorf("parse %q: %w", path, err) + } + if err := validateImportedSecrets(requests); err != nil { + return xerrors.Errorf("validate %q: %w", path, err) + } + + secrets, err := client.ImportUserSecrets(inv.Context(), codersdk.Me, codersdk.ImportUserSecretsRequest{ + Format: format, + Content: string(content), + }) + if err != nil { + return xerrors.Errorf("import secrets from %q: %w", path, err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Imported %s.\n", english.Plural(len(secrets), "secret", "")) + warnSecretsWithoutEnvName(inv.Stderr, secrets) + return nil + }, + } + + return cmd +} + +// secretsFileFormatFromPath infers the format from the file extension. +// Extensions that do not map to a format, such as ".env.local", require +// --input-format. +func secretsFileFormatFromPath(path string) (codersdk.SecretsFileFormat, error) { + switch strings.ToLower(filepath.Ext(path)) { + case ".env": + return codersdk.SecretsFileFormatEnv, nil + case ".json": + return codersdk.SecretsFileFormatJSON, nil + case ".yaml", ".yml": + return codersdk.SecretsFileFormatYAML, nil + default: + return "", xerrors.Errorf("cannot infer the secrets file format from %q, set --input-format to one of: %s", path, strings.Join(secretsFileFormats, ", ")) + } +} + +// readSecretsFile reads the file at path, or stdin when path is "-". It never +// reads more than one byte past the limit the server accepts. +func readSecretsFile(inv *serpent.Invocation, path string) ([]byte, error) { + reader := inv.Stdin + if path == "-" && isTTYIn(inv) { + return nil, xerrors.New("secrets file must be provided via non-interactive stdin (pipe or redirect)") + } + if path != "-" { + file, err := os.Open(path) + if err != nil { + return nil, xerrors.Errorf("open secrets file: %w", err) + } + defer file.Close() + reader = file + } + + content, err := io.ReadAll(io.LimitReader(reader, codersdk.MaxSecretsFileBytes+1)) + if err != nil { + return nil, xerrors.Errorf("read secrets file: %w", err) + } + if len(content) > codersdk.MaxSecretsFileBytes { + return nil, xerrors.Errorf("secrets file exceeds the maximum allowed size of %d bytes", codersdk.MaxSecretsFileBytes) + } + if !utf8.Valid(content) { + return nil, xerrors.New("secrets file must contain valid UTF-8") + } + return content, nil +} + +func validateImportedSecrets(requests []codersdk.CreateUserSecretRequest) error { + var validationErrors []string + for i, request := range requests { + for _, validation := range codersdk.ValidateCreateUserSecretRequest(request) { + validationErrors = append(validationErrors, fmt.Sprintf("secret %d (%q) %s: %s", i+1, request.Name, validation.Field, validation.Detail)) + } + } + if len(validationErrors) > 0 { + return xerrors.New(strings.Join(validationErrors, "; ")) + } + return nil +} + +// warnSecretsWithoutEnvName reports imported secrets whose key is not a valid +// environment variable name. They are stored with an empty env name and are +// never injected into workspaces until one is set. +func warnSecretsWithoutEnvName(w io.Writer, secrets []codersdk.UserSecret) { + names := make([]string, 0, len(secrets)) + for _, secret := range secrets { + if secret.EnvName == "" { + names = append(names, strconv.Quote(secret.Name)) + } + } + if len(names) == 0 { + return + } + + cliui.Warn(w, + fmt.Sprintf("%s imported without an environment variable name: %s", + english.Plural(len(names), "secret", ""), strings.Join(names, ", ")), + "Set each with `coder secret update --env ` to inject it into workspaces.", + ) +} + func secretValue(inv *serpent.Invocation, value string) (string, bool, error) { valueProvided := userSetOption(inv, "value") stdinValue, stdinProvided, err := readInvocationStdin(inv) @@ -295,6 +487,7 @@ type secretListRow struct { Updated string `json:"-" table:"updated"` Env string `json:"-" table:"env"` File string `json:"-" table:"file"` + Enabled string `json:"-" table:"enabled"` Description string `json:"-" table:"description"` } @@ -306,16 +499,88 @@ func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow { Updated: humanize.Time(secret.UpdatedAt), Env: secret.EnvName, File: secret.FilePath, + Enabled: strconv.FormatBool(secret.Enabled), Description: secret.Description, } } +func (r *RootCmd) secretEnable() *serpent.Command { + return r.secretEnabledSetter(secretEnabledStateEnabled) +} + +func (r *RootCmd) secretDisable() *serpent.Command { + return r.secretEnabledSetter(secretEnabledStateDisabled) +} + +// secretEnabledState distinguishes the two `coder secret enable` and +// `coder secret disable` subcommands without using a bare bool, which +// revive's flag-parameter rule treats as a control coupling. +type secretEnabledState int + +const ( + secretEnabledStateEnabled secretEnabledState = iota + secretEnabledStateDisabled +) + +// secretEnabledSetter builds the `coder secret enable` and `coder secret +// disable` subcommands. Both are a one-field PATCH that flips the enabled +// state. Disabling stops injection for new sessions but leaves the secret +// in place so it can be re-enabled later; existing sessions keep injected +// values until the workspace's agent manifest is refetched. +func (r *RootCmd) secretEnabledSetter(state secretEnabledState) *serpent.Command { + var ( + verb string + participle string + short string + enabled bool + ) + switch state { + case secretEnabledStateEnabled: + verb = "enable" + participle = "Enabled" + short = "Enable a secret so it is injected into workspaces" + enabled = true + case secretEnabledStateDisabled: + verb = "disable" + participle = "Disabled" + short = "Disable a secret without removing it" + enabled = false + } + + cmd := &serpent.Command{ + Use: fmt.Sprintf("%s ", verb), + Short: short, + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + name := inv.Args[0] + secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, name, codersdk.UpdateUserSecretRequest{ + Enabled: ptr.Ref(enabled), + }) + if err != nil { + return xerrors.Errorf("%s secret %q: %w", verb, name, err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "%s secret %s.\n", participle, cliui.Keyword(secret.Name)) + return nil + }, + } + + return cmd +} + func (r *RootCmd) secretList() *serpent.Command { formatter := cliui.NewOutputFormatter( cliui.ChangeFormatterData( cliui.TableFormat( []secretListRow{}, - []string{"name", "created", "updated", "env", "file", "description"}, + []string{"name", "created", "updated", "env", "file", "enabled", "description"}, ), func(data any) (any, error) { switch rows := data.(type) { diff --git a/cli/secret_internal_test.go b/cli/secret_internal_test.go index 70b4597feb1fe..1e5e6be1b7145 100644 --- a/cli/secret_internal_test.go +++ b/cli/secret_internal_test.go @@ -3,12 +3,15 @@ package cli import ( "bytes" "io" + "os" + "path/filepath" "strings" "testing" "github.com/spf13/pflag" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -108,6 +111,107 @@ func TestTrailingNewlineWarnings(t *testing.T) { }) } +func TestSecretsFileFormatFromPath(t *testing.T) { + t.Parallel() + + tests := []struct { + path string + want codersdk.SecretsFileFormat + }{ + {path: ".env", want: codersdk.SecretsFileFormatEnv}, + {path: "/tmp/prod.env", want: codersdk.SecretsFileFormatEnv}, + {path: "secrets.ENV", want: codersdk.SecretsFileFormatEnv}, + {path: "config.json", want: codersdk.SecretsFileFormatJSON}, + {path: "values.yaml", want: codersdk.SecretsFileFormatYAML}, + {path: "values.yml", want: codersdk.SecretsFileFormatYAML}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + t.Parallel() + + got, err := secretsFileFormatFromPath(tt.path) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } + + // filepath.Ext(".env.local") is ".local", so it does not map to a format. + for _, path := range []string{"secrets.txt", "noextension", ".env.local", "-", ""} { + t.Run("Unsupported/"+path, func(t *testing.T) { + t.Parallel() + + _, err := secretsFileFormatFromPath(path) + require.ErrorContains(t, err, "set --input-format to one of: env, json, yaml") + }) + } +} + +func TestReadSecretsFile(t *testing.T) { + t.Parallel() + + t.Run("Stdin", func(t *testing.T) { + t.Parallel() + + inv := newSecretTestInvocation(t, strings.NewReader("A=1"), nil) + + got, err := readSecretsFile(inv, "-") + require.NoError(t, err) + require.Equal(t, "A=1", string(got)) + }) + + t.Run("File", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "secrets.env") + require.NoError(t, os.WriteFile(path, []byte("A=1"), 0o600)) + inv := newSecretTestInvocation(t, strings.NewReader(""), nil) + + got, err := readSecretsFile(inv, path) + require.NoError(t, err) + require.Equal(t, "A=1", string(got)) + }) + + t.Run("MissingFile", func(t *testing.T) { + t.Parallel() + + inv := newSecretTestInvocation(t, strings.NewReader(""), nil) + + _, err := readSecretsFile(inv, filepath.Join(t.TempDir(), "absent.env")) + require.ErrorContains(t, err, "open secrets file") + }) + + t.Run("AtMaxSize", func(t *testing.T) { + t.Parallel() + + content := strings.Repeat("a", codersdk.MaxSecretsFileBytes) + inv := newSecretTestInvocation(t, strings.NewReader(content), nil) + + got, err := readSecretsFile(inv, "-") + require.NoError(t, err) + require.Len(t, got, codersdk.MaxSecretsFileBytes) + }) + + t.Run("OverMaxSize", func(t *testing.T) { + t.Parallel() + + content := strings.Repeat("a", codersdk.MaxSecretsFileBytes+1) + inv := newSecretTestInvocation(t, strings.NewReader(content), nil) + + _, err := readSecretsFile(inv, "-") + require.ErrorContains(t, err, "exceeds the maximum allowed size") + }) +} + +func TestWarnSecretsWithoutEnvNameEscapesNames(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + warnSecretsWithoutEnvName(&stderr, []codersdk.UserSecret{{Name: "\x1b[31mBAD"}}) + + require.Contains(t, stderr.String(), `"\x1b[31mBAD"`) + require.NotContains(t, stderr.String(), "\x1b[31mBAD") +} + func newSecretTestInvocation(t *testing.T, stdin io.Reader, stderr io.Writer) *serpent.Invocation { t.Helper() diff --git a/cli/secret_test.go b/cli/secret_test.go index be3d993db5fc5..0a24576988dd4 100644 --- a/cli/secret_test.go +++ b/cli/secret_test.go @@ -3,7 +3,12 @@ package cli_test import ( "encoding/json" "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" "strings" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -195,8 +200,9 @@ func TestSecretUpdate(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "my-secret", - Value: "original-value", + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", }) require.NoError(t, err) @@ -224,6 +230,10 @@ func TestSecretUpdate(t *testing.T) { }) require.NoError(t, err) + // Clearing env_name and description while leaving file_path + // keeps the secret well-formed (still has an injection + // target). Trying to clear both env_name and file_path is + // covered by the server-side test below. inv, root := clitest.New( t, "secret", @@ -232,7 +242,6 @@ func TestSecretUpdate(t *testing.T) { "--value", "rotated-secret", "--description", "", "--env", "", - "--file", "", ) output := clitest.Capture(inv) clitest.SetupConfig(t, client, root) @@ -246,7 +255,41 @@ func TestSecretUpdate(t *testing.T) { require.NoError(t, err) require.Equal(t, "", secret.Description) require.Equal(t, "", secret.EnvName) - require.Equal(t, "", secret.FilePath) + require.Equal(t, "~/.my-secret", secret.FilePath) + }) + + t.Run("ClearingBothTargetsRejected", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", + }) + require.NoError(t, err) + + inv, root := clitest.New( + t, + "secret", + "update", + "my-secret", + "--env", "", + ) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "env_name", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "at least one of env_name or file_path") }) t.Run("UpdatesValueFromEmptyFlag", func(t *testing.T) { @@ -257,8 +300,9 @@ func TestSecretUpdate(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "my-secret", - Value: "original-value", + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", }) require.NoError(t, err) @@ -286,8 +330,9 @@ func TestSecretUpdate(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "my-secret", - Value: "original-value", + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", }) require.NoError(t, err) @@ -310,8 +355,9 @@ func TestSecretUpdate(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "my-secret", - Value: "original-value", + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", }) require.NoError(t, err) @@ -507,8 +553,9 @@ func TestSecretDelete(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "service-token", - Value: "service-token-value", + Name: "service-token", + Value: "service-token-value", + EnvName: "SERVICE_TOKEN", }) require.NoError(t, err) @@ -542,8 +589,9 @@ func TestSecretDelete(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "service-token", - Value: "service-token-value", + Name: "service-token", + Value: "service-token-value", + EnvName: "SERVICE_TOKEN", }) require.NoError(t, err) @@ -591,3 +639,268 @@ func TestSecretDelete(t *testing.T) { require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) } + +func TestSecretImport(t *testing.T) { + t.Parallel() + + writeSecretsFile := func(t *testing.T, name, content string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path + } + + t.Run("InfersFormatFromExtension", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + content string + }{ + {name: "Env", file: "secrets.env", content: "ALPHA=a\nBETA=b\n"}, + {name: "JSON", file: "secrets.json", content: `{"ALPHA":"a","BETA":"b"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "import", writeSecretsFile(t, tt.file, tt.content)) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + require.NoError(t, inv.WithContext(ctx).Run()) + require.Contains(t, output.Stdout(), "Imported 2 secrets.") + require.NotContains(t, output.Stderr(), "without an environment variable name") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "ALPHA") + require.NoError(t, err) + require.Equal(t, "ALPHA", secret.EnvName) + }) + } + }) + + // The flag wins over the extension, and its value is matched + // case-insensitively. + t.Run("InputFormatOverridesExtension", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + path := writeSecretsFile(t, "secrets.json", "ALPHA=a\n") + inv, root := clitest.New(t, "secret", "import", path, "--input-format", "ENV") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + require.NoError(t, inv.WithContext(ctx).Run()) + require.Contains(t, output.Stdout(), "Imported 1 secret.") + + _, err := client.UserSecretByName(ctx, codersdk.Me, "ALPHA") + require.NoError(t, err) + }) + + t.Run("Stdin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "import", "-", "--input-format", "env") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("ALPHA=a\n") + + ctx := testutil.Context(t, testutil.WaitMedium) + require.NoError(t, inv.WithContext(ctx).Run()) + require.Contains(t, output.Stdout(), "Imported 1 secret.") + + _, err := client.UserSecretByName(ctx, codersdk.Me, "ALPHA") + require.NoError(t, err) + }) + + t.Run("UnknownExtension", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + path := writeSecretsFile(t, "secrets.txt", "ALPHA=a\n") + inv, root := clitest.New(t, "secret", "import", path) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "set --input-format to one of: env, json, yaml") + }) + + t.Run("RejectsLocalErrorsBeforeSending", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content []byte + wantErr string + }{ + {name: "Malformed", content: []byte("ALPHA=a\nNOEQUALS\n"), wantErr: "line 2: expected KEY=VALUE"}, + {name: "InvalidUTF8", content: []byte{'A', 'L', 'P', 'H', 'A', '=', 0xff}, wantErr: "must contain valid UTF-8"}, + {name: "InvalidEntry", content: []byte("ALPHA=a\nBETA=\n"), wantErr: `secret 2 ("BETA") value: Value is required.`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := codersdk.New(must(url.Parse(server.URL))) + client.SetSessionToken("test-token") + path := filepath.Join(t.TempDir(), "secrets.env") + require.NoError(t, os.WriteFile(path, tt.content, 0o600)) + inv, root := clitest.New(t, "secret", "import", path) + clitest.SetupConfig(t, client, root) + + err := inv.Run() + require.ErrorContains(t, err, tt.wantErr) + require.Zero(t, requests.Load(), "expected no API request") + }) + } + }) + + t.Run("RejectsTTYStdin", func(t *testing.T) { + t.Parallel() + + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := codersdk.New(must(url.Parse(server.URL))) + client.SetSessionToken("test-token") + inv, root := clitest.New(t, "--force-tty", "secret", "import", "-", "--input-format", "env") + clitest.SetupConfig(t, client, root) + + err := inv.Run() + require.ErrorContains(t, err, "secrets file must be provided via non-interactive stdin (pipe or redirect)") + require.Zero(t, requests.Load(), "expected no API request") + }) + + t.Run("WarnsAboutKeysWithoutEnvNames", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // PATH is reserved and MY-TOKEN is not a valid identifier, so both are + // imported without an env name. + require.Error(t, codersdk.UserSecretEnvNameValid("PATH")) + path := writeSecretsFile(t, "secrets.env", "ALPHA=a\nPATH=b\nMY-TOKEN=c\n") + inv, root := clitest.New(t, "secret", "import", path) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + require.NoError(t, inv.WithContext(ctx).Run()) + require.Contains(t, output.Stdout(), "Imported 3 secrets.") + require.Contains(t, output.Stderr(), `2 secrets imported without an environment variable name: "PATH", "MY-TOKEN"`) + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "PATH") + require.NoError(t, err) + require.Empty(t, secret.EnvName) + }) +} + +func TestSecretEnableDisable(t *testing.T) { + t.Parallel() + + t.Run("Disable", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + require.True(t, created.Enabled) + + inv, root := clitest.New(t, "secret", "disable", "service-token") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "Disabled secret") + require.Contains(t, output.Stdout(), "service-token") + + got, err := client.UserSecretByName(setupCtx, codersdk.Me, "service-token") + require.NoError(t, err) + assert.False(t, got.Enabled) + }) + + t.Run("Enable", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + disabled := false + created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + EnvName: "SERVICE_TOKEN", + Enabled: &disabled, + }) + require.NoError(t, err) + require.False(t, created.Enabled) + + inv, root := clitest.New(t, "secret", "enable", "service-token") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "Enabled secret") + require.Contains(t, output.Stdout(), "service-token") + + got, err := client.UserSecretByName(setupCtx, codersdk.Me, "service-token") + require.NoError(t, err) + assert.True(t, got.Enabled) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "disable", "missing-secret") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/cli/testdata/coder_secret_--help.golden b/cli/testdata/coder_secret_--help.golden index 45447c96e39e4..a3061334da25f 100644 --- a/cli/testdata/coder_secret_--help.golden +++ b/cli/testdata/coder_secret_--help.golden @@ -17,6 +17,10 @@ USAGE: $ echo -n "$NEW_SECRET_VALUE" | coder secret update api-key --description "Rotated API key" --env API_KEY --file "~/.api-key" + - Import secrets from a file: + + $ coder secret import ./secrets.env + - List your secrets: $ coder secret list @@ -30,10 +34,13 @@ USAGE: $ coder secret delete api-key SUBCOMMANDS: - create Create a secret - delete Delete a secret - list List secrets, or show one by name - update Update a secret + create Create a secret + delete Delete a secret + disable Disable a secret without removing it + enable Enable a secret so it is injected into workspaces + import Import secrets from a file + list List secrets, or show one by name + update Update a secret ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_create_--help.golden b/cli/testdata/coder_secret_create_--help.golden index 0a5d53d119866..8b829bade8d2f 100644 --- a/cli/testdata/coder_secret_create_--help.golden +++ b/cli/testdata/coder_secret_create_--help.golden @@ -12,6 +12,11 @@ OPTIONS: --description string Set the secret description. + --enabled bool (default: true) + Whether the secret is injected into workspaces. An enabled secret must + set --env or --file; pass --enabled=false to store a secret without + injecting it. + --env string Name of the workspace environment variable that this secret will set. diff --git a/cli/testdata/coder_secret_disable_--help.golden b/cli/testdata/coder_secret_disable_--help.golden new file mode 100644 index 0000000000000..5eeb305db643a --- /dev/null +++ b/cli/testdata/coder_secret_disable_--help.golden @@ -0,0 +1,9 @@ +coder v0.0.0-devel + +USAGE: + coder secret disable + + Disable a secret without removing it + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_enable_--help.golden b/cli/testdata/coder_secret_enable_--help.golden new file mode 100644 index 0000000000000..ea5896449c294 --- /dev/null +++ b/cli/testdata/coder_secret_enable_--help.golden @@ -0,0 +1,9 @@ +coder v0.0.0-devel + +USAGE: + coder secret enable + + Enable a secret so it is injected into workspaces + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_import_--help.golden b/cli/testdata/coder_secret_import_--help.golden new file mode 100644 index 0000000000000..217e614405475 --- /dev/null +++ b/cli/testdata/coder_secret_import_--help.golden @@ -0,0 +1,19 @@ +coder v0.0.0-devel + +USAGE: + coder secret import [flags] + + Import secrets from a file + + Every key in the file becomes a secret. Keys allowed as environment variable + names are injected into workspaces under the same name. The import is all or + nothing, and existing secrets are never overwritten. Pass - to read the file + from non-interactive stdin (pipe or redirect). + +OPTIONS: + --input-format env|json|yaml + Format of the secrets file. Inferred from the file extension when + unset, and required when reading from stdin. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_list_--help.golden b/cli/testdata/coder_secret_list_--help.golden index 803968373cf5b..3d2fd83dc5bd4 100644 --- a/cli/testdata/coder_secret_list_--help.golden +++ b/cli/testdata/coder_secret_list_--help.golden @@ -10,7 +10,7 @@ USAGE: Secret values are omitted from the output. OPTIONS: - -c, --column [created|name|updated|env|file|description] (default: name,created,updated,env,file,description) + -c, --column [created|name|updated|env|file|enabled|description] (default: name,created,updated,env,file,enabled,description) Columns to display in table output. -o, --output table|json (default: table) diff --git a/cli/testdata/coder_secret_update_--help.golden b/cli/testdata/coder_secret_update_--help.golden index 6864ca22daa83..12407a74afd1b 100644 --- a/cli/testdata/coder_secret_update_--help.golden +++ b/cli/testdata/coder_secret_update_--help.golden @@ -5,14 +5,19 @@ USAGE: Update a secret - At least one of --value, --description, --env, or --file must be specified. - Provide the secret value by at most one of --value or non-interactive stdin - (pipe or redirect). + At least one of --value, --description, --env, --file, or --enabled must be + specified. Provide the secret value by at most one of --value or + non-interactive stdin (pipe or redirect). OPTIONS: --description string Update the secret description. Pass an empty string to clear it. + --enabled bool + Whether the secret is injected into workspaces. An enabled secret must + keep at least one of --env or --file; pass --enabled=false to stop + injecting it without deleting it. + --env string Name of the workspace environment variable that this secret will set. Pass an empty string to clear it. diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 8abc40867e264..54a172752444a 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -55,6 +55,13 @@ OPTIONS: the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured. + --disable-workspace-agent-context-sync bool, $CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC + Stop persisting workspace agent context snapshots (instructions, + skills, and MCP state used for pinned chat context). When set, coderd + rejects agent context pushes as unimplemented and agents stop sending + them; chats cannot pin workspace context. Use this to shed the + database write load of context sync on large deployments. + --disable-workspace-sharing bool, $CODER_DISABLE_WORKSPACE_SHARING Disable workspace sharing. Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index a8f3d90eb32e0..393150a003467 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -566,6 +566,13 @@ disableWorkspaceSharing: false # their chats. # (default: , type: bool) disableChatSharing: false +# Stop persisting workspace agent context snapshots (instructions, skills, and MCP +# state used for pinned chat context). When set, coderd rejects agent context +# pushes as unimplemented and agents stop sending them; chats cannot pin workspace +# context. Use this to shed the database write load of context sync on large +# deployments. +# (default: , type: bool) +disableWorkspaceAgentContextSync: false # These options change the behavior of how clients interact with the Coder. # Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. client: diff --git a/cli/usercreate.go b/cli/usercreate.go index 1a904582593e2..dbbe92be664af 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -44,10 +44,15 @@ func (r *RootCmd) userCreate() *serpent.Command { case disableLogin: return xerrors.New("You cannot use --disable-login with --service-account") } - } - - if disableLogin && loginType != "" { - return xerrors.New("You cannot specify both --disable-login and --login-type") + } else { + switch { + case disableLogin && loginType != "": + return xerrors.New("You cannot specify both --disable-login and --login-type") + case disableLogin: + return xerrors.New("--disable-login is deprecated. Use --service-account for machine-to-machine access.") + case loginType == string(codersdk.LoginTypeNone): + return xerrors.New("Login type 'none' is deprecated. Use --service-account for machine-to-machine access.") + } } client, err := r.InitClient(inv) @@ -200,7 +205,7 @@ Create a workspace `+pretty.Sprint(cliui.DefaultStyles.Code, "coder create")+`! { Flag: "disable-login", Hidden: true, - Description: "Deprecated: Use '--login-type=none'. \nDisabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " + + Description: "Deprecated: Use --service-account (requires Premium) for machine-to-machine access. \nDisabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " + "Be careful when using this flag as it can lock the user out of their account.", Value: serpent.BoolOf(&disableLogin), }, diff --git a/cli/usercreate_test.go b/cli/usercreate_test.go index 7453d371238f7..5fc9c86d81f7c 100644 --- a/cli/usercreate_test.go +++ b/cli/usercreate_test.go @@ -160,6 +160,21 @@ func TestUserCreate(t *testing.T) { args: []string{"--service-account", "-u", "dean", "--password", "1n5ecureP4ssw0rd!"}, err: "You cannot use --password with --service-account", }, + { + name: "DisableLogin", + args: []string{"--disable-login", "-u", "dean"}, + err: "--disable-login is deprecated. Use --service-account for machine-to-machine access.", + }, + { + name: "LoginTypeNone", + args: []string{"--login-type", "none", "-u", "dean"}, + err: "Login type 'none' is deprecated. Use --service-account for machine-to-machine access.", + }, + { + name: "DisableLoginWithLoginType", + args: []string{"--disable-login", "--login-type", "password", "-u", "dean"}, + err: "You cannot specify both --disable-login and --login-type", + }, } for _, tt := range tests { diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index ce697bc4826fe..369aeede04dec 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -59,7 +59,7 @@ type API struct { *SubAgentAPI *BoundaryLogsAPI *ContextAPI - *tailnet.DRPCService + tailnetService *tailnet.DRPCService cachedWorkspaceFields *CachedWorkspaceFields @@ -68,6 +68,28 @@ type API struct { var _ agentproto.DRPCAgentServer = &API{} +// agentTailnetService exposes only Tailnet RPCs intended for workspace agents. +// Other current and future RPCs remain unavailable until explicitly forwarded. +type agentTailnetService struct { + tailnetproto.DRPCTailnetUnimplementedServer + + service *tailnet.DRPCService +} + +func (s *agentTailnetService) PostTelemetry(ctx context.Context, req *tailnetproto.TelemetryRequest) (*tailnetproto.TelemetryResponse, error) { + return s.service.PostTelemetry(ctx, req) +} + +func (s *agentTailnetService) StreamDERPMaps(req *tailnetproto.StreamDERPMapsRequest, stream tailnetproto.DRPCTailnet_StreamDERPMapsStream) error { + return s.service.StreamDERPMaps(req, stream) +} + +func (s *agentTailnetService) Coordinate(stream tailnetproto.DRPCTailnet_CoordinateStream) error { + return s.service.Coordinate(stream) +} + +var _ tailnetproto.DRPCTailnetServer = (*agentTailnetService)(nil) + type Options struct { AgentID uuid.UUID OwnerID uuid.UUID @@ -83,7 +105,10 @@ type Options struct { Pubsub pubsub.Pubsub // ContextDirtyMarker is the chatd-backed hydrate/dirty fan-out invoked // from PushContextState. Nil when chatd is disabled. - ContextDirtyMarker ContextDirtyMarker + ContextDirtyMarker ContextDirtyMarker + // ContextSyncDisabled makes PushContextState reject pushes with a dRPC + // Unimplemented code so agents stop sending context snapshots. + ContextSyncDisabled bool ConnectionLogger *atomic.Pointer[connectionlog.ConnectionLogger] DerpMapFn func() *tailcfg.DERPMap TailnetCoordinator *atomic.Pointer[tailnet.Coordinator] @@ -221,7 +246,7 @@ func New(opts Options, workspace database.Workspace, agent database.WorkspaceAge Log: opts.Log, } - api.DRPCService = &tailnet.DRPCService{ + api.tailnetService = &tailnet.DRPCService{ CoordPtr: opts.TailnetCoordinator, Logger: opts.Log, DerpMapUpdateFrequency: opts.DerpMapUpdateFrequency, @@ -257,6 +282,7 @@ func New(opts Options, workspace database.Workspace, agent database.WorkspaceAge Clock: opts.Clock, Database: opts.Database, DirtyMarker: opts.ContextDirtyMarker, + Disabled: opts.ContextSyncDisabled, } // Start background cache refresh loop to handle workspace changes @@ -273,12 +299,14 @@ func (a *API) Server(ctx context.Context) (*drpcserver.Server, error) { return nil, xerrors.Errorf("register agent API protocol in DRPC mux: %w", err) } - err = tailnetproto.DRPCRegisterTailnet(mux, a) + err = tailnetproto.DRPCRegisterTailnet(mux, &agentTailnetService{ + service: a.tailnetService, + }) if err != nil { return nil, xerrors.Errorf("register tailnet API protocol in DRPC mux: %w", err) } - return drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + return drpcsdk.NewServer(a.opts.Log, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/agentapi/context.go b/coderd/agentapi/context.go index f6dd69e4e3824..09766bca87c8a 100644 --- a/coderd/agentapi/context.go +++ b/coderd/agentapi/context.go @@ -12,6 +12,7 @@ import ( "golang.org/x/xerrors" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "storj.io/drpc/drpcerr" "cdr.dev/slog/v3" agentproto "github.com/coder/coder/v2/agent/proto" @@ -67,6 +68,13 @@ type ContextAPI struct { // snapshot persisted by a push. It is nil when chatd is not running, // in which case PushContextState stays a pure write path. DirtyMarker ContextDirtyMarker + // Disabled rejects every push with a dRPC Unimplemented code. The + // agent's DRPCPusher translates that code into ErrPushUnimplemented, + // which terminates its RunPush loop for the life of the connection, + // exactly as if coderd predated the v2.10 Agent API. This is the + // deployment-wide kill switch for context sync write load + // (CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC). + Disabled bool } // ContextDirtyMarker hydrates chats from, and marks chats dirty against, a @@ -103,6 +111,14 @@ type ContextDirtyMarker interface { // authorizes the actor (the agent's token subject) against the // workspace that owns the agent. func (a *ContextAPI) PushContextState(ctx context.Context, req *agentproto.PushContextStateRequest) (*agentproto.PushContextStateResponse, error) { + if a.Disabled { + // The Unimplemented code (not a plain error) is what tells the + // agent to stop pushing instead of retrying with backoff. + return nil, drpcerr.WithCode( + xerrors.New("agentapi: workspace agent context sync is disabled on this deployment"), + drpcerr.Unimplemented, + ) + } if req == nil { return nil, xerrors.New("agentapi: PushContextState request is nil") } diff --git a/coderd/agentapi/context_test.go b/coderd/agentapi/context_test.go index 5c724b93560da..f190d0dfb0280 100644 --- a/coderd/agentapi/context_test.go +++ b/coderd/agentapi/context_test.go @@ -13,6 +13,7 @@ import ( "github.com/lib/pq" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "storj.io/drpc/drpcerr" "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" @@ -58,6 +59,27 @@ func TestPushContextState(t *testing.T) { ) } + t.Run("DisabledReturnsUnimplemented", func(t *testing.T) { + t.Parallel() + + // No InTx or query expectations: a disabled push must return + // before touching the store. The Unimplemented dRPC code is + // load-bearing; the agent's DRPCPusher translates it into + // ErrPushUnimplemented, which stops its RunPush loop instead + // of retrying with backoff. + api, _ := makeAPI(t) + api.Disabled = true + + resp, err := api.PushContextState(context.Background(), &agentproto.PushContextStateRequest{ + Version: 1, + AggregateHash: []byte{0x01, 0x02}, + Initial: true, + }) + require.Error(t, err) + require.Nil(t, resp) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + }) + t.Run("AcceptsInitialPush", func(t *testing.T) { t.Parallel() diff --git a/coderd/agentapi/lifecycle.go b/coderd/agentapi/lifecycle.go index 5003a16f04dae..d9b1aa296a162 100644 --- a/coderd/agentapi/lifecycle.go +++ b/coderd/agentapi/lifecycle.go @@ -10,7 +10,9 @@ import ( "github.com/google/uuid" "golang.org/x/mod/semver" "golang.org/x/xerrors" + "google.golang.org/grpc/codes" "google.golang.org/protobuf/types/known/timestamppb" + "storj.io/drpc/drpcerr" "cdr.dev/slog/v3" agentproto "github.com/coder/coder/v2/agent/proto" @@ -45,6 +47,10 @@ func (a *LifecycleAPI) now() time.Time { } func (a *LifecycleAPI) UpdateLifecycle(ctx context.Context, req *agentproto.UpdateLifecycleRequest) (*agentproto.Lifecycle, error) { + if req.GetLifecycle() == nil { + return nil, drpcerr.WithCode(xerrors.New("lifecycle is required"), uint64(codes.InvalidArgument)) + } + workspaceAgent, err := a.AgentFn(ctx) if err != nil { return nil, err @@ -146,6 +152,10 @@ func (a *LifecycleAPI) UpdateLifecycle(ctx context.Context, req *agentproto.Upda } func (a *LifecycleAPI) UpdateStartup(ctx context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) { + if req.GetStartup() == nil { + return nil, drpcerr.WithCode(xerrors.New("startup is required"), uint64(codes.InvalidArgument)) + } + apiVersion, ok := ctx.Value(contextKeyAPIVersion{}).(string) if !ok { return nil, xerrors.Errorf("internal error; api version unspecified") diff --git a/coderd/agentapi/manifest.go b/coderd/agentapi/manifest.go index fd8e6f7739cfa..7af2e6fd85d6d 100644 --- a/coderd/agentapi/manifest.go +++ b/coderd/agentapi/manifest.go @@ -278,10 +278,11 @@ func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevconta func dbUserSecretsToProto(secrets []database.UserSecret) []*agentproto.WorkspaceSecret { ret := make([]*agentproto.WorkspaceSecret, 0, len(secrets)) for _, s := range secrets { - // Only include secrets that have an environment variable - // name or file path set. Secrets with neither are not - // injected at runtime. - if s.EnvName == "" && s.FilePath == "" { + // Skip disabled secrets so they are not injected as env vars or + // written to secret files. The API guarantees every enabled + // secret has at least one of env_name or file_path set, so we + // don't need to filter both-empty rows separately here. + if !s.Enabled { continue } ret = append(ret, &agentproto.WorkspaceSecret{ diff --git a/coderd/agentapi/manifest_test.go b/coderd/agentapi/manifest_test.go index 4c5890052b0da..fecfc1cf5be1f 100644 --- a/coderd/agentapi/manifest_test.go +++ b/coderd/agentapi/manifest_test.go @@ -468,19 +468,20 @@ func TestGetManifest(t *testing.T) { mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil) // Return a mix of secrets: env-only, file-only, both, and - // one with neither set. The last should be filtered out. + // one explicitly disabled. The disabled secret should be + // filtered out. mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return([]database.UserSecret{ - {EnvName: "GITHUB_TOKEN", FilePath: "", Value: "ghp_xxxx"}, - {EnvName: "", FilePath: "~/.ssh/id_rsa", Value: "private-key"}, - {EnvName: "BOTH_ENV", FilePath: "/etc/both", Value: "both-val"}, - {EnvName: "", FilePath: "", Value: "stored-only"}, + {EnvName: "GITHUB_TOKEN", FilePath: "", Value: "ghp_xxxx", Enabled: true}, + {EnvName: "", FilePath: "~/.ssh/id_rsa", Value: "private-key", Enabled: true}, + {EnvName: "BOTH_ENV", FilePath: "/etc/both", Value: "both-val", Enabled: true}, + {EnvName: "DISABLED_ENV", FilePath: "", Value: "disabled-val", Enabled: false}, }, nil) got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{}) require.NoError(t, err) - // The secret with neither env_name nor file_path should - // be filtered out, leaving exactly 3. + // The disabled secret should be filtered out, leaving + // exactly 3. require.Len(t, got.Secrets, 3) require.Equal(t, "GITHUB_TOKEN", got.Secrets[0].EnvName) require.Equal(t, "", got.Secrets[0].FilePath) diff --git a/coderd/agentapi/metadata.go b/coderd/agentapi/metadata.go index 12efe362abb02..8baee8eb3f236 100644 --- a/coderd/agentapi/metadata.go +++ b/coderd/agentapi/metadata.go @@ -8,6 +8,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "google.golang.org/grpc/codes" + "storj.io/drpc/drpcerr" "cdr.dev/slog/v3" agentproto "github.com/coder/coder/v2/agent/proto" @@ -34,6 +36,16 @@ func (a *MetadataAPI) now() time.Time { } func (a *MetadataAPI) BatchUpdateMetadata(ctx context.Context, req *agentproto.BatchUpdateMetadataRequest) (*agentproto.BatchUpdateMetadataResponse, error) { + if req == nil { + return nil, drpcerr.WithCode(xerrors.New("metadata request is required"), uint64(codes.InvalidArgument)) + } + // Validate the entire batch before trimming values or discarding excess keys. + for i, md := range req.Metadata { + if md.GetResult() == nil { + return nil, drpcerr.WithCode(xerrors.Errorf("metadata result at index %d is required", i), uint64(codes.InvalidArgument)) + } + } + const ( // maxAllKeysLen is the maximum length of all metadata keys. This is // 6144 to stay below the Postgres NOTIFY limit of 8000 bytes, with some diff --git a/coderd/agentapi/validation_test.go b/coderd/agentapi/validation_test.go new file mode 100644 index 0000000000000..2a32556b2195d --- /dev/null +++ b/coderd/agentapi/validation_test.go @@ -0,0 +1,90 @@ +package agentapi_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "storj.io/drpc/drpcerr" + + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/coderd/agentapi" + "github.com/coder/coder/v2/codersdk/drpcsdk" + "github.com/coder/coder/v2/testutil" +) + +func TestUpdateLifecycleMissingMessage(t *testing.T) { + t.Parallel() + + resp, err := (&agentapi.LifecycleAPI{}).UpdateLifecycle(context.Background(), &agentproto.UpdateLifecycleRequest{}) + require.ErrorContains(t, err, "lifecycle is required") + require.EqualValues(t, codes.InvalidArgument, drpcerr.Code(err)) + require.Nil(t, resp) +} + +func TestUpdateStartupMissingMessage(t *testing.T) { + t.Parallel() + + resp, err := (&agentapi.LifecycleAPI{}).UpdateStartup(context.Background(), &agentproto.UpdateStartupRequest{}) + require.ErrorContains(t, err, "startup is required") + require.EqualValues(t, codes.InvalidArgument, drpcerr.Code(err)) + require.Nil(t, resp) +} + +func TestBatchUpdateMetadataMissingMessage(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + req *agentproto.BatchUpdateMetadataRequest + }{ + {name: "NilRequest"}, + {name: "MixedBatch", req: &agentproto.BatchUpdateMetadataRequest{Metadata: []*agentproto.Metadata{ + {Key: "valid", Result: &agentproto.WorkspaceAgentMetadata_Result{Value: "value"}}, + {Key: "invalid"}, + }}}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp, err := (&agentapi.MetadataAPI{}).BatchUpdateMetadata(context.Background(), tt.req) + require.Error(t, err) + require.EqualValues(t, codes.InvalidArgument, drpcerr.Code(err)) + require.Nil(t, resp) + }) + } +} + +func TestAgentRPCMissingMessage(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + serverCtx, cancel := context.WithCancel(agentapi.WithAPIVersion(ctx, "2.0")) + defer cancel() + conn, listener := drpcsdk.MemTransportPipe() + defer conn.Close() + defer listener.Close() + + api := &agentapi.API{ + LifecycleAPI: &agentapi.LifecycleAPI{}, + StatsAPI: &agentapi.StatsAPI{}, + } + server, err := api.Server(serverCtx) + require.NoError(t, err) + done := make(chan error, 1) + go func() { + done <- server.Serve(serverCtx, listener) + }() + client := agentproto.NewDRPCAgentClient(conn) + + _, err = client.UpdateLifecycle(ctx, &agentproto.UpdateLifecycleRequest{}) + require.ErrorContains(t, err, "lifecycle is required") + require.EqualValues(t, codes.InvalidArgument, drpcerr.Code(err)) + stats, err := client.UpdateStats(ctx, &agentproto.UpdateStatsRequest{}) + require.NoError(t, err) + require.NotNil(t, stats.ReportInterval) + + cancel() + require.NoError(t, testutil.RequireReceive(ctx, t, done)) +} diff --git a/coderd/aibridge/prices/data/prices.json b/coderd/aibridge/prices/data/prices.json index d6aabb9abfbe6..7570561b6185e 100644 --- a/coderd/aibridge/prices/data/prices.json +++ b/coderd/aibridge/prices/data/prices.json @@ -128,23 +128,199 @@ "cache_write_price": 2500000 }, { - "provider": "openai", - "model": "gpt-3.5-turbo", + "provider": "azure", + "model": "claude-fable-5", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "azure", + "model": "claude-haiku-4-5", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "azure", + "model": "claude-opus-4-1", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "azure", + "model": "claude-opus-4-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "azure", + "model": "claude-opus-4-6", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "azure", + "model": "claude-opus-4-8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "azure", + "model": "claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "azure", + "model": "claude-sonnet-4-5", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "azure", + "model": "claude-sonnet-4-6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "azure", + "model": "claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "azure", + "model": "codestral-2501", + "input_price": 300000, + "output_price": 900000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "codex-mini", + "input_price": 1500000, + "output_price": 6000000, + "cache_read_price": 375000, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "cohere-command-a", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "cohere-embed-v-4-0", + "input_price": 120000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "cohere-embed-v3-english", + "input_price": 100000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "cohere-embed-v3-multilingual", + "input_price": 100000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "deepseek-r1", + "input_price": 1350000, + "output_price": 5400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "deepseek-v3.2", + "input_price": 580000, + "output_price": 1680000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "deepseek-v3.2-speciale", + "input_price": 580000, + "output_price": 1680000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "deepseek-v4-flash", + "input_price": 190000, + "output_price": 510000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "deepseek-v4-pro", + "input_price": 1740000, + "output_price": 3480000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "gpt-3.5-turbo-0125", "input_price": 500000, "output_price": 1500000, - "cache_read_price": 0, + "cache_read_price": null, "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-4", - "input_price": 30000000, - "output_price": 60000000, + "provider": "azure", + "model": "gpt-3.5-turbo-1106", + "input_price": 1000000, + "output_price": 2000000, "cache_read_price": null, "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", + "model": "gpt-3.5-turbo-instruct", + "input_price": 1500000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", "model": "gpt-4-turbo", "input_price": 10000000, "output_price": 30000000, @@ -152,7 +328,15 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", + "model": "gpt-4-turbo-vision", + "input_price": 10000000, + "output_price": 30000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", "model": "gpt-4.1", "input_price": 2000000, "output_price": 8000000, @@ -160,7 +344,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-4.1-mini", "input_price": 400000, "output_price": 1600000, @@ -168,7 +352,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-4.1-nano", "input_price": 100000, "output_price": 400000, @@ -176,7 +360,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-4o", "input_price": 2500000, "output_price": 10000000, @@ -184,31 +368,7 @@ "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-4o-2024-05-13", - "input_price": 5000000, - "output_price": 15000000, - "cache_read_price": null, - "cache_write_price": null - }, - { - "provider": "openai", - "model": "gpt-4o-2024-08-06", - "input_price": 2500000, - "output_price": 10000000, - "cache_read_price": 1250000, - "cache_write_price": null - }, - { - "provider": "openai", - "model": "gpt-4o-2024-11-20", - "input_price": 2500000, - "output_price": 10000000, - "cache_read_price": 1250000, - "cache_write_price": null - }, - { - "provider": "openai", + "provider": "azure", "model": "gpt-4o-mini", "input_price": 150000, "output_price": 600000, @@ -216,31 +376,39 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5", "input_price": 1250000, "output_price": 10000000, - "cache_read_price": 125000, + "cache_read_price": 130000, "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", + "model": "gpt-5-codex", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 130000, + "cache_write_price": null + }, + { + "provider": "azure", "model": "gpt-5-mini", "input_price": 250000, "output_price": 2000000, - "cache_read_price": 25000, + "cache_read_price": 30000, "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5-nano", "input_price": 50000, "output_price": 400000, - "cache_read_price": 5000, + "cache_read_price": 10000, "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5-pro", "input_price": 15000000, "output_price": 120000000, @@ -248,7 +416,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5.1", "input_price": 1250000, "output_price": 10000000, @@ -256,55 +424,55 @@ "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-5.2", - "input_price": 1750000, - "output_price": 14000000, - "cache_read_price": 175000, + "provider": "azure", + "model": "gpt-5.1-codex", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-5.2-chat-latest", - "input_price": 1750000, - "output_price": 14000000, - "cache_read_price": 175000, + "provider": "azure", + "model": "gpt-5.1-codex-max", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-5.2-pro", - "input_price": 21000000, - "output_price": 168000000, - "cache_read_price": null, + "provider": "azure", + "model": "gpt-5.1-codex-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 25000, "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-5.3-chat-latest", + "provider": "azure", + "model": "gpt-5.2", "input_price": 1750000, "output_price": 14000000, - "cache_read_price": 175000, + "cache_read_price": 125000, "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-5.3-codex", + "provider": "azure", + "model": "gpt-5.2-codex", "input_price": 1750000, "output_price": 14000000, "cache_read_price": 175000, "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-5.3-codex-spark", + "provider": "azure", + "model": "gpt-5.3-codex", "input_price": 1750000, "output_price": 14000000, "cache_read_price": 175000, "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5.4", "input_price": 2500000, "output_price": 15000000, @@ -312,7 +480,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5.4-mini", "input_price": 750000, "output_price": 4500000, @@ -320,7 +488,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5.4-nano", "input_price": 200000, "output_price": 1250000, @@ -328,7 +496,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5.4-pro", "input_price": 30000000, "output_price": 180000000, @@ -336,7 +504,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5.5", "input_price": 5000000, "output_price": 30000000, @@ -344,23 +512,7 @@ "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-5.5-pro", - "input_price": 30000000, - "output_price": 180000000, - "cache_read_price": null, - "cache_write_price": null - }, - { - "provider": "openai", - "model": "gpt-5.6", - "input_price": 5000000, - "output_price": 30000000, - "cache_read_price": 500000, - "cache_write_price": 6250000 - }, - { - "provider": "openai", + "provider": "azure", "model": "gpt-5.6-luna", "input_price": 1000000, "output_price": 6000000, @@ -368,15 +520,15 @@ "cache_write_price": 1250000 }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5.6-sol", "input_price": 5000000, "output_price": 30000000, "cache_read_price": 500000, - "cache_write_price": 6250000 + "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "gpt-5.6-terra", "input_price": 2500000, "output_price": 15000000, @@ -384,39 +536,151 @@ "cache_write_price": 3125000 }, { - "provider": "openai", - "model": "gpt-image-2", + "provider": "azure", + "model": "gpt-chat-latest", "input_price": 5000000, "output_price": 30000000, - "cache_read_price": 1250000, + "cache_read_price": 500000, "cache_write_price": null }, { - "provider": "openai", - "model": "gpt-realtime-2.1", - "input_price": 4000000, - "output_price": 24000000, - "cache_read_price": 400000, + "provider": "azure", + "model": "gpt-image-1", + "input_price": 5000000, + "output_price": 40000000, + "cache_read_price": 1250000, "cache_write_price": null }, { - "provider": "openai", - "model": "o1", - "input_price": 15000000, - "output_price": 60000000, - "cache_read_price": 7500000, + "provider": "azure", + "model": "gpt-image-1.5", + "input_price": 5000000, + "output_price": 32000000, + "cache_read_price": 1250000, "cache_write_price": null }, { - "provider": "openai", - "model": "o1-pro", - "input_price": 150000000, - "output_price": 600000000, + "provider": "azure", + "model": "gpt-image-2", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "grok-4-1-fast-non-reasoning", + "input_price": 200000, + "output_price": 500000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "grok-4-1-fast-reasoning", + "input_price": 200000, + "output_price": 500000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "grok-4-20-non-reasoning", + "input_price": 2000000, + "output_price": 6000000, "cache_read_price": null, "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", + "model": "grok-4-20-reasoning", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "kimi-k2.5", + "input_price": 600000, + "output_price": 3000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "kimi-k2.6", + "input_price": 950000, + "output_price": 4000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "llama-3.3-70b-instruct", + "input_price": 710000, + "output_price": 710000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "llama-4-maverick-17b-128e-instruct-fp8", + "input_price": 250000, + "output_price": 1000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "llama-4-scout-17b-16e-instruct", + "input_price": 200000, + "output_price": 780000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "ministral-3b", + "input_price": 40000, + "output_price": 40000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "mistral-medium-2505", + "input_price": 400000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "mistral-small-2503", + "input_price": 100000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "model-router", + "input_price": 140000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "o1", + "input_price": 15000000, + "output_price": 60000000, + "cache_read_price": 7500000, + "cache_write_price": null + }, + { + "provider": "azure", "model": "o3", "input_price": 2000000, "output_price": 8000000, @@ -424,7 +688,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "o3-mini", "input_price": 1100000, "output_price": 4400000, @@ -432,15 +696,7 @@ "cache_write_price": null }, { - "provider": "openai", - "model": "o3-pro", - "input_price": 20000000, - "output_price": 80000000, - "cache_read_price": null, - "cache_write_price": null - }, - { - "provider": "openai", + "provider": "azure", "model": "o4-mini", "input_price": 1100000, "output_price": 4400000, @@ -448,7 +704,55 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", + "model": "phi-4", + "input_price": 125000, + "output_price": 500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "phi-4-mini", + "input_price": 75000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "phi-4-mini-reasoning", + "input_price": 75000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "phi-4-multimodal", + "input_price": 80000, + "output_price": 320000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "phi-4-reasoning", + "input_price": 125000, + "output_price": 500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", + "model": "phi-4-reasoning-plus", + "input_price": 125000, + "output_price": 500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "azure", "model": "text-embedding-3-large", "input_price": 130000, "output_price": 0, @@ -456,7 +760,7 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "text-embedding-3-small", "input_price": 20000, "output_price": 0, @@ -464,11 +768,6179 @@ "cache_write_price": null }, { - "provider": "openai", + "provider": "azure", "model": "text-embedding-ada-002", "input_price": 100000, "output_price": 0, "cache_read_price": null, "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "amazon.nova-2-lite-v1:0", + "input_price": 330000, + "output_price": 2750000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "amazon.nova-lite-v1:0", + "input_price": 60000, + "output_price": 240000, + "cache_read_price": 15000, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "amazon.nova-micro-v1:0", + "input_price": 35000, + "output_price": 140000, + "cache_read_price": 8750, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "amazon.nova-pro-v1:0", + "input_price": 800000, + "output_price": 3200000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "anthropic.claude-fable-5", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-haiku-4-5-20251001-v1:0", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-opus-4-1-20250805-v1:0", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-opus-4-5-20251101-v1:0", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-opus-4-6-v1", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-opus-4-7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-opus-4-8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-sonnet-4-6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "anthropic.claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "bedrock", + "model": "au.anthropic.claude-haiku-4-5-20251001-v1:0", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "bedrock", + "model": "au.anthropic.claude-opus-4-6-v1", + "input_price": 16500000, + "output_price": 82500000, + "cache_read_price": 1650000, + "cache_write_price": 20625000 + }, + { + "provider": "bedrock", + "model": "au.anthropic.claude-opus-4-8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "au.anthropic.claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "au.anthropic.claude-sonnet-4-5-20250929-v1:0", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "au.anthropic.claude-sonnet-4-6", + "input_price": 3300000, + "output_price": 16500000, + "cache_read_price": 330000, + "cache_write_price": 4125000 + }, + { + "provider": "bedrock", + "model": "au.anthropic.claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "bedrock", + "model": "deepseek.r1-v1:0", + "input_price": 1350000, + "output_price": 5400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "deepseek.v3-v1:0", + "input_price": 580000, + "output_price": 1680000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "deepseek.v3.2", + "input_price": 620000, + "output_price": 1850000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-fable-5", + "input_price": 11000000, + "output_price": 55000000, + "cache_read_price": 1100000, + "cache_write_price": 13750000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "input_price": 1100000, + "output_price": 5500000, + "cache_read_price": 110000, + "cache_write_price": 1375000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-opus-4-5-20251101-v1:0", + "input_price": 5500000, + "output_price": 27500000, + "cache_read_price": 550000, + "cache_write_price": 6875000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-opus-4-6-v1", + "input_price": 5500000, + "output_price": 27500000, + "cache_read_price": 550000, + "cache_write_price": 6875000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-opus-4-7", + "input_price": 5500000, + "output_price": 27500000, + "cache_read_price": 550000, + "cache_write_price": 6875000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-opus-4-8", + "input_price": 5500000, + "output_price": 27500000, + "cache_read_price": 550000, + "cache_write_price": 6875000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-opus-5", + "input_price": 5500000, + "output_price": 27500000, + "cache_read_price": 550000, + "cache_write_price": 6875000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "input_price": 3300000, + "output_price": 16500000, + "cache_read_price": 330000, + "cache_write_price": 4125000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-sonnet-4-6", + "input_price": 3300000, + "output_price": 16500000, + "cache_read_price": 330000, + "cache_write_price": 4125000 + }, + { + "provider": "bedrock", + "model": "eu.anthropic.claude-sonnet-5", + "input_price": 2200000, + "output_price": 11000000, + "cache_read_price": 220000, + "cache_write_price": 2750000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-fable-5", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-opus-4-5-20251101-v1:0", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-opus-4-6-v1", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-opus-4-7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-opus-4-8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-sonnet-4-6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "global.anthropic.claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "bedrock", + "model": "google.gemma-3-12b-it", + "input_price": 50000, + "output_price": 100000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "google.gemma-3-27b-it", + "input_price": 120000, + "output_price": 200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "google.gemma-3-4b-it", + "input_price": 40000, + "output_price": 80000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "jp.anthropic.claude-haiku-4-5-20251001-v1:0", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "bedrock", + "model": "jp.anthropic.claude-opus-4-7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "jp.anthropic.claude-opus-4-8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "jp.anthropic.claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "jp.anthropic.claude-sonnet-4-6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "jp.anthropic.claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "bedrock", + "model": "meta.llama3-1-70b-instruct-v1:0", + "input_price": 720000, + "output_price": 720000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "meta.llama3-1-8b-instruct-v1:0", + "input_price": 220000, + "output_price": 220000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "meta.llama3-3-70b-instruct-v1:0", + "input_price": 720000, + "output_price": 720000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "meta.llama4-maverick-17b-instruct-v1:0", + "input_price": 240000, + "output_price": 970000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "meta.llama4-scout-17b-instruct-v1:0", + "input_price": 170000, + "output_price": 660000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "minimax.minimax-m2", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "minimax.minimax-m2.1", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "minimax.minimax-m2.5", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.devstral-2-123b", + "input_price": 400000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.magistral-small-2509", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.ministral-3-14b-instruct", + "input_price": 200000, + "output_price": 200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.ministral-3-3b-instruct", + "input_price": 100000, + "output_price": 100000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.ministral-3-8b-instruct", + "input_price": 150000, + "output_price": 150000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.mistral-large-3-675b-instruct", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.pixtral-large-2502-v1:0", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.voxtral-mini-3b-2507", + "input_price": 40000, + "output_price": 40000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "mistral.voxtral-small-24b-2507", + "input_price": 150000, + "output_price": 350000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "moonshot.kimi-k2-thinking", + "input_price": 600000, + "output_price": 2500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "moonshotai.kimi-k2.5", + "input_price": 600000, + "output_price": 3000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "nvidia.nemotron-nano-12b-v2", + "input_price": 200000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "nvidia.nemotron-nano-3-30b", + "input_price": 60000, + "output_price": 240000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "nvidia.nemotron-nano-9b-v2", + "input_price": 60000, + "output_price": 230000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "nvidia.nemotron-super-3-120b", + "input_price": 150000, + "output_price": 650000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "openai.gpt-5.4", + "input_price": 2750000, + "output_price": 16500000, + "cache_read_price": 275000, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "openai.gpt-5.5", + "input_price": 5500000, + "output_price": 33000000, + "cache_read_price": 550000, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "openai.gpt-5.6-luna", + "input_price": 220000, + "output_price": 1320000, + "cache_read_price": 22000, + "cache_write_price": 275000 + }, + { + "provider": "bedrock", + "model": "openai.gpt-5.6-sol", + "input_price": 5500000, + "output_price": 33000000, + "cache_read_price": 550000, + "cache_write_price": 6880000 + }, + { + "provider": "bedrock", + "model": "openai.gpt-5.6-terra", + "input_price": 2200000, + "output_price": 13200000, + "cache_read_price": 220000, + "cache_write_price": 2750000 + }, + { + "provider": "bedrock", + "model": "openai.gpt-oss-120b", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "openai.gpt-oss-120b-1:0", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "openai.gpt-oss-20b", + "input_price": 70000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "openai.gpt-oss-20b-1:0", + "input_price": 70000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "openai.gpt-oss-safeguard-120b", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "openai.gpt-oss-safeguard-20b", + "input_price": 70000, + "output_price": 200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "qwen.qwen3-235b-a22b-2507-v1:0", + "input_price": 220000, + "output_price": 880000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "qwen.qwen3-32b-v1:0", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "qwen.qwen3-coder-30b-a3b-v1:0", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "qwen.qwen3-coder-480b-a35b-v1:0", + "input_price": 220000, + "output_price": 1800000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "qwen.qwen3-coder-next", + "input_price": 220000, + "output_price": 1800000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "qwen.qwen3-next-80b-a3b", + "input_price": 140000, + "output_price": 1400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "qwen.qwen3-vl-235b-a22b", + "input_price": 300000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-fable-5", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-opus-4-1-20250805-v1:0", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-opus-4-5-20251101-v1:0", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-opus-4-6-v1", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-opus-4-7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-opus-4-8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-sonnet-4-6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "bedrock", + "model": "us.anthropic.claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "bedrock", + "model": "us.deepseek.r1-v1:0", + "input_price": 1350000, + "output_price": 5400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "us.meta.llama4-maverick-17b-instruct-v1:0", + "input_price": 240000, + "output_price": 970000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "us.meta.llama4-scout-17b-instruct-v1:0", + "input_price": 170000, + "output_price": 660000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "writer.palmyra-x4-v1:0", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "writer.palmyra-x5-v1:0", + "input_price": 600000, + "output_price": 6000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "xai.grok-4.3", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "zai.glm-4.7", + "input_price": 600000, + "output_price": 2200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "zai.glm-4.7-flash", + "input_price": 70000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "bedrock", + "model": "zai.glm-5", + "input_price": 1000000, + "output_price": 3200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "claude-fable-5", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "copilot", + "model": "claude-haiku-4.5", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "copilot", + "model": "claude-opus-4.5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "copilot", + "model": "claude-opus-4.6", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "copilot", + "model": "claude-opus-4.7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "copilot", + "model": "claude-opus-4.8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "copilot", + "model": "claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "copilot", + "model": "claude-sonnet-4", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "copilot", + "model": "claude-sonnet-4.5", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "copilot", + "model": "claude-sonnet-4.6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "copilot", + "model": "claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "copilot", + "model": "gemini-3.1-pro-preview", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gemini-3.5-flash", + "input_price": 1500000, + "output_price": 9000000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gemini-3.6-flash", + "input_price": 1500000, + "output_price": 7500000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-4.1", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.2", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.2-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.3-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.4", + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.4-mini", + "input_price": 750000, + "output_price": 4500000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.4-nano", + "input_price": 200000, + "output_price": 1250000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.5", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.6-luna", + "input_price": 200000, + "output_price": 1200000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "gpt-5.6-sol", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "copilot", + "model": "gpt-5.6-terra", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "grok-4.5", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "kimi-k2.7-code", + "input_price": 950000, + "output_price": 4000000, + "cache_read_price": 190000, + "cache_write_price": null + }, + { + "provider": "copilot", + "model": "mai-code-1-flash-picker", + "input_price": 750000, + "output_price": 4500000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "deep-research-max-preview-04-2026", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "deep-research-preview-04-2026", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.0-flash", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.0-flash-lite", + "input_price": 75000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.5-computer-use-preview-10-2025", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.5-flash", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.5-flash-image", + "input_price": 300000, + "output_price": 30000000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.5-flash-lite", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 10000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.5-flash-preview-tts", + "input_price": 500000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.5-pro", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-2.5-pro-preview-tts", + "input_price": 1000000, + "output_price": 20000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3-flash-preview", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3-pro-image", + "input_price": 2000000, + "output_price": 120000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3-pro-image-preview", + "input_price": 2000000, + "output_price": 120000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3-pro-preview", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-flash-image", + "input_price": 500000, + "output_price": 60000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-flash-image-preview", + "input_price": 500000, + "output_price": 60000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-flash-lite", + "input_price": 250000, + "output_price": 1500000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-flash-lite-image", + "input_price": 250000, + "output_price": 30000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-flash-lite-preview", + "input_price": 250000, + "output_price": 1500000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-flash-live-preview", + "input_price": 750000, + "output_price": 4500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-flash-tts-preview", + "input_price": 1000000, + "output_price": 20000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-pro-preview", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.1-pro-preview-customtools", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.5-flash", + "input_price": 1500000, + "output_price": 9000000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.5-flash-lite", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.5-live-translate-preview", + "input_price": 3500000, + "output_price": 21000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-3.6-flash", + "input_price": 1500000, + "output_price": 7500000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-embedding-001", + "input_price": 150000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-embedding-2", + "input_price": 200000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-flash-latest", + "input_price": 1500000, + "output_price": 9000000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-flash-lite-latest", + "input_price": 250000, + "output_price": 1500000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-omni-flash-preview", + "input_price": 1500000, + "output_price": 17500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "gemini-robotics-er-1.6-preview", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "lyria-3-clip-preview", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "google", + "model": "lyria-3-pro-preview", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-3.5-turbo", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": 0, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4", + "input_price": 30000000, + "output_price": 60000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4-turbo", + "input_price": 10000000, + "output_price": 30000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4.1", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_price": 400000, + "output_price": 1600000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4.1-nano", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o-2024-05-13", + "input_price": 5000000, + "output_price": 15000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o-2024-08-06", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o-2024-11-20", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-4o-mini", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5-nano", + "input_price": 50000, + "output_price": 400000, + "cache_read_price": 5000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5-pro", + "input_price": 15000000, + "output_price": 120000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.1", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.2", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.2-chat-latest", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.2-pro", + "input_price": 21000000, + "output_price": 168000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.3-chat-latest", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.3-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.3-codex-spark", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.4", + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.4-mini", + "input_price": 750000, + "output_price": 4500000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.4-nano", + "input_price": 200000, + "output_price": 1250000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.4-pro", + "input_price": 30000000, + "output_price": 180000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.5", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.5-pro", + "input_price": 30000000, + "output_price": 180000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-5.6", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openai", + "model": "gpt-5.6-luna", + "input_price": 200000, + "output_price": 1200000, + "cache_read_price": 20000, + "cache_write_price": 250000 + }, + { + "provider": "openai", + "model": "gpt-5.6-sol", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openai", + "model": "gpt-5.6-terra", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "openai", + "model": "gpt-image-2", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "gpt-realtime-2.1", + "input_price": 4000000, + "output_price": 24000000, + "cache_read_price": 400000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o1", + "input_price": 15000000, + "output_price": 60000000, + "cache_read_price": 7500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o1-pro", + "input_price": 150000000, + "output_price": 600000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o3", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o3-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 550000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o3-pro", + "input_price": 20000000, + "output_price": 80000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "o4-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 275000, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "text-embedding-3-large", + "input_price": 130000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "text-embedding-3-small", + "input_price": 20000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openai", + "model": "text-embedding-ada-002", + "input_price": 100000, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "ai21/jamba-large-1.7", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "aion-labs/aion-2.0", + "input_price": 800000, + "output_price": 1600000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "aion-labs/aion-3.0", + "input_price": 3000000, + "output_price": 6000000, + "cache_read_price": 750000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "aion-labs/aion-3.0-mini", + "input_price": 700000, + "output_price": 1400000, + "cache_read_price": 180000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "aion-labs/aion-rp-llama-3.1-8b", + "input_price": 800000, + "output_price": 1600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "allenai/olmo-3-32b-think", + "input_price": 150000, + "output_price": 500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "amazon/nova-2-lite-v1", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "amazon/nova-lite-v1", + "input_price": 60000, + "output_price": 240000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "amazon/nova-micro-v1", + "input_price": 35000, + "output_price": 140000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "amazon/nova-premier-v1", + "input_price": 2500000, + "output_price": 12500000, + "cache_read_price": 625000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "amazon/nova-pro-v1", + "input_price": 800000, + "output_price": 3200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "anthracite-org/magnum-v4-72b", + "input_price": 3000000, + "output_price": 5000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "anthropic/claude-3-haiku", + "input_price": 250000, + "output_price": 1250000, + "cache_read_price": 30000, + "cache_write_price": 300000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-fable-5", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-haiku-4.5", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-4", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.1", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.6", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.7-fast", + "input_price": 30000000, + "output_price": 150000000, + "cache_read_price": 3000000, + "cache_write_price": 37500000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-4.8-fast", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-opus-5-fast", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.5", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "openrouter", + "model": "arcee-ai/trinity-large-thinking", + "input_price": 220000, + "output_price": 850000, + "cache_read_price": 60000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "arcee-ai/virtuoso-large", + "input_price": 750000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "baidu/ernie-4.5-vl-424b-a47b", + "input_price": 420000, + "output_price": 1250000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "bytedance-seed/seed-1.6", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "bytedance-seed/seed-1.6-flash", + "input_price": 75000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "bytedance-seed/seed-2.0-lite", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "bytedance-seed/seed-2.0-mini", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "bytedance/ui-tars-1.5-7b", + "input_price": 100000, + "output_price": 200000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "cognitivecomputations/dolphin-mistral-24b-venice-edition", + "input_price": 200000, + "output_price": 900000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "cohere/command-a", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "cohere/command-r-08-2024", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "cohere/command-r-plus-08-2024", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "cohere/command-r7b-12-2024", + "input_price": 37500, + "output_price": 150000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "cohere/north-mini-code:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepcogito/cogito-v2.1-671b", + "input_price": 1250000, + "output_price": 1250000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-chat", + "input_price": 257400, + "output_price": 1028700, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-chat-v3-0324", + "input_price": 270000, + "output_price": 1120000, + "cache_read_price": 135000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-chat-v3.1", + "input_price": 250000, + "output_price": 950000, + "cache_read_price": 130000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-r1", + "input_price": 700000, + "output_price": 2500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-r1-0528", + "input_price": 500000, + "output_price": 2150000, + "cache_read_price": 350000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-r1-distill-llama-70b", + "input_price": 800000, + "output_price": 800000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-v3.1-terminus", + "input_price": 270000, + "output_price": 1000000, + "cache_read_price": 135000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-v3.2", + "input_price": 269000, + "output_price": 400000, + "cache_read_price": 134500, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-v3.2-exp", + "input_price": 270000, + "output_price": 410000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-v4-flash", + "input_price": 140000, + "output_price": 280000, + "cache_read_price": 28000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-v4-flash-0731", + "input_price": 90000, + "output_price": 180000, + "cache_read_price": 18000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "deepseek/deepseek-v4-pro", + "input_price": 435000, + "output_price": 870000, + "cache_read_price": 3625, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemini-2.5-flash", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 30000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemini-2.5-flash-image", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 30000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemini-2.5-flash-lite", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 10000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemini-2.5-pro", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "google/gemini-2.5-pro-preview", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "google/gemini-2.5-pro-preview-05-06", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "google/gemini-3-flash-preview", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": 50000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemini-3-pro-image", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "google/gemini-3-pro-image-preview", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "google/gemini-3.1-flash-image", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemini-3.1-flash-image-preview", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemini-3.1-flash-lite", + "input_price": 250000, + "output_price": 1500000, + "cache_read_price": 25000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemini-3.1-flash-lite-image", + "input_price": 250000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemini-3.1-flash-lite-preview", + "input_price": 250000, + "output_price": 1500000, + "cache_read_price": 25000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemini-3.1-pro-preview", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "google/gemini-3.1-pro-preview-customtools", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "google/gemini-3.5-flash", + "input_price": 1500000, + "output_price": 9000000, + "cache_read_price": 150000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemini-3.5-flash-lite", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 30000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "input_price": 1500000, + "output_price": 7500000, + "cache_read_price": 150000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "google/gemma-2-27b-it", + "input_price": 650000, + "output_price": 650000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemma-3-12b-it", + "input_price": 50000, + "output_price": 150000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemma-3-27b-it", + "input_price": 80000, + "output_price": 450000, + "cache_read_price": 40000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemma-3-4b-it", + "input_price": 50000, + "output_price": 100000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemma-3n-e4b-it", + "input_price": 60000, + "output_price": 120000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemma-4-26b-a4b-it", + "input_price": 70000, + "output_price": 340000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemma-4-26b-a4b-it:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemma-4-31b-it", + "input_price": 100000, + "output_price": 340000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/gemma-4-31b-it:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/lyria-3-clip-preview", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "google/lyria-3-pro-preview", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "gryphe/mythomax-l2-13b", + "input_price": 80000, + "output_price": 110000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "ibm-granite/granite-4.0-h-micro", + "input_price": 17000, + "output_price": 112000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "ibm-granite/granite-4.1-8b", + "input_price": 50000, + "output_price": 100000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "inception/mercury-2", + "input_price": 250000, + "output_price": 750000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "inclusionai/ling-2.6-1t", + "input_price": 75000, + "output_price": 625000, + "cache_read_price": 15000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "inclusionai/ling-2.6-flash", + "input_price": 10000, + "output_price": 30000, + "cache_read_price": 2000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "inclusionai/ling-3.0-flash:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "inclusionai/ring-2.6-1t", + "input_price": 75000, + "output_price": 625000, + "cache_read_price": 15000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "kwaipilot/kat-coder-air-v2.5", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "kwaipilot/kat-coder-pro-v2", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 60000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "kwaipilot/kat-coder-pro-v2.5", + "input_price": 740000, + "output_price": 2960000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mancer/weaver", + "input_price": 500000, + "output_price": 750000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meituan/longcat-2.0", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 6000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta-llama/llama-3.1-70b-instruct", + "input_price": 400000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta-llama/llama-3.1-8b-instruct", + "input_price": 50000, + "output_price": 80000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta-llama/llama-3.2-1b-instruct", + "input_price": 27000, + "output_price": 201000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta-llama/llama-3.2-3b-instruct", + "input_price": 50000, + "output_price": 330000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta-llama/llama-3.3-70b-instruct", + "input_price": 100000, + "output_price": 320000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta-llama/llama-4-maverick", + "input_price": 200000, + "output_price": 800000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta-llama/llama-4-scout", + "input_price": 100000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta-llama/llama-guard-4-12b", + "input_price": 180000, + "output_price": 180000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "meta/muse-spark-1.1", + "input_price": 1250000, + "output_price": 4250000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "microsoft/phi-4", + "input_price": 70000, + "output_price": 140000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "microsoft/wizardlm-2-8x22b", + "input_price": 620000, + "output_price": 620000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "minimax/minimax-01", + "input_price": 200000, + "output_price": 1100000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "minimax/minimax-m1", + "input_price": 550000, + "output_price": 2200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "minimax/minimax-m2", + "input_price": 255000, + "output_price": 1020000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "minimax/minimax-m2-her", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "minimax/minimax-m2.1", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "minimax/minimax-m2.5", + "input_price": 150000, + "output_price": 900000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "minimax/minimax-m2.7", + "input_price": 250000, + "output_price": 1000000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "minimax/minimax-m3", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 60000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/codestral-2508", + "input_price": 300000, + "output_price": 900000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/ministral-14b-2512", + "input_price": 200000, + "output_price": 200000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/ministral-3b-2512", + "input_price": 100000, + "output_price": 100000, + "cache_read_price": 10000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/ministral-8b-2512", + "input_price": 150000, + "output_price": 150000, + "cache_read_price": 15000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-large", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-large-2407", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-large-2512", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-medium-3", + "input_price": 400000, + "output_price": 2000000, + "cache_read_price": 40000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-medium-3-5", + "input_price": 1500000, + "output_price": 7500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-medium-3.1", + "input_price": 400000, + "output_price": 2000000, + "cache_read_price": 40000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-nemo", + "input_price": 19000, + "output_price": 30000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-saba", + "input_price": 200000, + "output_price": 600000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-small-24b-instruct-2501", + "input_price": 50000, + "output_price": 80000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-small-2603", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 15000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-small-3.1-24b-instruct", + "input_price": 351000, + "output_price": 555000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mistral-small-3.2-24b-instruct", + "input_price": 75000, + "output_price": 200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/mixtral-8x22b-instruct", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "mistralai/voxtral-small-24b-2507", + "input_price": 100000, + "output_price": 300000, + "cache_read_price": 10000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "moonshotai/kimi-k2", + "input_price": 570000, + "output_price": 2300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "moonshotai/kimi-k2-0905", + "input_price": 600000, + "output_price": 2500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "moonshotai/kimi-k2-thinking", + "input_price": 600000, + "output_price": 2500000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "moonshotai/kimi-k2.5", + "input_price": 570000, + "output_price": 2850000, + "cache_read_price": 95000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "moonshotai/kimi-k2.6", + "input_price": 589000, + "output_price": 2480000, + "cache_read_price": 99200, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "moonshotai/kimi-k2.7-code", + "input_price": 730000, + "output_price": 3500000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "moonshotai/kimi-k3", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "morph/morph-v3-fast", + "input_price": 800000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "morph/morph-v3-large", + "input_price": 900000, + "output_price": 1900000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nex-agi/nex-n2-mini", + "input_price": 25000, + "output_price": 100000, + "cache_read_price": 2500, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nex-agi/nex-n2-pro", + "input_price": 250000, + "output_price": 1000000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nousresearch/hermes-3-llama-3.1-405b", + "input_price": 1000000, + "output_price": 1000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nousresearch/hermes-3-llama-3.1-70b", + "input_price": 700000, + "output_price": 700000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nousresearch/hermes-4-405b", + "input_price": 1000000, + "output_price": 3000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nousresearch/hermes-4-70b", + "input_price": 130000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3-nano-30b-a3b", + "input_price": 50000, + "output_price": 200000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3-nano-30b-a3b:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3-super-120b-a12b", + "input_price": 85000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3-super-120b-a12b:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3-ultra-550b-a55b", + "input_price": 600000, + "output_price": 3600000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3-ultra-550b-a55b:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-3.5-content-safety:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-nano-12b-v2-vl:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "nvidia/nemotron-nano-9b-v2:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-3.5-turbo", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-3.5-turbo-0613", + "input_price": 1000000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-3.5-turbo-16k", + "input_price": 3000000, + "output_price": 4000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-3.5-turbo-instruct", + "input_price": 1500000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4", + "input_price": 30000000, + "output_price": 60000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4-turbo", + "input_price": 10000000, + "output_price": 30000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4-turbo-preview", + "input_price": 10000000, + "output_price": 30000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4.1", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4.1-mini", + "input_price": 400000, + "output_price": 1600000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4.1-nano", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4o", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4o-2024-05-13", + "input_price": 5000000, + "output_price": 15000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4o-2024-08-06", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4o-2024-11-20", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4o-mini", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-4o-mini-2024-07-18", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5-image", + "input_price": 10000000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5-image-mini", + "input_price": 2500000, + "output_price": 2000000, + "cache_read_price": 250000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5-nano", + "input_price": 50000, + "output_price": 400000, + "cache_read_price": 5000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5-pro", + "input_price": 15000000, + "output_price": 120000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.1", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.1-codex", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 130000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.1-codex-max", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.1-codex-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.2", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.2-chat", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.2-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.2-pro", + "input_price": 21000000, + "output_price": 168000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.3-chat", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.3-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.4", + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.4-image-2", + "input_price": 8000000, + "output_price": 15000000, + "cache_read_price": 2000000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.4-mini", + "input_price": 750000, + "output_price": 4500000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.4-nano", + "input_price": 200000, + "output_price": 1250000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.4-pro", + "input_price": 30000000, + "output_price": 180000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.5", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.5-pro", + "input_price": 30000000, + "output_price": 180000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.6-luna", + "input_price": 100000, + "output_price": 600000, + "cache_read_price": 10000, + "cache_write_price": 125000 + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.6-luna-pro", + "input_price": 100000, + "output_price": 600000, + "cache_read_price": 10000, + "cache_write_price": 125000 + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.6-sol", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.6-sol-pro", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.6-terra", + "input_price": 1000000, + "output_price": 6000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.6-terra-pro", + "input_price": 1000000, + "output_price": 6000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "openrouter", + "model": "openai/gpt-audio", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-audio-mini", + "input_price": 600000, + "output_price": 2400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-chat-latest", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-oss-120b", + "input_price": 37000, + "output_price": 170000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-oss-20b", + "input_price": 30000, + "output_price": 130000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-oss-20b:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/gpt-oss-safeguard-20b", + "input_price": 75000, + "output_price": 300000, + "cache_read_price": 37500, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/o1", + "input_price": 15000000, + "output_price": 60000000, + "cache_read_price": 7500000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/o1-pro", + "input_price": 150000000, + "output_price": 600000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/o3", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/o3-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 550000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/o3-mini-high", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 550000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/o3-pro", + "input_price": 20000000, + "output_price": 80000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/o4-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 275000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openai/o4-mini-high", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 275000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "openrouter/free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "perceptron/perceptron-mk1", + "input_price": 150000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "perplexity/sonar", + "input_price": 1000000, + "output_price": 1000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "perplexity/sonar-deep-research", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "perplexity/sonar-pro", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "perplexity/sonar-pro-search", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "perplexity/sonar-reasoning-pro", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "poolside/laguna-s-2.1", + "input_price": 90000, + "output_price": 180000, + "cache_read_price": 9000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "poolside/laguna-s-2.1:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "poolside/laguna-xs-2.1", + "input_price": 60000, + "output_price": 120000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "poolside/laguna-xs-2.1:free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen-2.5-72b-instruct", + "input_price": 360000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen-2.5-7b-instruct", + "input_price": 100000, + "output_price": 200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen-2.5-coder-32b-instruct", + "input_price": 660000, + "output_price": 1000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen-plus", + "input_price": 260000, + "output_price": 780000, + "cache_read_price": 52000, + "cache_write_price": 325000 + }, + { + "provider": "openrouter", + "model": "qwen/qwen-plus-2025-07-28", + "input_price": 260000, + "output_price": 780000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen-plus-2025-07-28:thinking", + "input_price": 400000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": 500000 + }, + { + "provider": "openrouter", + "model": "qwen/qwen2.5-vl-72b-instruct", + "input_price": 250000, + "output_price": 750000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-14b", + "input_price": 227500, + "output_price": 910000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-235b-a22b", + "input_price": 455000, + "output_price": 1820000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-235b-a22b-2507", + "input_price": 149500, + "output_price": 598000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-235b-a22b-thinking-2507", + "input_price": 230000, + "output_price": 2300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-30b-a3b", + "input_price": 120000, + "output_price": 500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-30b-a3b-instruct-2507", + "input_price": 48150, + "output_price": 193050, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-30b-a3b-thinking-2507", + "input_price": 200000, + "output_price": 2400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-32b", + "input_price": 80000, + "output_price": 280000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-8b", + "input_price": 117000, + "output_price": 455000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-coder", + "input_price": 300000, + "output_price": 1000000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-coder-30b-a3b-instruct", + "input_price": 70000, + "output_price": 270000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-coder-flash", + "input_price": 195000, + "output_price": 975000, + "cache_read_price": 39000, + "cache_write_price": 243750 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-coder-next", + "input_price": 120000, + "output_price": 800000, + "cache_read_price": 70000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-coder-plus", + "input_price": 650000, + "output_price": 3250000, + "cache_read_price": 130000, + "cache_write_price": 812500 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-max", + "input_price": 780000, + "output_price": 3900000, + "cache_read_price": 156000, + "cache_write_price": 975000 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-max-thinking", + "input_price": 780000, + "output_price": 3900000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-next-80b-a3b-instruct", + "input_price": 90000, + "output_price": 1100000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-next-80b-a3b-thinking", + "input_price": 150000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-vl-235b-a22b-instruct", + "input_price": 210000, + "output_price": 1900000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-vl-235b-a22b-thinking", + "input_price": 980000, + "output_price": 3950000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-vl-30b-a3b-instruct", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-vl-30b-a3b-thinking", + "input_price": 200000, + "output_price": 2400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-vl-32b-instruct", + "input_price": 104000, + "output_price": 416000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-vl-8b-instruct", + "input_price": 117000, + "output_price": 455000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3-vl-8b-thinking", + "input_price": 180000, + "output_price": 2100000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.5-122b-a10b", + "input_price": 260000, + "output_price": 2080000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.5-27b", + "input_price": 195000, + "output_price": 1560000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.5-35b-a3b", + "input_price": 140000, + "output_price": 1000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.5-397b-a17b", + "input_price": 390000, + "output_price": 2340000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.5-9b", + "input_price": 100000, + "output_price": 150000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.5-flash-02-23", + "input_price": 65000, + "output_price": 260000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.5-plus-02-15", + "input_price": 260000, + "output_price": 1560000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.5-plus-20260420", + "input_price": 300000, + "output_price": 1800000, + "cache_read_price": null, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.6-27b", + "input_price": 289000, + "output_price": 2400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.6-35b-a3b", + "input_price": 140000, + "output_price": 1000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.6-flash", + "input_price": 187500, + "output_price": 1125000, + "cache_read_price": null, + "cache_write_price": 234375 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.6-max-preview", + "input_price": 1027000, + "output_price": 6162000, + "cache_read_price": null, + "cache_write_price": 1283750 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.6-plus", + "input_price": 325000, + "output_price": 1950000, + "cache_read_price": null, + "cache_write_price": 406250 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.7-flash", + "input_price": 30000, + "output_price": 130000, + "cache_read_price": 6000, + "cache_write_price": 38000 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.7-max", + "input_price": 1475000, + "output_price": 4425000, + "cache_read_price": 295000, + "cache_write_price": 1843750 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.7-plus", + "input_price": 320000, + "output_price": 1280000, + "cache_read_price": 64000, + "cache_write_price": 400000 + }, + { + "provider": "openrouter", + "model": "qwen/qwen3.8-max", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 250000, + "cache_write_price": 2500000 + }, + { + "provider": "openrouter", + "model": "rekaai/reka-edge", + "input_price": 100000, + "output_price": 100000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "rekaai/reka-flash-3", + "input_price": 100000, + "output_price": 200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "relace/relace-apply-3", + "input_price": 850000, + "output_price": 1250000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "relace/relace-search", + "input_price": 1000000, + "output_price": 3000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "sakana/fugu-ultra", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "sao10k/l3-lunaris-8b", + "input_price": 40000, + "output_price": 50000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "sao10k/l3.1-euryale-70b", + "input_price": 850000, + "output_price": 850000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "sao10k/l3.3-euryale-70b", + "input_price": 650000, + "output_price": 750000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "stepfun/step-3.5-flash", + "input_price": 100000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "stepfun/step-3.7-flash", + "input_price": 200000, + "output_price": 1150000, + "cache_read_price": 40000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "tencent/hunyuan-a13b-instruct", + "input_price": 140000, + "output_price": 570000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "tencent/hy3", + "input_price": 132000, + "output_price": 528000, + "cache_read_price": 33000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "tencent/hy3-preview", + "input_price": 63000, + "output_price": 210000, + "cache_read_price": 21000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "thedrummer/cydonia-24b-v4.1", + "input_price": 300000, + "output_price": 500000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "thedrummer/rocinante-12b", + "input_price": 250000, + "output_price": 500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "thedrummer/skyfall-36b-v2", + "input_price": 550000, + "output_price": 800000, + "cache_read_price": 250000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "thedrummer/unslopnemo-12b", + "input_price": 400000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "thinkingmachines/inkling", + "input_price": 1000000, + "output_price": 4050000, + "cache_read_price": 170000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "thinkingmachines/inkling-small", + "input_price": 500000, + "output_price": 1200000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "undi95/remm-slerp-l2-13b", + "input_price": 450000, + "output_price": 650000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "upstage/solar-pro-3", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 15000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "writer/palmyra-x5", + "input_price": 600000, + "output_price": 6000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "x-ai/grok-4.20", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "x-ai/grok-4.20-multi-agent", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "x-ai/grok-4.3", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "x-ai/grok-4.5", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 300000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "x-ai/grok-build-0.1", + "input_price": 1000000, + "output_price": 2000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "xiaomi/mimo-v2.5", + "input_price": 140000, + "output_price": 280000, + "cache_read_price": 2800, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "xiaomi/mimo-v2.5-pro", + "input_price": 435000, + "output_price": 870000, + "cache_read_price": 3600, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-4.5", + "input_price": 600000, + "output_price": 2200000, + "cache_read_price": 110000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-4.5-air", + "input_price": 130000, + "output_price": 850000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-4.5v", + "input_price": 600000, + "output_price": 1800000, + "cache_read_price": 110000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-4.6", + "input_price": 500000, + "output_price": 2000000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-4.6v", + "input_price": 300000, + "output_price": 900000, + "cache_read_price": 55000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-4.7", + "input_price": 400000, + "output_price": 1750000, + "cache_read_price": 80000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-4.7-flash", + "input_price": 60000, + "output_price": 400000, + "cache_read_price": 10000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-5", + "input_price": 950000, + "output_price": 2550000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-5-turbo", + "input_price": 1200000, + "output_price": 4000000, + "cache_read_price": 240000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-5.1", + "input_price": 966000, + "output_price": 3036000, + "cache_read_price": 179400, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-5.2", + "input_price": 760000, + "output_price": 2420000, + "cache_read_price": 140000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "z-ai/glm-5v-turbo", + "input_price": 1200000, + "output_price": 4000000, + "cache_read_price": 240000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "~anthropic/claude-fable-latest", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "openrouter", + "model": "~anthropic/claude-haiku-latest", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "openrouter", + "model": "~anthropic/claude-opus-latest", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "~anthropic/claude-sonnet-latest", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "openrouter", + "model": "~deepseek/deepseek-v4-flash-latest", + "input_price": 90000, + "output_price": 180000, + "cache_read_price": 18000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "~google/gemini-flash-latest", + "input_price": 1500000, + "output_price": 7500000, + "cache_read_price": 150000, + "cache_write_price": 83333 + }, + { + "provider": "openrouter", + "model": "~google/gemini-pro-latest", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 375000 + }, + { + "provider": "openrouter", + "model": "~moonshotai/kimi-latest", + "input_price": 2900000, + "output_price": 14000000, + "cache_read_price": 290000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "~openai/gpt-latest", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "openrouter", + "model": "~openai/gpt-mini-latest", + "input_price": 750000, + "output_price": 4500000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "openrouter", + "model": "~x-ai/grok-latest", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 300000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen-3-14b", + "input_price": 120000, + "output_price": 240000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen-3-235b", + "input_price": 220000, + "output_price": 880000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen-3-30b", + "input_price": 120000, + "output_price": 500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen-3-32b", + "input_price": 160000, + "output_price": 640000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen-3.6-max-preview", + "input_price": 1300000, + "output_price": 7800000, + "cache_read_price": 260000, + "cache_write_price": 1625000 + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-235b-a22b-thinking", + "input_price": 400000, + "output_price": 4000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-coder", + "input_price": 1500000, + "output_price": 7500000, + "cache_read_price": 300000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-coder-30b-a3b", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-coder-next", + "input_price": 500000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-coder-plus", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-max", + "input_price": 1200000, + "output_price": 6000000, + "cache_read_price": 240000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-max-preview", + "input_price": 1200000, + "output_price": 6000000, + "cache_read_price": 240000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-max-thinking", + "input_price": 1200000, + "output_price": 6000000, + "cache_read_price": 240000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-next-80b-a3b-instruct", + "input_price": 150000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-next-80b-a3b-thinking", + "input_price": 150000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-vl-235b-a22b-instruct", + "input_price": 400000, + "output_price": 1600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-vl-instruct", + "input_price": 400000, + "output_price": 1600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3-vl-thinking", + "input_price": 400000, + "output_price": 4000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.5-flash", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 1000, + "cache_write_price": 125000 + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.5-plus", + "input_price": 400000, + "output_price": 2400000, + "cache_read_price": 40000, + "cache_write_price": 500000 + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.6-27b", + "input_price": 600000, + "output_price": 3600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.6-plus", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": 100000, + "cache_write_price": 625000 + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.7-flash", + "input_price": 30000, + "output_price": 130000, + "cache_read_price": 6000, + "cache_write_price": 38000 + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.7-max", + "input_price": 2500000, + "output_price": 7500000, + "cache_read_price": 500000, + "cache_write_price": 3125000 + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.7-plus", + "input_price": 400000, + "output_price": 1600000, + "cache_read_price": 80000, + "cache_write_price": 500000 + }, + { + "provider": "vercel", + "model": "alibaba/qwen3.8-max", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 250000, + "cache_write_price": 2500000 + }, + { + "provider": "vercel", + "model": "amazon/nova-2-lite", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "amazon/nova-lite", + "input_price": 60000, + "output_price": 240000, + "cache_read_price": 15000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "amazon/nova-micro", + "input_price": 35000, + "output_price": 140000, + "cache_read_price": 8750, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "amazon/nova-pro", + "input_price": 800000, + "output_price": 3200000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "anthropic/claude-3-haiku", + "input_price": 250000, + "output_price": 1250000, + "cache_read_price": 30000, + "cache_write_price": 300000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-fable-5", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-haiku-4.5", + "input_price": 1000000, + "output_price": 5000000, + "cache_read_price": 100000, + "cache_write_price": 1250000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-4", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-4.1", + "input_price": 15000000, + "output_price": 75000000, + "cache_read_price": 1500000, + "cache_write_price": 18750000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-4.5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-4.6", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-4.7", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-4.8", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-4.8-fast", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-5", + "input_price": 5000000, + "output_price": 25000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-opus-5-fast", + "input_price": 10000000, + "output_price": 50000000, + "cache_read_price": 1000000, + "cache_write_price": 12500000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-sonnet-4", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-sonnet-4.5", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-sonnet-4.6", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": 3750000 + }, + { + "provider": "vercel", + "model": "anthropic/claude-sonnet-5", + "input_price": 2000000, + "output_price": 10000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "vercel", + "model": "arcee-ai/trinity-large-thinking", + "input_price": 250000, + "output_price": 900000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "arcee-ai/trinity-mini", + "input_price": 45000, + "output_price": 150000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "bytedance/seed-1.6", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "bytedance/seed-1.8", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "cohere/command-a", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-r1", + "input_price": 1350000, + "output_price": 5400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v3", + "input_price": 270000, + "output_price": 1120000, + "cache_read_price": 135000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v3.1", + "input_price": 250000, + "output_price": 950000, + "cache_read_price": 130000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v3.1-terminus", + "input_price": 270000, + "output_price": 1000000, + "cache_read_price": 135000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v3.2", + "input_price": 280000, + "output_price": 420000, + "cache_read_price": 28000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v3.2-thinking", + "input_price": 620000, + "output_price": 1850000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v4-flash", + "input_price": 200000, + "output_price": 400000, + "cache_read_price": 40000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v4-flash-0731", + "input_price": 130000, + "output_price": 260000, + "cache_read_price": 28000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "deepseek/deepseek-v4-pro", + "input_price": 435000, + "output_price": 870000, + "cache_read_price": 3600, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-2.5-flash", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-2.5-flash-image", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-2.5-flash-lite", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 10000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-2.5-pro", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3-flash", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3-pro-image", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.1-flash-image", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.1-flash-image-preview", + "input_price": 500000, + "output_price": 3000000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.1-flash-lite", + "input_price": 250000, + "output_price": 1500000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.1-flash-lite-image", + "input_price": 250000, + "output_price": 1500000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.1-pro-preview", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.5-flash", + "input_price": 1500000, + "output_price": 9000000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.5-flash-lite", + "input_price": 300000, + "output_price": 2500000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-3.6-flash", + "input_price": 1500000, + "output_price": 7500000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemini-omni-flash-preview", + "input_price": 1500000, + "output_price": 9000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemma-4-26b-a4b-it", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 15000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "google/gemma-4-31b-it", + "input_price": 140000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "inception/mercury-2", + "input_price": 250000, + "output_price": 750000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "inception/mercury-coder-small", + "input_price": 250000, + "output_price": 1000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "inclusionai/ling-3.0-flash-free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "interfaze/interfaze-beta", + "input_price": 1500000, + "output_price": 3500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "kwaipilot/kat-coder-air-v2.5", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "kwaipilot/kat-coder-pro-v1", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 60000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "kwaipilot/kat-coder-pro-v2", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 60000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "kwaipilot/kat-coder-pro-v2.5", + "input_price": 740000, + "output_price": 2960000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "meta/llama-3.1-70b", + "input_price": 720000, + "output_price": 720000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "meta/llama-3.1-8b", + "input_price": 220000, + "output_price": 220000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "meta/llama-3.3-70b", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "meta/llama-4-maverick", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "meta/llama-4-scout", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "meta/muse-spark-1.1", + "input_price": 1250000, + "output_price": 4250000, + "cache_read_price": 150000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "minimax/minimax-m2", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 30000, + "cache_write_price": 375000 + }, + { + "provider": "vercel", + "model": "minimax/minimax-m2.1", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 30000, + "cache_write_price": 375000 + }, + { + "provider": "vercel", + "model": "minimax/minimax-m2.1-lightning", + "input_price": 300000, + "output_price": 2400000, + "cache_read_price": 30000, + "cache_write_price": 375000 + }, + { + "provider": "vercel", + "model": "minimax/minimax-m2.5", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 30000, + "cache_write_price": 375000 + }, + { + "provider": "vercel", + "model": "minimax/minimax-m2.5-highspeed", + "input_price": 600000, + "output_price": 2400000, + "cache_read_price": 30000, + "cache_write_price": 375000 + }, + { + "provider": "vercel", + "model": "minimax/minimax-m2.7", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 60000, + "cache_write_price": 375000 + }, + { + "provider": "vercel", + "model": "minimax/minimax-m2.7-highspeed", + "input_price": 600000, + "output_price": 2400000, + "cache_read_price": 60000, + "cache_write_price": 375000 + }, + { + "provider": "vercel", + "model": "minimax/minimax-m3", + "input_price": 300000, + "output_price": 1200000, + "cache_read_price": 60000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/codestral", + "input_price": 300000, + "output_price": 900000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/devstral-2", + "input_price": 400000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/devstral-small-2", + "input_price": 100000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/magistral-medium", + "input_price": 2000000, + "output_price": 5000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/magistral-small", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/ministral-14b", + "input_price": 200000, + "output_price": 200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/ministral-3b", + "input_price": 40000, + "output_price": 40000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/ministral-8b", + "input_price": 100000, + "output_price": 100000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/mistral-large-3", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/mistral-medium", + "input_price": 400000, + "output_price": 2000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/mistral-medium-3.5", + "input_price": 1500000, + "output_price": 7500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/mistral-nemo", + "input_price": 150000, + "output_price": 150000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/mistral-small", + "input_price": 100000, + "output_price": 300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "mistral/pixtral-12b", + "input_price": 150000, + "output_price": 150000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "moonshotai/kimi-k2", + "input_price": 570000, + "output_price": 2300000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "moonshotai/kimi-k2-thinking", + "input_price": 470000, + "output_price": 2000000, + "cache_read_price": 141000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "moonshotai/kimi-k2.5", + "input_price": 600000, + "output_price": 3000000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "moonshotai/kimi-k2.6", + "input_price": 950000, + "output_price": 4000000, + "cache_read_price": 160000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "moonshotai/kimi-k2.7-code", + "input_price": 950000, + "output_price": 4000000, + "cache_read_price": 190000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "moonshotai/kimi-k2.7-code-highspeed", + "input_price": 1900000, + "output_price": 8000000, + "cache_read_price": 380000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "moonshotai/kimi-k3", + "input_price": 3000000, + "output_price": 15000000, + "cache_read_price": 300000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "moonshotai/kimi-k3-fast", + "input_price": 4500000, + "output_price": 22500000, + "cache_read_price": 450000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "morph/morph-v3-fast", + "input_price": 800000, + "output_price": 1200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "morph/morph-v3-large", + "input_price": 900000, + "output_price": 1900000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "nvidia/nemotron-3-nano-30b-a3b", + "input_price": 50000, + "output_price": 240000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "nvidia/nemotron-3-super-120b-a12b", + "input_price": 150000, + "output_price": 650000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "nvidia/nemotron-3-ultra-550b-a55b", + "input_price": 600000, + "output_price": 2400000, + "cache_read_price": 120000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "nvidia/nemotron-nano-12b-v2-vl", + "input_price": 200000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "nvidia/nemotron-nano-9b-v2", + "input_price": 60000, + "output_price": 230000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-3.5-turbo", + "input_price": 500000, + "output_price": 1500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4-turbo", + "input_price": 10000000, + "output_price": 30000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4.1", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4.1-mini", + "input_price": 400000, + "output_price": 1600000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4.1-nano", + "input_price": 100000, + "output_price": 400000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4o", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4o-mini", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4o-mini-search-preview", + "input_price": 150000, + "output_price": 600000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4o-mini-transcribe", + "input_price": 1250000, + "output_price": 5000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-4o-transcribe", + "input_price": 2500000, + "output_price": 10000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5-codex", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 130000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 25000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5-nano", + "input_price": 50000, + "output_price": 400000, + "cache_read_price": 5000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5-pro", + "input_price": 15000000, + "output_price": 120000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.1-codex", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 130000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.1-codex-max", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.1-codex-mini", + "input_price": 250000, + "output_price": 2000000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.1-instant", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 130000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.1-thinking", + "input_price": 1250000, + "output_price": 10000000, + "cache_read_price": 125000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.2", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.2-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.2-pro", + "input_price": 21000000, + "output_price": 168000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.3-chat", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.3-codex", + "input_price": 1750000, + "output_price": 14000000, + "cache_read_price": 175000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.4", + "input_price": 2500000, + "output_price": 15000000, + "cache_read_price": 250000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.4-mini", + "input_price": 750000, + "output_price": 4500000, + "cache_read_price": 75000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.4-nano", + "input_price": 200000, + "output_price": 1250000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.4-pro", + "input_price": 30000000, + "output_price": 180000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.5", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.5-pro", + "input_price": 30000000, + "output_price": 180000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-5.6-luna", + "input_price": 200000, + "output_price": 1200000, + "cache_read_price": 20000, + "cache_write_price": 250000 + }, + { + "provider": "vercel", + "model": "openai/gpt-5.6-sol", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": 6250000 + }, + { + "provider": "vercel", + "model": "openai/gpt-5.6-terra", + "input_price": 2000000, + "output_price": 12000000, + "cache_read_price": 200000, + "cache_write_price": 2500000 + }, + { + "provider": "vercel", + "model": "openai/gpt-image-1", + "input_price": 5000000, + "output_price": 40000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-image-1-mini", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-image-1.5", + "input_price": 5000000, + "output_price": 32000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-image-2", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 1250000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-oss-120b", + "input_price": 100000, + "output_price": 500000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-oss-20b", + "input_price": 50000, + "output_price": 200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-oss-safeguard-20b", + "input_price": 75000, + "output_price": 300000, + "cache_read_price": 37000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-realtime-1.5", + "input_price": 4000000, + "output_price": 16000000, + "cache_read_price": 400000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-realtime-2", + "input_price": 4000000, + "output_price": 24000000, + "cache_read_price": 400000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-realtime-2.1", + "input_price": 4000000, + "output_price": 24000000, + "cache_read_price": 400000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/gpt-realtime-mini", + "input_price": 600000, + "output_price": 2400000, + "cache_read_price": 60000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/o1", + "input_price": 15000000, + "output_price": 60000000, + "cache_read_price": 7500000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/o3", + "input_price": 2000000, + "output_price": 8000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/o3-deep-research", + "input_price": 10000000, + "output_price": 40000000, + "cache_read_price": 2500000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/o3-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 550000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/o3-pro", + "input_price": 20000000, + "output_price": 80000000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "openai/o4-mini", + "input_price": 1100000, + "output_price": 4400000, + "cache_read_price": 275000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "poolside/laguna-s-2.1", + "input_price": 100000, + "output_price": 200000, + "cache_read_price": 10000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "poolside/laguna-s-2.1-free", + "input_price": 0, + "output_price": 0, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "sakana/fugu-ultra", + "input_price": 5000000, + "output_price": 30000000, + "cache_read_price": 500000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "stepfun/step-3.5-flash", + "input_price": 90000, + "output_price": 300000, + "cache_read_price": 20000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "stepfun/step-3.7-flash", + "input_price": 200000, + "output_price": 1150000, + "cache_read_price": 40000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "tencent/hy3", + "input_price": 140000, + "output_price": 580000, + "cache_read_price": 35000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "thinkingmachines/inkling", + "input_price": 1000000, + "output_price": 4050000, + "cache_read_price": 170000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "thinkingmachines/inkling-small", + "input_price": 500000, + "output_price": 1200000, + "cache_read_price": 100000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.1-fast-non-reasoning", + "input_price": 200000, + "output_price": 500000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.1-fast-reasoning", + "input_price": 200000, + "output_price": 500000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.20-multi-agent", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.20-multi-agent-beta", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.20-non-reasoning", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.20-non-reasoning-beta", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 400000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.20-reasoning", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.20-reasoning-beta", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.3", + "input_price": 1250000, + "output_price": 2500000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-4.5", + "input_price": 2000000, + "output_price": 6000000, + "cache_read_price": 300000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xai/grok-build-0.1", + "input_price": 1000000, + "output_price": 2000000, + "cache_read_price": 200000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xiaomi/mimo-v2.5", + "input_price": 140000, + "output_price": 280000, + "cache_read_price": 2800, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "xiaomi/mimo-v2.5-pro", + "input_price": 435000, + "output_price": 870000, + "cache_read_price": 3600, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-4.5", + "input_price": 600000, + "output_price": 2200000, + "cache_read_price": 110000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-4.5-air", + "input_price": 200000, + "output_price": 1100000, + "cache_read_price": 30000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-4.5v", + "input_price": 600000, + "output_price": 1800000, + "cache_read_price": 110000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-4.6", + "input_price": 600000, + "output_price": 2200000, + "cache_read_price": 110000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-4.6v", + "input_price": 300000, + "output_price": 900000, + "cache_read_price": 50000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-4.7", + "input_price": 600000, + "output_price": 2200000, + "cache_read_price": 120000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-4.7-flash", + "input_price": 70000, + "output_price": 400000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-4.7-flashx", + "input_price": 60000, + "output_price": 400000, + "cache_read_price": 10000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-5", + "input_price": 1000000, + "output_price": 3200000, + "cache_read_price": null, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-5-turbo", + "input_price": 1200000, + "output_price": 4000000, + "cache_read_price": 240000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-5.1", + "input_price": 1400000, + "output_price": 4400000, + "cache_read_price": 260000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-5.2", + "input_price": 1100000, + "output_price": 3851000, + "cache_read_price": 275000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-5.2-fast", + "input_price": 2100000, + "output_price": 6600000, + "cache_read_price": 210000, + "cache_write_price": null + }, + { + "provider": "vercel", + "model": "zai/glm-5v-turbo", + "input_price": 1200000, + "output_price": 4000000, + "cache_read_price": 240000, + "cache_write_price": null } ] diff --git a/coderd/aibridge/prices/prices_test.go b/coderd/aibridge/prices/prices_test.go index 1ce642e20840d..ae30b7e449e84 100644 --- a/coderd/aibridge/prices/prices_test.go +++ b/coderd/aibridge/prices/prices_test.go @@ -1,6 +1,7 @@ package prices_test import ( + "fmt" "testing" "github.com/prometheus/client_golang/prometheus" @@ -58,6 +59,7 @@ func TestSeedFromBytes(t *testing.T) { require.Equal(t, int64(25_000_000), opus.OutputPrice.Int64) require.Equal(t, int64(500_000), opus.CacheReadPrice.Int64) require.Equal(t, int64(6_250_000), opus.CacheWritePrice.Int64) + require.Equal(t, opus.CreatedAt, opus.UpdatedAt) // Spot-check a row where the seed has a NULL price (OpenAI does not // publish a cache_write_price). The column should land as SQL NULL. @@ -90,11 +92,11 @@ func TestSeedFromBytes(t *testing.T) { }) require.NoError(t, err) - // Prices must be identical across runs and CreatedAt must be - // preserved (only updated_at moves on a no-op upsert). + // A re-seed that changes nothing must not touch the row at all. require.Equal(t, first.InputPrice, second.InputPrice) require.Equal(t, first.OutputPrice, second.OutputPrice) require.Equal(t, first.CreatedAt, second.CreatedAt) + require.Equal(t, first.UpdatedAt, second.UpdatedAt) }) t.Run("OverwritesExistingPrices", func(t *testing.T) { @@ -114,6 +116,10 @@ func TestSeedFromBytes(t *testing.T) { "cache_read_price": 3, "cache_write_price": 4 }]`))) + before, err := db.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ + Provider: "openai", Model: "gpt-4o", + }) + require.NoError(t, err) require.NoError(t, prices.SeedFromBytes(ctx, db, []byte(testSeedJSON))) @@ -126,6 +132,8 @@ func TestSeedFromBytes(t *testing.T) { require.Equal(t, int64(1_250_000), got.CacheReadPrice.Int64) require.False(t, got.CacheWritePrice.Valid) require.Zero(t, got.CacheWritePrice.Int64) + require.Equal(t, before.CreatedAt, got.CreatedAt) + require.True(t, got.UpdatedAt.After(before.UpdatedAt)) }) t.Run("LeavesOrphanRowsUntouched", func(t *testing.T) { @@ -174,6 +182,101 @@ func TestSeedFromBytes(t *testing.T) { require.True(t, got.InputPrice.Valid) require.Equal(t, int64(2_500_000), got.InputPrice.Int64) }) + + // Every price column counts toward the comparison, and a NULL on either + // side counts as a difference. + t.Run("UpdatedAtTracksPriceChanges", func(t *testing.T) { + t.Parallel() + + key := database.GetAIModelPriceByProviderModelParams{Provider: "openai", Model: "gpt-4o"} + seed := func(priceFields string) []byte { + return fmt.Appendf(nil, `[{"provider": %q, "model": %q, %s}]`, key.Provider, key.Model, priceFields) + } + + tests := []struct { + name string + initial, updated string + }{ + { + name: "InputPriceChanged", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 111, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + }, + { + name: "InputPriceSetFromNull", + initial: `"input_price": null, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + }, + { + name: "InputPriceClearedToNull", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": null, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + }, + { + name: "OutputPriceChanged", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": 222, "cache_read_price": 300, "cache_write_price": 400`, + }, + { + name: "OutputPriceSetFromNull", + initial: `"input_price": 100, "output_price": null, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + }, + { + name: "OutputPriceClearedToNull", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": null, "cache_read_price": 300, "cache_write_price": 400`, + }, + { + name: "CacheReadPriceChanged", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": 200, "cache_read_price": 333, "cache_write_price": 400`, + }, + { + name: "CacheReadPriceSetFromNull", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": null, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + }, + { + name: "CacheReadPriceClearedToNull", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": 200, "cache_read_price": null, "cache_write_price": 400`, + }, + { + name: "CacheWritePriceChanged", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 444`, + }, + { + name: "CacheWritePriceSetFromNull", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": null`, + updated: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + }, + { + name: "CacheWritePriceClearedToNull", + initial: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": 400`, + updated: `"input_price": 100, "output_price": 200, "cache_read_price": 300, "cache_write_price": null`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + db, _ := dbtestutil.NewDB(t) + + require.NoError(t, prices.SeedFromBytes(ctx, db, seed(tt.initial))) + before, err := db.GetAIModelPriceByProviderModel(ctx, key) + require.NoError(t, err) + + require.NoError(t, prices.SeedFromBytes(ctx, db, seed(tt.updated))) + after, err := db.GetAIModelPriceByProviderModel(ctx, key) + require.NoError(t, err) + + require.True(t, after.UpdatedAt.After(before.UpdatedAt), "updated_at should advance when a price changes") + }) + } + }) } // TestSeed exercises the real embedded prices.json so we catch a corrupted, diff --git a/coderd/aibridged.go b/coderd/aibridged.go index e7c8295fed05f..0ea322e455a19 100644 --- a/coderd/aibridged.go +++ b/coderd/aibridged.go @@ -84,7 +84,7 @@ func (api *API) CreateInMemoryAIBridgeServer(dialCtx context.Context) (client ai if err := aibridgedserver.Register(mux, srv); err != nil { return nil, err } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(api.Logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index c9906bd35aa9c..038286555f756 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -78,6 +78,7 @@ type store interface { // Cost-attribution queries, used to snapshot price and effective group on // each token usage record. GetAIBridgeInterceptionByID(ctx context.Context, id uuid.UUID) (database.AIBridgeInterception, error) + GetAIProviderByName(ctx context.Context, name string) (database.AIProvider, error) GetAIModelPriceByProviderModel(ctx context.Context, arg database.GetAIModelPriceByProviderModelParams) (database.AIModelPrice, error) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAIBudgetOverride, error) GetHighestGroupAIBudgetByUser(ctx context.Context, userID uuid.UUID) (database.GetHighestGroupAIBudgetByUserRow, error) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index f5641a0531cc6..9b63d66b64d31 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -1684,6 +1684,17 @@ func TestRecordTokenUsage(t *testing.T) { now = time.Date(2026, 6, 25, 14, 30, 0, 0, time.UTC) ) + // Budget resolution falls through to the Everyone group, for cases that vary + // only provider resolution. + expectBudgetLookups := func(db *dbmock.MockStore, intc database.AIBridgeInterception) { + db.EXPECT().GetAIBridgeInterceptionByID(gomock.Any(), intc.ID).Return(intc, nil) + db.EXPECT().GetUserAIBudgetOverride(gomock.Any(), intc.InitiatorID). + Return(database.UserAIBudgetOverride{}, sql.ErrNoRows) + db.EXPECT().GetHighestGroupAIBudgetByUser(gomock.Any(), intc.InitiatorID). + Return(database.GetHighestGroupAIBudgetByUserRow{}, sql.ErrNoRows) + db.EXPECT().GetUserEveryoneFallbackGroup(gomock.Any(), intc.InitiatorID).Return(uuid.New(), nil) + } + testRecordMethod(t, func(srv *aibridgedserver.Server, ctx context.Context, req *proto.RecordTokenUsageRequest) (*proto.RecordTokenUsageResponse, error) { return srv.RecordTokenUsage(ctx, req) @@ -1751,7 +1762,7 @@ func TestRecordTokenUsage(t *testing.T) { // A priced model does not increment unpriced_token_usage_records_total. assertMetrics: func(t *testing.T, reg *prometheus.Registry) { require.Nil(t, promhelp.MetricValue(t, reg, "cost_control_unpriced_token_usage_records_total", - prometheus.Labels{"provider": "anthropic", "model": "claude-sonnet-4-6"})) + prometheus.Labels{"provider": "anthropic-eu", "provider_type": "anthropic", "model": "claude-sonnet-4-6"})) }, }, { @@ -1898,7 +1909,7 @@ func TestRecordTokenUsage(t *testing.T) { // A missing price row increments unpriced_token_usage_records_total. assertMetrics: func(t *testing.T, reg *prometheus.Registry) { require.Equal(t, 1, promhelp.CounterValue(t, reg, "cost_control_unpriced_token_usage_records_total", - prometheus.Labels{"provider": "anthropic", "model": "claude-sonnet-4-6"})) + prometheus.Labels{"provider": "anthropic-eu", "provider_type": "anthropic", "model": "claude-sonnet-4-6"})) }, }, { @@ -2078,7 +2089,7 @@ func TestRecordTokenUsage(t *testing.T) { // A missing price row increments unpriced_token_usage_records_total. assertMetrics: func(t *testing.T, reg *prometheus.Registry) { require.Equal(t, 1, promhelp.CounterValue(t, reg, "cost_control_unpriced_token_usage_records_total", - prometheus.Labels{"provider": "anthropic", "model": "claude-sonnet-4-6"})) + prometheus.Labels{"provider": "anthropic-eu", "provider_type": "anthropic", "model": "claude-sonnet-4-6"})) }, }, { @@ -2131,6 +2142,123 @@ func TestRecordTokenUsage(t *testing.T) { db.EXPECT().IncrementUserAIDailySpend(gomock.Any(), gomock.Any()).Times(0) }, }, + { + // An azure provider has the openai upstream wire format but bills at + // its own rates. + name: "openai wire format priced as azure", + request: &proto.RecordTokenUsageRequest{ + InterceptionId: uuid.NewString(), + MsgId: "msg_123", + InputTokens: 100, + CreatedAt: timestamppb.Now(), + }, + setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordTokenUsageRequest) { + interceptionID, err := uuid.Parse(req.GetInterceptionId()) + assert.NoError(t, err, "parse interception UUID") + + intc := newTestInterception(interceptionID) + intc.Provider = "openai" + intc.ProviderName = "azure-prod" + intc.Model = "gpt-5-mini" + expectBudgetLookups(db, intc) + + db.EXPECT().GetAIProviderByName(gomock.Any(), intc.ProviderName). + Return(database.AIProvider{Name: intc.ProviderName, Type: database.AIProviderTypeAzure}, nil) + db.EXPECT().GetAIModelPriceByProviderModel(gomock.Any(), database.GetAIModelPriceByProviderModelParams{ + Provider: string(database.AIProviderTypeAzure), + Model: intc.Model, + }).Return(database.AIModelPrice{ + Provider: string(database.AIProviderTypeAzure), + Model: intc.Model, + InputPrice: sql.NullInt64{Int64: 3_000_000, Valid: true}, + }, nil) + + db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( + func(fn func(database.Store) error, _ *database.TxOptions) error { return fn(db) }, + ) + db.EXPECT().InsertAIBridgeTokenUsage(gomock.Any(), gomock.Cond(func(p database.InsertAIBridgeTokenUsageParams) bool { + return assert.Equal(t, sql.NullInt64{Int64: 3_000_000, Valid: true}, p.InputPriceMicros, "input price") && + assert.Equal(t, sql.NullInt64{Int64: 300, Valid: true}, p.CostMicros, "cost") + })).Return(database.AIBridgeTokenUsage{ID: uuid.New(), InterceptionID: interceptionID}, nil) + db.EXPECT().IncrementUserAIDailySpend(gomock.Any(), gomock.Any()). + Return(database.AIUserDailySpend{}, nil) + }, + }, + { + // A bedrock provider has the anthropic upstream wire format but bills + // at its own rates. + name: "anthropic wire format priced as bedrock", + request: &proto.RecordTokenUsageRequest{ + InterceptionId: uuid.NewString(), + MsgId: "msg_123", + InputTokens: 100, + CreatedAt: timestamppb.Now(), + }, + setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordTokenUsageRequest) { + interceptionID, err := uuid.Parse(req.GetInterceptionId()) + assert.NoError(t, err, "parse interception UUID") + + intc := newTestInterception(interceptionID) + intc.ProviderName = "bedrock-eu" + expectBudgetLookups(db, intc) + + db.EXPECT().GetAIProviderByName(gomock.Any(), intc.ProviderName). + Return(database.AIProvider{Name: intc.ProviderName, Type: database.AIProviderTypeBedrock}, nil) + db.EXPECT().GetAIModelPriceByProviderModel(gomock.Any(), database.GetAIModelPriceByProviderModelParams{ + Provider: string(database.AIProviderTypeBedrock), + Model: intc.Model, + }).Return(database.AIModelPrice{ + Provider: string(database.AIProviderTypeBedrock), + Model: intc.Model, + InputPrice: sql.NullInt64{Int64: 3_000_000, Valid: true}, + }, nil) + + db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( + func(fn func(database.Store) error, _ *database.TxOptions) error { return fn(db) }, + ) + db.EXPECT().InsertAIBridgeTokenUsage(gomock.Any(), gomock.Cond(func(p database.InsertAIBridgeTokenUsageParams) bool { + return assert.Equal(t, sql.NullInt64{Int64: 3_000_000, Valid: true}, p.InputPriceMicros, "input price") && + assert.Equal(t, sql.NullInt64{Int64: 300, Valid: true}, p.CostMicros, "cost") + })).Return(database.AIBridgeTokenUsage{ID: uuid.New(), InterceptionID: interceptionID}, nil) + db.EXPECT().IncrementUserAIDailySpend(gomock.Any(), gomock.Any()). + Return(database.AIUserDailySpend{}, nil) + }, + }, + { + name: "unresolved provider is unpriced", + request: &proto.RecordTokenUsageRequest{ + InterceptionId: uuid.NewString(), + MsgId: "msg_123", + InputTokens: 100, + CreatedAt: timestamppb.Now(), + }, + setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordTokenUsageRequest) { + interceptionID, err := uuid.Parse(req.GetInterceptionId()) + assert.NoError(t, err, "parse interception UUID") + + intc := newTestInterception(interceptionID) + expectBudgetLookups(db, intc) + + db.EXPECT().GetAIProviderByName(gomock.Any(), intc.ProviderName). + Return(database.AIProvider{}, sql.ErrNoRows) + // Without a provider there is nothing to key the price on. + db.EXPECT().GetAIModelPriceByProviderModel(gomock.Any(), gomock.Any()).Times(0) + + db.EXPECT().InTx(gomock.Any(), nil).DoAndReturn( + func(fn func(database.Store) error, _ *database.TxOptions) error { return fn(db) }, + ) + db.EXPECT().InsertAIBridgeTokenUsage(gomock.Any(), gomock.Cond(func(p database.InsertAIBridgeTokenUsageParams) bool { + return assert.False(t, p.InputPriceMicros.Valid, "input price null") && + assert.False(t, p.CostMicros.Valid, "cost null") + })).Return(database.AIBridgeTokenUsage{ID: uuid.New(), InterceptionID: interceptionID}, nil) + db.EXPECT().IncrementUserAIDailySpend(gomock.Any(), gomock.Any()).Times(0) + }, + // The metric names the provider that failed to resolve. + assertMetrics: func(t *testing.T, reg *prometheus.Registry) { + require.Equal(t, 1, promhelp.CounterValue(t, reg, "cost_control_unpriced_token_usage_records_total", + prometheus.Labels{"provider": "anthropic-eu", "provider_type": "unknown", "model": "claude-sonnet-4-6"})) + }, + }, { name: "invalid interception ID", request: &proto.RecordTokenUsageRequest{ @@ -2162,6 +2290,27 @@ func TestRecordTokenUsage(t *testing.T) { }, expectedErr: "get interception", }, + { + name: "provider lookup error", + request: &proto.RecordTokenUsageRequest{ + InterceptionId: uuid.NewString(), + MsgId: "msg_123", + InputTokens: 100, + CreatedAt: timestamppb.Now(), + }, + setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordTokenUsageRequest) { + interceptionID, err := uuid.Parse(req.GetInterceptionId()) + assert.NoError(t, err, "parse interception UUID") + + // An unexpected provider lookup error (not sql.ErrNoRows) fails + // the record. + intc := newTestInterception(interceptionID) + expectBudgetLookups(db, intc) + db.EXPECT().GetAIProviderByName(gomock.Any(), intc.ProviderName). + Return(database.AIProvider{}, sql.ErrConnDone) + }, + expectedErr: "get configured provider", + }, { name: "price lookup error", request: &proto.RecordTokenUsageRequest{ @@ -2185,6 +2334,8 @@ func TestRecordTokenUsage(t *testing.T) { Return(database.GetHighestGroupAIBudgetByUserRow{}, sql.ErrNoRows) db.EXPECT().GetUserEveryoneFallbackGroup(gomock.Any(), intc.InitiatorID). Return(uuid.New(), nil) + db.EXPECT().GetAIProviderByName(gomock.Any(), intc.ProviderName). + Return(database.AIProvider{Name: intc.ProviderName, Type: database.AIProviderTypeAnthropic}, nil) db.EXPECT().GetAIModelPriceByProviderModel(gomock.Any(), gomock.Any()). Return(database.AIModelPrice{}, sql.ErrConnDone) }, @@ -2287,10 +2438,18 @@ func TestRecordTokenUsageAuthorized(t *testing.T) { require.NoError(t, err) require.NoError(t, rawDB.UpsertAIModelPrices(ctx, priceSeed), "seed model prices") + // The interception's provider name resolves to this provider, whose type keys + // the price lookup. + aiProvider := dbgen.AIProvider(t, rawDB, database.AIProvider{ + Name: "anthropic-eu", + Type: database.AIProviderTypeAnthropic, + }) + intc := dbgen.AIBridgeInterception(t, rawDB, database.InsertAIBridgeInterceptionParams{ - InitiatorID: user.ID, - Provider: provider, - Model: model, + InitiatorID: user.ID, + Provider: provider, + ProviderName: aiProvider.Name, + Model: model, }, nil) // Use fixed dates to keep the test deterministic. @@ -2348,6 +2507,152 @@ func TestRecordTokenUsageAuthorized(t *testing.T) { require.Equal(t, wantCost, spend.SpendMicros, "spend micros") } +// TestRecordTokenUsageProviderResolution covers provider resolution against a real +// database through dbauthz, where the live-row filter and name reuse apply. +func TestRecordTokenUsageProviderResolution(t *testing.T) { + t.Parallel() + + const claudeModel, gptModel = "claude-sonnet-4-6", "gpt-5-mini" + const anthropicInputPrice, bedrockInputPrice, openaiInputPrice, azureInputPrice int64 = 2_000_000, 3_000_000, 4_000_000, 5_000_000 + + setupCtx := testutil.Context(t, testutil.WaitLong) + logger := testutil.Logger(t) + + rawDB, _ := dbtestutil.NewDB(t) + authzDB := dbauthz.New(rawDB, rbac.NewStrictAuthorizer(prometheus.NewRegistry()), logger, coderdtest.AccessControlStorePointer()) + + user := dbgen.User(t, rawDB, database.User{}) + + // Prices differ per provider type so the asserted cost identifies which type resolved. + priceSeed, err := json.Marshal([]map[string]any{ + {"provider": string(database.AIProviderTypeAnthropic), "model": claudeModel, "input_price": anthropicInputPrice}, + {"provider": string(database.AIProviderTypeBedrock), "model": claudeModel, "input_price": bedrockInputPrice}, + {"provider": string(database.AIProviderTypeOpenai), "model": gptModel, "input_price": openaiInputPrice}, + {"provider": string(database.AIProviderTypeAzure), "model": gptModel, "input_price": azureInputPrice}, + }) + require.NoError(t, err) + require.NoError(t, rawDB.UpsertAIModelPrices(setupCtx, priceSeed), "seed model prices") + + srv, err := aibridgedserver.NewServer(setupCtx, aibridgedserver.Options{ + Store: authzDB, + AISeatTracker: agplaiseats.Noop{}, + AccessURL: "/", + GatewayCfg: codersdk.AIBridgeConfig{}, + Experiments: requiredExperiments, + Logger: logger, + Clock: quartz.NewReal(), + }) + require.NoError(t, err) + + cases := []struct { + name string + // wireProvider is the upstream wire format recorded on the interception. + wireProvider string + // providerName is the provider instance name recorded on the interception. + providerName string + // providerType is the configured provider type of the live provider. + providerType database.AIProviderType + model string + // setupProvider creates and deletes the case's providers. + setupProvider func(t *testing.T, ctx context.Context, providerName string, providerType database.AIProviderType) + wantInputPrice sql.NullInt64 + wantCost sql.NullInt64 + }{ + { + // The common configuration, where the configured provider type matches + // the upstream wire format. + name: "provider named after its type", + wireProvider: "anthropic", + providerName: "anthropic", + providerType: database.AIProviderTypeAnthropic, + model: claudeModel, + // One live anthropic provider. + setupProvider: func(t *testing.T, _ context.Context, providerName string, providerType database.AIProviderType) { + dbgen.AIProvider(t, rawDB, database.AIProvider{Name: providerName, Type: providerType}) + }, + wantInputPrice: sql.NullInt64{Int64: anthropicInputPrice, Valid: true}, + // 100 input tokens at the anthropic input price: $0.0002. + wantCost: sql.NullInt64{Int64: 200, Valid: true}, + }, + { + name: "priced by configured provider type", + wireProvider: "anthropic", + providerName: "bedrock-eu", + providerType: database.AIProviderTypeBedrock, + model: claudeModel, + // One live bedrock provider. + setupProvider: func(t *testing.T, _ context.Context, providerName string, providerType database.AIProviderType) { + dbgen.AIProvider(t, rawDB, database.AIProvider{Name: providerName, Type: providerType}) + }, + wantInputPrice: sql.NullInt64{Int64: bedrockInputPrice, Valid: true}, + // 100 input tokens at the bedrock input price: $0.0003. + wantCost: sql.NullInt64{Int64: 300, Valid: true}, + }, + { + name: "deleted provider is unpriced", + wireProvider: "anthropic", + providerName: "bedrock-deleted", + providerType: database.AIProviderTypeBedrock, + model: claudeModel, + // One bedrock provider, deleted before the usage is recorded. + setupProvider: func(t *testing.T, ctx context.Context, providerName string, providerType database.AIProviderType) { + provider := dbgen.AIProvider(t, rawDB, database.AIProvider{Name: providerName, Type: providerType}) + require.NoError(t, rawDB.DeleteAIProviderByID(ctx, provider.ID), "delete provider") + }, + wantInputPrice: sql.NullInt64{Valid: false}, + wantCost: sql.NullInt64{Valid: false}, + }, + { + // Names are unique only among live providers, so a deleted name can be + // reused by a provider of a different configured provider type. + name: "reused name resolves to the live provider", + wireProvider: "openai", + providerName: "reused-name", + providerType: database.AIProviderTypeAzure, + model: gptModel, + // A deleted openai provider and a live azure provider sharing the name. + setupProvider: func(t *testing.T, ctx context.Context, providerName string, providerType database.AIProviderType) { + deleted := dbgen.AIProvider(t, rawDB, database.AIProvider{Name: providerName, Type: database.AIProviderTypeOpenai}) + require.NoError(t, rawDB.DeleteAIProviderByID(ctx, deleted.ID), "delete provider") + dbgen.AIProvider(t, rawDB, database.AIProvider{Name: providerName, Type: providerType}) + }, + wantInputPrice: sql.NullInt64{Int64: azureInputPrice, Valid: true}, + // 100 input tokens at the azure input price: $0.0005. + wantCost: sql.NullInt64{Int64: 500, Valid: true}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + tc.setupProvider(t, ctx, tc.providerName, tc.providerType) + + intc := dbgen.AIBridgeInterception(t, rawDB, database.InsertAIBridgeInterceptionParams{ + InitiatorID: user.ID, + Provider: tc.wireProvider, + ProviderName: tc.providerName, + Model: tc.model, + }, nil) + + _, err := srv.RecordTokenUsage(ctx, &proto.RecordTokenUsageRequest{ + InterceptionId: intc.ID.String(), + MsgId: "msg_e2e", + InputTokens: 100, + CreatedAt: timestamppb.Now(), + }) + require.NoError(t, err, "record token usage") + + tokenUsages, err := rawDB.GetAIBridgeTokenUsagesByInterceptionID(ctx, intc.ID) + require.NoError(t, err) + require.Len(t, tokenUsages, 1) + require.Equal(t, tc.wantInputPrice, tokenUsages[0].InputPriceMicros, "input price") + require.Equal(t, tc.wantCost, tokenUsages[0].CostMicros, "cost") + }) + } +} + // TestRecordTokenUsageBudgetNotifications verifies that recording token usage // enqueues the right budget notifications: the warning template when spend // crosses the warning threshold, the limit-reached template at 100%, both when @@ -2900,21 +3205,25 @@ func TestRecordTokenUsageBudgetAdminNotification(t *testing.T) { } // newTestInterception returns an interception with a fixed initiator, provider, -// and model for cost-attribution test setup. +// and model for cost-attribution test setup. The provider name intentionally +// differs from the upstream wire format. func newTestInterception(id uuid.UUID) database.AIBridgeInterception { return database.AIBridgeInterception{ - ID: id, - InitiatorID: uuid.New(), - Provider: "anthropic", - Model: "claude-sonnet-4-6", + ID: id, + InitiatorID: uuid.New(), + Provider: "anthropic", + ProviderName: "anthropic-eu", + Model: "claude-sonnet-4-6", } } // expectTokenUsageCostLookups mocks the store lookups made by resolveTokenUsageCost -// (budget resolution and the price lookup). A nil override, group, everyoneGroupID, or -// price makes that lookup return sql.ErrNoRows. Budget resolution mirrors production code: -// a non-nil override wins and skips the group lookup, and the Everyone fallback is consulted -// only when both override and group are nil. +// (budget resolution, provider resolution, and the price lookup). A nil override, group, +// everyoneGroupID, or price makes that lookup return sql.ErrNoRows. Budget resolution +// mirrors production code: a non-nil override wins and skips the group lookup, and the +// Everyone fallback is consulted only when both override and group are nil. The provider +// name resolves to a provider whose configured provider type equals the interception's +// upstream wire format. func expectTokenUsageCostLookups( db *dbmock.MockStore, intc database.AIBridgeInterception, @@ -2945,6 +3254,11 @@ func expectTokenUsageCostLookups( } } + db.EXPECT().GetAIProviderByName(gomock.Any(), intc.ProviderName).Return(database.AIProvider{ + Name: intc.ProviderName, + Type: database.AIProviderType(intc.Provider), + }, nil) + if price != nil { db.EXPECT().GetAIModelPriceByProviderModel(gomock.Any(), database.GetAIModelPriceByProviderModelParams{ Provider: intc.Provider, diff --git a/coderd/aibridgedserver/cost.go b/coderd/aibridgedserver/cost.go index 9bd280b53c5dd..0470e66b09a12 100644 --- a/coderd/aibridgedserver/cost.go +++ b/coderd/aibridgedserver/cost.go @@ -19,6 +19,10 @@ import ( // tokens. const tokensPerMillion = 1_000_000 +// unknownProviderType labels a metric whose provider did not resolve to a +// configured type. +const unknownProviderType = "unknown" + // tokenUsageCost holds the cost-attribution columns snapshotted onto a token // usage record. A field left unset (Valid == false) is recorded as SQL NULL; a // price or cost of 0 is recorded as 0, which is distinct from NULL. @@ -34,10 +38,11 @@ type tokenUsageCost struct { } // resolveTokenUsageCost resolves the effective group and per-token prices for an -// interception and computes its cost. Two independent conditions yield a NULL +// interception and computes its cost. Three independent conditions yield a NULL // column rather than an error: an unresolved effective group (the user has no -// org membership), and a model absent from the price table leaves prices and -// cost NULL (a NULL cost unambiguously means "model not priced"). +// org membership), an interception whose provider name matches no configured +// provider, and a model absent from the price table. The latter two leave prices +// and cost NULL (a NULL cost unambiguously means "model not priced"). // Any other error is returned. func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBridgeInterception, in *proto.RecordTokenUsageRequest) (tokenUsageCost, error) { var result tokenUsageCost @@ -63,22 +68,40 @@ func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBrid } } + // The interception records one of three upstream wire formats. Prices are + // keyed on the configured provider type, the provider actually serving the + // request, resolved by provider name. Names are unique among live providers. + provider, err := s.store.GetAIProviderByName(ctx, intc.ProviderName) + switch { + case errors.Is(err, sql.ErrNoRows): + // Only reachable if the provider was deleted mid-request. + s.logger.Warn(ctx, "no configured provider found for interception, recording token usage with NULL cost", + slog.F("provider_name", intc.ProviderName), slog.F("model", intc.Model)) + if s.metrics != nil { + s.metrics.UnpricedTokenUsageRecords.WithLabelValues(intc.ProviderName, unknownProviderType, intc.Model).Inc() + } + return result, nil + case err != nil: + return tokenUsageCost{}, xerrors.Errorf("get configured provider %q: %w", intc.ProviderName, err) + } + configuredType := string(provider.Type) + // Snapshot the price for this (provider, model) and compute cost. price, err := s.store.GetAIModelPriceByProviderModel(ctx, database.GetAIModelPriceByProviderModelParams{ - Provider: intc.Provider, + Provider: configuredType, Model: intc.Model, }) switch { case errors.Is(err, sql.ErrNoRows): // Model not in the price table: record tokens but leave cost NULL. - s.logger.Debug(ctx, "no price found for model, recording token usage with NULL cost", - slog.F("provider", intc.Provider), slog.F("model", intc.Model)) + s.logger.Info(ctx, "no price found for model, recording token usage with NULL cost", + slog.F("provider", configuredType), slog.F("model", intc.Model)) if s.metrics != nil { - s.metrics.UnpricedTokenUsageRecords.WithLabelValues(intc.Provider, intc.Model).Inc() + s.metrics.UnpricedTokenUsageRecords.WithLabelValues(intc.ProviderName, configuredType, intc.Model).Inc() } return result, nil case err != nil: - return tokenUsageCost{}, xerrors.Errorf("look up model price for %s/%s: %w", intc.Provider, intc.Model, err) + return tokenUsageCost{}, xerrors.Errorf("look up model price for %s/%s: %w", configuredType, intc.Model, err) } result.inputPriceMicros = price.InputPrice diff --git a/coderd/aibridgedserver/metrics.go b/coderd/aibridgedserver/metrics.go index dfd3cfdf8bcfb..c613b2f2f3e91 100644 --- a/coderd/aibridgedserver/metrics.go +++ b/coderd/aibridgedserver/metrics.go @@ -50,13 +50,13 @@ func NewMetrics(reg prometheus.Registerer) *Metrics { Name: "blocked_users", Help: "The number of users currently over their AI budget.", }, []string{"group_id"}), - // Pessimistic cardinality: 3 providers, 5 models = up to 15. + // Pessimistic cardinality: one series per configured provider and model. UnpricedTokenUsageRecords: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ Subsystem: "cost_control", Name: "unpriced_token_usage_records_total", - Help: "The number of recorded AI token-usage records for which no model price was found " + - "(provider: anthropic, openai, copilot).", - }, []string{"provider", "model"}), + Help: "The number of recorded AI token-usage records for which no (provider_type, model) price was found. " + + "provider is the provider instance name, and provider_type is its configured type.", + }, []string{"provider", "provider_type", "model"}), // Pessimistic cardinality: 3 outcomes, 8 buckets + 3 extra series // (count, sum, +Inf) = up to 33. EnforcementDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 13eea683db243..df3743c73348c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3400,6 +3400,42 @@ const docTemplate = `{ ] } }, + "/api/v2/groups/{group}/ai/spend": { + "get": { + "description": "Returns the AI spend limit and aggregate spend for the group.", + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get group AI spend", + "operationId": "get-group-ai-spend", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GroupAISpend" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/groups/{group}/members": { "get": { "produces": [ @@ -9852,7 +9888,7 @@ const docTemplate = `{ ] } }, - "/api/v2/users/{user}/ai/budget": { + "/api/v2/users/{user}/ai/budget/override": { "get": { "produces": [ "application/json" @@ -15463,7 +15499,7 @@ const docTemplate = `{ } }, "network_calls": { - "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", + "description": "NetworkCalls summarizes the Agent Firewall network requests made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", "allOf": [ { "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" @@ -15499,6 +15535,17 @@ const docTemplate = `{ } } }, + "codersdk.AIBridgeSessionNetworkDomain": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, "codersdk.AIBridgeSessionThreadsResponse": { "type": "object", "properties": { @@ -15525,6 +15572,31 @@ const docTemplate = `{ "type": "string" } }, + "network_call_logs": { + "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, holding the earliest calls up to a server-side cap.\nNetworkCalls remains authoritative for whole-session totals, so a shorter\nlist than NetworkCalls.Total means the list was truncated. Empty when the\nsession did not pass through Agent Firewall.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentFirewallLog" + } + }, + "network_calls": { + "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" + } + ] + }, + "network_domain_count": { + "type": "integer" + }, + "network_top_domains": { + "description": "NetworkTopDomains lists the most contacted destination hosts, ordered by\ncall count descending. NetworkDomainCount is the total number of distinct\ndomains, used to render a \"+N more\" overflow beyond the listed domains.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkDomain" + } + }, "page_ended_at": { "type": "string", "format": "date-time" @@ -15686,6 +15758,17 @@ const docTemplate = `{ } } }, + "codersdk.AIBudgetLimit": { + "type": "object", + "properties": { + "limit_source": { + "$ref": "#/definitions/codersdk.AIBudgetLimitSource" + }, + "spend_limit_micros": { + "type": "integer" + } + } + }, "codersdk.AIBudgetLimitSource": { "type": "string", "enum": [ @@ -15734,17 +15817,6 @@ const docTemplate = `{ } } }, - "codersdk.AIGroupBudget": { - "type": "object", - "properties": { - "limit_source": { - "$ref": "#/definitions/codersdk.AIBudgetLimitSource" - }, - "spend_limit_micros": { - "type": "integer" - } - } - }, "codersdk.AIProvider": { "type": "object", "properties": { @@ -19236,6 +19308,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "env_name": { "type": "string" }, @@ -19801,6 +19876,9 @@ const docTemplate = `{ "disable_path_apps": { "type": "boolean" }, + "disable_workspace_agent_context_sync": { + "type": "boolean" + }, "disable_workspace_sharing": { "type": "boolean" }, @@ -20174,12 +20252,10 @@ const docTemplate = `{ "minimum-implicit-member", "workspace-capable-licensing", "ai-gateway-seat-exclusion", - "ai-gateway-cost-control", "chat-advisor", "chat-virtual-desktop" ], "x-enum-comments": { - "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", "ExperimentAIGatewaySeatExclusion": "Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentChatAdvisor": "Enables the advisor tool for root agent chats.", @@ -20206,7 +20282,6 @@ const docTemplate = `{ "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "Counts only users holding the workspace-create permission toward the license seat limit.", "Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption.", - "Enables AI Gateway cost control functionality.", "Enables the advisor tool for root agent chats.", "Enables virtual desktop and computer use provider for agents." ], @@ -20222,7 +20297,6 @@ const docTemplate = `{ "ExperimentMinimumImplicitMember", "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewaySeatExclusion", - "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop" ] @@ -20642,6 +20716,37 @@ const docTemplate = `{ } } }, + "codersdk.GroupAISpend": { + "type": "object", + "properties": { + "current_spend_micros": { + "description": "CurrentSpendMicros is the group's spend over the current budget period.", + "type": "integer" + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "period_end": { + "description": "PeriodEnd is the exclusive upper bound of the current budget\nperiod.", + "type": "string", + "format": "date-time" + }, + "period_start": { + "description": "PeriodStart is the inclusive lower bound of the current budget\nperiod.", + "type": "string", + "format": "date-time" + }, + "spend_limit_micros": { + "description": "SpendLimitMicros is the group's configured AI spend budget per member.\nNull when the group has no configured budget.", + "type": "integer" + }, + "total_spend_limit_micros": { + "description": "TotalSpendLimitMicros is the currently configured combined budget of the\nmembers attributed to this group, with each member's override replacing\ntheir share. Null when the group has no budget, and zero when no members\nare attributed to it.", + "type": "integer" + } + } + }, "codersdk.GroupMemberAISpend": { "type": "object", "properties": { @@ -20654,7 +20759,7 @@ const docTemplate = `{ "description": "GroupBudget is the budget when the queried group is this user's\neffective budget source. Null when the user's budget resolves to another\ngroup or no budget applies to the user.", "allOf": [ { - "$ref": "#/definitions/codersdk.AIGroupBudget" + "$ref": "#/definitions/codersdk.AIBudgetLimit" } ] }, @@ -22117,7 +22222,7 @@ const docTemplate = `{ "type": "object", "properties": { "current_spend_micros": { - "description": "CurrentSpendMicros is the group's spend over the current budget\nperiod.", + "description": "CurrentSpendMicros is the group's spend over the current budget period.", "type": "integer" }, "group_id": { @@ -22125,7 +22230,11 @@ const docTemplate = `{ "format": "uuid" }, "spend_limit_micros": { - "description": "SpendLimitMicros is the group's configured AI spend limit. Null when\nthe group has no configured budget.", + "description": "SpendLimitMicros is the group's configured AI spend budget per member.\nNull when the group has no configured budget.", + "type": "integer" + }, + "total_spend_limit_micros": { + "description": "TotalSpendLimitMicros is the currently configured combined budget of the\nmembers attributed to this group, with each member's override replacing\ntheir share. Null when the group has no budget, and zero when no members\nare attributed to it.", "type": "integer" } } @@ -25984,6 +26093,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "env_name": { "type": "string" }, @@ -26119,6 +26231,7 @@ const docTemplate = `{ "type": "object", "properties": { "spend_limit_micros": { + "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.", "type": "integer", "minimum": 0 } @@ -26136,6 +26249,7 @@ const docTemplate = `{ "format": "uuid" }, "spend_limit_micros": { + "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.", "type": "integer", "minimum": 0 } @@ -26326,19 +26440,19 @@ const docTemplate = `{ "description": "CurrentSpendMicros is the user's spend on their effective group over\nthe current budget period.", "type": "integer" }, - "effective_group_id": { - "description": "EffectiveGroupID is the group the spend is attributed to, falling back to\nthe Everyone group when no budget applies. Null only when the user has no\norganization membership.", - "type": "string", - "format": "uuid" - }, - "limit_source": { - "description": "LimitSource identifies which tier produced the limit. Null when no\nbudget applies.", + "effective_budget": { + "description": "EffectiveBudget is the spend limit that applies to the user, whether it\ncame from a group budget or a user override. Null when no budget\napplies, leaving the user's spend unlimited.", "allOf": [ { - "$ref": "#/definitions/codersdk.AIBudgetLimitSource" + "$ref": "#/definitions/codersdk.AIBudgetLimit" } ] }, + "effective_group_id": { + "description": "EffectiveGroupID is the group the spend is attributed to, falling back to\nthe Everyone group when no budget applies. Null only when the user has no\norganization membership.", + "type": "string", + "format": "uuid" + }, "period_end": { "description": "PeriodEnd is the exclusive upper bound of the current budget\nperiod.", "type": "string", @@ -26349,10 +26463,6 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, - "spend_limit_micros": { - "description": "SpendLimitMicros is the effective spend limit in micro-units.\nNull when no budget applies to the user (unlimited).", - "type": "integer" - }, "user_id": { "type": "string", "format": "uuid" @@ -26592,6 +26702,10 @@ const docTemplate = `{ "description": { "type": "string" }, + "enabled": { + "description": "Enabled controls whether the secret is injected into workspaces.\nDisabled secrets remain visible and editable, but are not added\nto the agent manifest, so they are not exposed as environment\nvariables or written to secret files.", + "type": "boolean" + }, "env_name": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a50b9dabee9bf..556ababc4950e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2999,6 +2999,38 @@ ] } }, + "/api/v2/groups/{group}/ai/spend": { + "get": { + "description": "Returns the AI spend limit and aggregate spend for the group.", + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get group AI spend", + "operationId": "get-group-ai-spend", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Group ID", + "name": "group", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.GroupAISpend" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/groups/{group}/members": { "get": { "produces": ["application/json"], @@ -8739,7 +8771,7 @@ ] } }, - "/api/v2/users/{user}/ai/budget": { + "/api/v2/users/{user}/ai/budget/override": { "get": { "produces": ["application/json"], "tags": ["Enterprise"], @@ -13771,7 +13803,7 @@ } }, "network_calls": { - "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", + "description": "NetworkCalls summarizes the Agent Firewall network requests made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", "allOf": [ { "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" @@ -13807,6 +13839,17 @@ } } }, + "codersdk.AIBridgeSessionNetworkDomain": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, "codersdk.AIBridgeSessionThreadsResponse": { "type": "object", "properties": { @@ -13833,6 +13876,31 @@ "type": "string" } }, + "network_call_logs": { + "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, holding the earliest calls up to a server-side cap.\nNetworkCalls remains authoritative for whole-session totals, so a shorter\nlist than NetworkCalls.Total means the list was truncated. Empty when the\nsession did not pass through Agent Firewall.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentFirewallLog" + } + }, + "network_calls": { + "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" + } + ] + }, + "network_domain_count": { + "type": "integer" + }, + "network_top_domains": { + "description": "NetworkTopDomains lists the most contacted destination hosts, ordered by\ncall count descending. NetworkDomainCount is the total number of distinct\ndomains, used to render a \"+N more\" overflow beyond the listed domains.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkDomain" + } + }, "page_ended_at": { "type": "string", "format": "date-time" @@ -13994,6 +14062,17 @@ } } }, + "codersdk.AIBudgetLimit": { + "type": "object", + "properties": { + "limit_source": { + "$ref": "#/definitions/codersdk.AIBudgetLimitSource" + }, + "spend_limit_micros": { + "type": "integer" + } + } + }, "codersdk.AIBudgetLimitSource": { "type": "string", "enum": ["user_override", "group"], @@ -14039,17 +14118,6 @@ } } }, - "codersdk.AIGroupBudget": { - "type": "object", - "properties": { - "limit_source": { - "$ref": "#/definitions/codersdk.AIBudgetLimitSource" - }, - "spend_limit_micros": { - "type": "integer" - } - } - }, "codersdk.AIProvider": { "type": "object", "properties": { @@ -17407,6 +17475,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "env_name": { "type": "string" }, @@ -17956,6 +18027,9 @@ "disable_path_apps": { "type": "boolean" }, + "disable_workspace_agent_context_sync": { + "type": "boolean" + }, "disable_workspace_sharing": { "type": "boolean" }, @@ -18322,12 +18396,10 @@ "minimum-implicit-member", "workspace-capable-licensing", "ai-gateway-seat-exclusion", - "ai-gateway-cost-control", "chat-advisor", "chat-virtual-desktop" ], "x-enum-comments": { - "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", "ExperimentAIGatewaySeatExclusion": "Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentChatAdvisor": "Enables the advisor tool for root agent chats.", @@ -18354,7 +18426,6 @@ "Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts.", "Counts only users holding the workspace-create permission toward the license seat limit.", "Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption.", - "Enables AI Gateway cost control functionality.", "Enables the advisor tool for root agent chats.", "Enables virtual desktop and computer use provider for agents." ], @@ -18370,7 +18441,6 @@ "ExperimentMinimumImplicitMember", "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewaySeatExclusion", - "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop" ] @@ -18790,6 +18860,37 @@ } } }, + "codersdk.GroupAISpend": { + "type": "object", + "properties": { + "current_spend_micros": { + "description": "CurrentSpendMicros is the group's spend over the current budget period.", + "type": "integer" + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "period_end": { + "description": "PeriodEnd is the exclusive upper bound of the current budget\nperiod.", + "type": "string", + "format": "date-time" + }, + "period_start": { + "description": "PeriodStart is the inclusive lower bound of the current budget\nperiod.", + "type": "string", + "format": "date-time" + }, + "spend_limit_micros": { + "description": "SpendLimitMicros is the group's configured AI spend budget per member.\nNull when the group has no configured budget.", + "type": "integer" + }, + "total_spend_limit_micros": { + "description": "TotalSpendLimitMicros is the currently configured combined budget of the\nmembers attributed to this group, with each member's override replacing\ntheir share. Null when the group has no budget, and zero when no members\nare attributed to it.", + "type": "integer" + } + } + }, "codersdk.GroupMemberAISpend": { "type": "object", "properties": { @@ -18802,7 +18903,7 @@ "description": "GroupBudget is the budget when the queried group is this user's\neffective budget source. Null when the user's budget resolves to another\ngroup or no budget applies to the user.", "allOf": [ { - "$ref": "#/definitions/codersdk.AIGroupBudget" + "$ref": "#/definitions/codersdk.AIBudgetLimit" } ] }, @@ -20187,7 +20288,7 @@ "type": "object", "properties": { "current_spend_micros": { - "description": "CurrentSpendMicros is the group's spend over the current budget\nperiod.", + "description": "CurrentSpendMicros is the group's spend over the current budget period.", "type": "integer" }, "group_id": { @@ -20195,7 +20296,11 @@ "format": "uuid" }, "spend_limit_micros": { - "description": "SpendLimitMicros is the group's configured AI spend limit. Null when\nthe group has no configured budget.", + "description": "SpendLimitMicros is the group's configured AI spend budget per member.\nNull when the group has no configured budget.", + "type": "integer" + }, + "total_spend_limit_micros": { + "description": "TotalSpendLimitMicros is the currently configured combined budget of the\nmembers attributed to this group, with each member's override replacing\ntheir share. Null when the group has no budget, and zero when no members\nare attributed to it.", "type": "integer" } } @@ -23882,6 +23987,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "env_name": { "type": "string" }, @@ -24013,6 +24121,7 @@ "type": "object", "properties": { "spend_limit_micros": { + "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.", "type": "integer", "minimum": 0 } @@ -24028,6 +24137,7 @@ "format": "uuid" }, "spend_limit_micros": { + "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.", "type": "integer", "minimum": 0 } @@ -24197,19 +24307,19 @@ "description": "CurrentSpendMicros is the user's spend on their effective group over\nthe current budget period.", "type": "integer" }, - "effective_group_id": { - "description": "EffectiveGroupID is the group the spend is attributed to, falling back to\nthe Everyone group when no budget applies. Null only when the user has no\norganization membership.", - "type": "string", - "format": "uuid" - }, - "limit_source": { - "description": "LimitSource identifies which tier produced the limit. Null when no\nbudget applies.", + "effective_budget": { + "description": "EffectiveBudget is the spend limit that applies to the user, whether it\ncame from a group budget or a user override. Null when no budget\napplies, leaving the user's spend unlimited.", "allOf": [ { - "$ref": "#/definitions/codersdk.AIBudgetLimitSource" + "$ref": "#/definitions/codersdk.AIBudgetLimit" } ] }, + "effective_group_id": { + "description": "EffectiveGroupID is the group the spend is attributed to, falling back to\nthe Everyone group when no budget applies. Null only when the user has no\norganization membership.", + "type": "string", + "format": "uuid" + }, "period_end": { "description": "PeriodEnd is the exclusive upper bound of the current budget\nperiod.", "type": "string", @@ -24220,10 +24330,6 @@ "type": "string", "format": "date-time" }, - "spend_limit_micros": { - "description": "SpendLimitMicros is the effective spend limit in micro-units.\nNull when no budget applies to the user (unlimited).", - "type": "integer" - }, "user_id": { "type": "string", "format": "uuid" @@ -24463,6 +24569,10 @@ "description": { "type": "string" }, + "enabled": { + "description": "Enabled controls whether the secret is injected into workspaces.\nDisabled secrets remain visible and editable, but are not added\nto the agent manifest, so they are not exposed as environment\nvariables or written to secret files.", + "type": "boolean" + }, "env_name": { "type": "string" }, diff --git a/coderd/coderd.go b/coderd/coderd.go index b01efaf9cd2d8..d6cd586ec2ea1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -2622,7 +2622,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n if err != nil { return nil, err } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 7ab7ca8fcc86a..cf96de00b0863 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -1598,10 +1598,11 @@ func RequestExternalAuthCallback(t testing.TB, providerID string, client *coders // NewGoogleInstanceIdentity returns a metadata client and ID token validator for faking // instance authentication for Google Cloud. // nolint:revive -func NewGoogleInstanceIdentity(t testing.TB, instanceID string, expired bool) (*idtoken.Validator, *metadata.Client) { +func NewGoogleInstanceIdentity(t testing.TB, instanceID string, expired bool, audience string) (*idtoken.Validator, *metadata.Client) { keyID, err := cryptorand.String(12) require.NoError(t, err) claims := jwt.MapClaims{ + "aud": audience, "google": map[string]interface{}{ "compute_engine": map[string]string{ "instance_id": instanceID, diff --git a/coderd/coderdtest/coderdtest_test.go b/coderd/coderdtest/coderdtest_test.go index e245898de6c42..aa776b84fea12 100644 --- a/coderd/coderdtest/coderdtest_test.go +++ b/coderd/coderdtest/coderdtest_test.go @@ -28,7 +28,7 @@ func TestNew(t *testing.T) { workspace := coderdtest.CreateWorkspace(t, client, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - _, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false) + _, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false, "coder") _, _ = coderdtest.NewAWSInstanceIdentity(t, "an-instance") } diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 224928f2c1156..dbf0debfcab50 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -56,6 +56,7 @@ const ( CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys + CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills CheckUserSkillsNameFormat CheckConstraint = "user_skills_name_format" // user_skills diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index d0f94b71d1bc4..b966afd71c426 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1142,33 +1142,43 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess return session } +// AIBridgeSessionThreadsParams groups the session row and its subresources for +// AIBridgeSessionThreads. Named fields avoid transposing the several adjacent +// slice arguments (notably TopDomains and NetworkCalls) at the call site. +type AIBridgeSessionThreadsParams struct { + Session database.ListAIBridgeSessionsRow + Interceptions []database.ListAIBridgeSessionThreadsRow + TokenUsages []database.AIBridgeTokenUsage + ToolUsages []database.AIBridgeToolUsage + UserPrompts []database.AIBridgeUserPrompt + ModelThoughts []database.AIBridgeModelThought + TopDomains []database.GetAIBridgeSessionTopDomainsRow + NetworkCalls []database.BoundaryLog +} + // AIBridgeSessionThreads converts session metadata and thread interceptions // into the threads response. It groups interceptions into threads, builds // agentic actions from tool usages and model thoughts, and aggregates // token usage with metadata. -func AIBridgeSessionThreads( - session database.ListAIBridgeSessionsRow, - interceptions []database.ListAIBridgeSessionThreadsRow, - tokenUsages []database.AIBridgeTokenUsage, - toolUsages []database.AIBridgeToolUsage, - userPrompts []database.AIBridgeUserPrompt, - modelThoughts []database.AIBridgeModelThought, -) codersdk.AIBridgeSessionThreadsResponse { +func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSessionThreadsResponse { + session := p.Session + interceptions := p.Interceptions + // Index subresources by interception ID. tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions)) - for _, tu := range tokenUsages { + for _, tu := range p.TokenUsages { tokensByInterception[tu.InterceptionID] = append(tokensByInterception[tu.InterceptionID], tu) } toolsByInterception := make(map[uuid.UUID][]database.AIBridgeToolUsage, len(interceptions)) - for _, tu := range toolUsages { + for _, tu := range p.ToolUsages { toolsByInterception[tu.InterceptionID] = append(toolsByInterception[tu.InterceptionID], tu) } promptsByInterception := make(map[uuid.UUID][]database.AIBridgeUserPrompt, len(interceptions)) - for _, up := range userPrompts { + for _, up := range p.UserPrompts { promptsByInterception[up.InterceptionID] = append(promptsByInterception[up.InterceptionID], up) } thoughtsByInterception := make(map[uuid.UUID][]database.AIBridgeModelThought, len(interceptions)) - for _, mt := range modelThoughts { + for _, mt := range p.ModelThoughts { thoughtsByInterception[mt.InterceptionID] = append(thoughtsByInterception[mt.InterceptionID], mt) } @@ -1206,7 +1216,7 @@ func AIBridgeSessionThreads( // Aggregate session-level token usage metadata from all token // usages in the session (not just the page). - sessionTokenMeta := aggregateTokenMetadata(tokenUsages) + sessionTokenMeta := aggregateTokenMetadata(p.TokenUsages) resp := codersdk.AIBridgeSessionThreadsResponse{ ID: session.SessionID, @@ -1243,9 +1253,52 @@ func AIBridgeSessionThreads( if !session.EndedAt.IsZero() { resp.EndedAt = &session.EndedAt } + // NetworkCalls is only meaningful when the session passed through Agent + // Firewall. When it did not, leave it nil so the UI renders "Disabled" + // rather than a misleading zero count. + if session.FirewallActive { + resp.NetworkCalls = &codersdk.AIBridgeSessionNetworkCallSummary{ + Total: session.NetworkCallsTotal, + Blocked: session.NetworkCallsBlocked, + } + } + for _, d := range p.TopDomains { + resp.NetworkTopDomains = append(resp.NetworkTopDomains, codersdk.AIBridgeSessionNetworkDomain{ + Domain: d.Domain, + Count: d.Count, + }) + // TotalDomains is the same on every row (a window aggregate); take it + // from the last row processed. + resp.NetworkDomainCount = d.TotalDomains + } + resp.NetworkCallLogs = AgentFirewallLogs(p.NetworkCalls) return resp } +// AgentFirewallLogs converts boundary logs to their SDK representation. +// Allowed is derived from MatchedRule being non-NULL. +func AgentFirewallLogs(logs []database.BoundaryLog) []codersdk.AgentFirewallLog { + results := make([]codersdk.AgentFirewallLog, 0, len(logs)) + for _, l := range logs { + bl := codersdk.AgentFirewallLog{ + ID: l.ID, + SessionID: l.SessionID, + SequenceNumber: l.SequenceNumber, + Allowed: l.MatchedRule.Valid, + CreatedAt: l.CreatedAt, + Proto: l.Proto, + Method: l.Method, + Detail: l.Detail, + CapturedAt: &l.CapturedAt, + } + if l.MatchedRule.Valid { + bl.MatchedRule = &l.MatchedRule.String + } + results = append(results, bl) + } + return results +} + func buildAIBridgeThread( threadID uuid.UUID, interceptions []database.AIBridgeInterception, @@ -1475,6 +1528,9 @@ func OrganizationGroupAISpend(row database.GetOrganizationGroupsAISpendRow) code if row.SpendLimitMicros.Valid { group.SpendLimitMicros = &row.SpendLimitMicros.Int64 } + if row.TotalSpendLimitMicros.Valid { + group.TotalSpendLimitMicros = &row.TotalSpendLimitMicros.Int64 + } return group } @@ -1487,7 +1543,7 @@ func GroupMemberAISpend(row database.GetGroupMembersAISpendRow) codersdk.GroupMe member.EffectiveGroupID = &row.EffectiveGroupID.UUID } if row.SpendLimitMicros.Valid { - member.GroupBudget = &codersdk.AIGroupBudget{ + member.GroupBudget = &codersdk.AIBudgetLimit{ SpendLimitMicros: row.SpendLimitMicros.Int64, LimitSource: codersdk.AIBudgetLimitSource(row.LimitSource.String), } @@ -2098,6 +2154,7 @@ func UserSecret(secret database.ListUserSecretsRow) codersdk.UserSecret { Description: secret.Description, EnvName: secret.EnvName, FilePath: secret.FilePath, + Enabled: secret.Enabled, CreatedAt: secret.CreatedAt, UpdatedAt: secret.UpdatedAt, } @@ -2112,6 +2169,7 @@ func UserSecretFromFull(secret database.UserSecret) codersdk.UserSecret { Description: secret.Description, EnvName: secret.EnvName, FilePath: secret.FilePath, + Enabled: secret.Enabled, CreatedAt: secret.CreatedAt, UpdatedAt: secret.UpdatedAt, } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5b8036958c54f..50db6bd437465 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2138,6 +2138,13 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } +func (q *querier) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg database.DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { + return 0, err + } + return q.db.DeleteCachedModuleFilesCreatedBetween(ctx, arg) +} + func (q *querier) DeleteChatContextResourcesByChatID(ctx context.Context, chatID uuid.UUID) error { chat, err := q.db.GetChatByID(ctx, chatID) if err != nil { @@ -2806,6 +2813,13 @@ func (q *querier) GetAIBridgeInterceptions(ctx context.Context) ([]database.AIBr return fetchWithPostFilter(q.auth, policy.ActionRead, fetch)(ctx, nil) } +func (q *querier) GetAIBridgeSessionTopDomains(ctx context.Context, arg database.GetAIBridgeSessionTopDomainsParams) ([]database.GetAIBridgeSessionTopDomainsRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { + return nil, err + } + return q.db.GetAIBridgeSessionTopDomains(ctx, arg) +} + func (q *querier) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]database.AIBridgeTokenUsage, error) { // All aibridge_token_usages records belong to the initiator of their associated interception. if err := q.authorizeAIBridgeInterceptionAction(ctx, policy.ActionRead, interceptionID); err != nil { @@ -6797,6 +6811,13 @@ func (q *querier) ListAIBridgeModels(ctx context.Context, arg database.ListAIBri return q.db.ListAuthorizedAIBridgeModels(ctx, arg, prep) } +func (q *querier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { + return nil, err + } + return q.db.ListAIBridgeSessionNetworkCalls(ctx, arg) +} + func (q *querier) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index ef0e45eaeb055..893999a21fb54 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -5364,6 +5364,11 @@ func (s *MethodTestSuite) TestSystemFunctions() { dbm.EXPECT().DeleteOldWorkspaceAgentLogs(gomock.Any(), t).Return(int64(0), nil).AnyTimes() check.Args(t).Asserts(rbac.ResourceSystem, policy.ActionDelete) })) + s.Run("DeleteCachedModuleFilesCreatedBetween", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + arg := database.DeleteCachedModuleFilesCreatedBetweenParams{} + dbm.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), arg).Return(int64(0), nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionDelete) + })) s.Run("InsertWorkspaceAgentStats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.InsertWorkspaceAgentStatsParams{} dbm.EXPECT().InsertWorkspaceAgentStats(gomock.Any(), arg).Return(xerrors.New("any error")).AnyTimes() @@ -6897,6 +6902,18 @@ func (s *MethodTestSuite) TestAIBridge() { check.Args(params, emptyPreparedAuthorized{}).Asserts() })) + s.Run("GetAIBridgeSessionTopDomains", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.GetAIBridgeSessionTopDomainsParams{SessionID: "sess", Limit: 5} + db.EXPECT().GetAIBridgeSessionTopDomains(gomock.Any(), params).Return([]database.GetAIBridgeSessionTopDomainsRow{}, nil).AnyTimes() + check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.GetAIBridgeSessionTopDomainsRow{}) + })) + + s.Run("ListAIBridgeSessionNetworkCalls", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.ListAIBridgeSessionNetworkCallsParams{SessionID: "sess", Limit: 1000} + db.EXPECT().ListAIBridgeSessionNetworkCalls(gomock.Any(), params).Return([]database.BoundaryLog{}, nil).AnyTimes() + check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.BoundaryLog{}) + })) + s.Run("ListAIBridgeTokenUsagesByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { ids := []uuid.UUID{{1}} db.EXPECT().ListAIBridgeTokenUsagesByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeTokenUsage{}, nil).AnyTimes() diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 9cdad7e8e82ec..98c8718697e63 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1962,6 +1962,7 @@ func UserSecret(t testing.TB, db database.Store, seed database.UserSecret, mutat ValueKeyID: seed.ValueKeyID, EnvName: takeFirst(seed.EnvName, "SECRET_ENV_NAME"), FilePath: takeFirst(seed.FilePath, "~/secret/file/path"), + Enabled: takeFirst(seed.Enabled, true), } for _, mut := range mutators { mut(¶ms) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index db91a9772b7c5..ac28d72628bf0 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -489,6 +489,14 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return r0 } +func (m queryMetricsStore) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg database.DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.DeleteCachedModuleFilesCreatedBetween(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteCachedModuleFilesCreatedBetween").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteCachedModuleFilesCreatedBetween").Inc() + return r0, r1 +} + func (m queryMetricsStore) DeleteChatContextResourcesByChatID(ctx context.Context, chatID uuid.UUID) error { start := time.Now() r0 := m.s.DeleteChatContextResourcesByChatID(ctx, chatID) @@ -1105,6 +1113,14 @@ func (m queryMetricsStore) GetAIBridgeInterceptions(ctx context.Context) ([]data return r0, r1 } +func (m queryMetricsStore) GetAIBridgeSessionTopDomains(ctx context.Context, arg database.GetAIBridgeSessionTopDomainsParams) ([]database.GetAIBridgeSessionTopDomainsRow, error) { + start := time.Now() + r0, r1 := m.s.GetAIBridgeSessionTopDomains(ctx, arg) + m.queryLatencies.WithLabelValues("GetAIBridgeSessionTopDomains").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIBridgeSessionTopDomains").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]database.AIBridgeTokenUsage, error) { start := time.Now() r0, r1 := m.s.GetAIBridgeTokenUsagesByInterceptionID(ctx, interceptionID) @@ -4745,6 +4761,14 @@ func (m queryMetricsStore) ListAIBridgeModels(ctx context.Context, arg database. return r0, r1 } +func (m queryMetricsStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { + start := time.Now() + r0, r1 := m.s.ListAIBridgeSessionNetworkCalls(ctx, arg) + m.queryLatencies.WithLabelValues("ListAIBridgeSessionNetworkCalls").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeSessionNetworkCalls").Inc() + return r0, r1 +} + func (m queryMetricsStore) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { start := time.Now() r0, r1 := m.s.ListAIBridgeSessionThreads(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index a038a74957f03..be424a0d85d21 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -790,6 +790,21 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } +// DeleteCachedModuleFilesCreatedBetween mocks base method. +func (m *MockStore) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg database.DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCachedModuleFilesCreatedBetween", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteCachedModuleFilesCreatedBetween indicates an expected call of DeleteCachedModuleFilesCreatedBetween. +func (mr *MockStoreMockRecorder) DeleteCachedModuleFilesCreatedBetween(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCachedModuleFilesCreatedBetween", reflect.TypeOf((*MockStore)(nil).DeleteCachedModuleFilesCreatedBetween), ctx, arg) +} + // DeleteChatContextResourcesByChatID mocks base method. func (m *MockStore) DeleteChatContextResourcesByChatID(ctx context.Context, chatID uuid.UUID) error { m.ctrl.T.Helper() @@ -1903,6 +1918,21 @@ func (mr *MockStoreMockRecorder) GetAIBridgeInterceptions(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeInterceptions", reflect.TypeOf((*MockStore)(nil).GetAIBridgeInterceptions), ctx) } +// GetAIBridgeSessionTopDomains mocks base method. +func (m *MockStore) GetAIBridgeSessionTopDomains(ctx context.Context, arg database.GetAIBridgeSessionTopDomainsParams) ([]database.GetAIBridgeSessionTopDomainsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIBridgeSessionTopDomains", ctx, arg) + ret0, _ := ret[0].([]database.GetAIBridgeSessionTopDomainsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIBridgeSessionTopDomains indicates an expected call of GetAIBridgeSessionTopDomains. +func (mr *MockStoreMockRecorder) GetAIBridgeSessionTopDomains(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeSessionTopDomains", reflect.TypeOf((*MockStore)(nil).GetAIBridgeSessionTopDomains), ctx, arg) +} + // GetAIBridgeTokenUsagesByInterceptionID mocks base method. func (m *MockStore) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]database.AIBridgeTokenUsage, error) { m.ctrl.T.Helper() @@ -8876,6 +8906,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeModels(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeModels", reflect.TypeOf((*MockStore)(nil).ListAIBridgeModels), ctx, arg) } +// ListAIBridgeSessionNetworkCalls mocks base method. +func (m *MockStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAIBridgeSessionNetworkCalls", ctx, arg) + ret0, _ := ret[0].([]database.BoundaryLog) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAIBridgeSessionNetworkCalls indicates an expected call of ListAIBridgeSessionNetworkCalls. +func (mr *MockStoreMockRecorder) ListAIBridgeSessionNetworkCalls(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeSessionNetworkCalls", reflect.TypeOf((*MockStore)(nil).ListAIBridgeSessionNetworkCalls), ctx, arg) +} + // ListAIBridgeSessionThreads mocks base method. func (m *MockStore) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index 7c284339d0e45..f84372cde6b2d 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -59,6 +59,20 @@ const ( chatSearchBackfillMaxBatches = 5 ) +// Terraform module archives ingested during this window may contain the +// identified upstream module. +// +// This is a one-off cleanup, not a recurring purge. It runs once per coderd +// process because the window is fixed in the past: after a successful pass +// there is nothing left to match. It lives here rather than in a migration +// because migrations cannot be backported. The version table records a single +// high-water mark, so a migration cherry-picked onto a release branch would +// cause later upgrades to skip every migration in between. +var ( + identifiedModuleCacheStart = time.Date(2026, 8, 31, 8, 0, 0, 0, time.UTC) + identifiedModuleCacheEnd = time.Date(2026, 8, 31, 22, 0, 0, 0, time.UTC) +) + type Option func(*instance) // WithClock overrides the clock used by the purger. Defaults to @@ -181,6 +195,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. chatConfigErr := errors.Join(chatRetentionErr, chatDebugRetentionErr) + // Latched after a successful commit so the one-off module cache cleanup + // is attempted again if the transaction rolls back. + ranModuleCachePurge := false + // Start a transaction to grab advisory lock, we don't want to run // multiple purges at the same time (multiple replicas). err := db.InTx(func(tx database.Store) error { @@ -354,6 +372,20 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. } } + // One-off cleanup of the identified Terraform module cache. Skipped + // once this process has completed a pass. + var purgedIdentifiedModuleFiles int64 + if !i.identifiedModuleCachePurged { + purgedIdentifiedModuleFiles, err = tx.DeleteCachedModuleFilesCreatedBetween(ctx, database.DeleteCachedModuleFilesCreatedBetweenParams{ + CreatedAtAfter: identifiedModuleCacheStart, + CreatedAtBefore: identifiedModuleCacheEnd, + }) + if err != nil { + return xerrors.Errorf("failed to delete identified module cache files: %w", err) + } + ranModuleCachePurge = true + } + i.logger.Debug(ctx, "purged old database entries", slog.F("workspace_agent_logs", purgedWorkspaceAgentLogs), slog.F("expired_api_keys", expiredAPIKeys), @@ -367,6 +399,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. slog.F("chat_files", purgedChatFiles), slog.F("chat_debug_runs", purgedChatDebugRuns), slog.F("chat_search_rows_backfilled", backfilledChatSearchRows), + slog.F("identified_module_files", purgedIdentifiedModuleFiles), slog.F("duration", i.clk.Since(start)), ) @@ -382,6 +415,7 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. i.recordsPurged.WithLabelValues("chats").Add(float64(purgedChats)) i.recordsPurged.WithLabelValues("chat_debug_runs").Add(float64(purgedChatDebugRuns)) i.recordsPurged.WithLabelValues("chat_files").Add(float64(purgedChatFiles)) + i.recordsPurged.WithLabelValues("identified_module_files").Add(float64(purgedIdentifiedModuleFiles)) } if i.chatSearchRowsBackfilled != nil { i.chatSearchRowsBackfilled.Add(float64(backfilledChatSearchRows)) @@ -400,6 +434,10 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. return err } + if ranModuleCachePurge { + i.identifiedModuleCachePurged = true + } + // Surface the deferred chat-config error so doTick records // the failed iteration metric. if chatConfigErr != nil { @@ -420,6 +458,11 @@ type instance struct { chatSearchRowsBackfilled prometheus.Counter chatSearchBackfillBatchSize int32 chatSearchBackfillMaxBatches int + + // identifiedModuleCachePurged latches once this process has completed a + // pass of the one-off module cache cleanup. The window is fixed in the + // past, so a completed pass leaves nothing to match on later ticks. + identifiedModuleCachePurged bool } func (i *instance) Close() error { diff --git a/coderd/database/dbpurge/dbpurge_internal_test.go b/coderd/database/dbpurge/dbpurge_internal_test.go index f49426e9560d2..a7fe08d1ebdf1 100644 --- a/coderd/database/dbpurge/dbpurge_internal_test.go +++ b/coderd/database/dbpurge/dbpurge_internal_test.go @@ -43,3 +43,17 @@ func TestDBPurgeAuthorization(t *testing.T) { err := inst.purgeTick(ctx, db, now) require.NoError(t, err) } + +// The behavior of the one-off module cache cleanup is covered by +// TestDeleteIdentifiedModuleCacheFiles, which supplies its own window. This +// guards the production constants themselves, which that test no longer reads. +func TestIdentifiedModuleCacheWindow(t *testing.T) { + t.Parallel() + + require.True(t, identifiedModuleCacheStart.Before(identifiedModuleCacheEnd), + "window start must precede window end") + require.Equal(t, time.UTC, identifiedModuleCacheStart.Location(), + "window bounds must be UTC so they do not shift with the host timezone") + require.Equal(t, time.UTC, identifiedModuleCacheEnd.Location(), + "window bounds must be UTC so they do not shift with the host timezone") +} diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 25e780ec3d2e3..ce25f7aa63dbd 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "crypto/sha256" "database/sql" "encoding/json" "fmt" @@ -256,6 +257,7 @@ func TestMetrics(t *testing.T) { mDB.EXPECT().DeleteOldWorkspaceBuildOrchestrations(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() + mDB.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteCachedModuleFilesCreatedBetweenParams{})).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatDebugRunsParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { @@ -308,6 +310,7 @@ func TestMetrics(t *testing.T) { mDB.EXPECT().DeleteOldWorkspaceBuildOrchestrations(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() mDB.EXPECT().BackfillChatMessagesSearchTsv(gomock.Any(), gomock.Any()).Return(int64(0), nil).AnyTimes() + mDB.EXPECT().DeleteCachedModuleFilesCreatedBetween(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteCachedModuleFilesCreatedBetweenParams{})).Return(int64(0), nil).AnyTimes() mDB.EXPECT().DeleteOldChats(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatsParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().DeleteOldChatFiles(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatFilesParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). @@ -3236,3 +3239,126 @@ func TestBackfillChatMessagesSearchTsv(t *testing.T) { testutil.TryReceive(ctx, t, done) }) } + +//nolint:paralleltest // It uses LockIDDBPurge. +func TestDeleteIdentifiedModuleCacheFiles(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + clk := quartz.NewMock(t) + clk.Set(dbtime.Now()).MustWait(ctx) + + // The window under test is supplied explicitly rather than copied from the + // production constants, so revising the incident timestamps cannot silently + // invalidate these boundary assertions. + windowStart := time.Date(2026, 8, 31, 8, 0, 0, 0, time.UTC) + windowEnd := time.Date(2026, 8, 31, 22, 0, 0, 0, time.UTC) + inWindow := windowStart.Add(time.Minute) + + db, _ := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID}) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + mkFile := func(name string, createdBy uuid.UUID, mimetype string, createdAt time.Time) database.File { + file, err := db.InsertFile(ctx, database.InsertFileParams{ + ID: uuid.New(), + Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(name))), + CreatedBy: createdBy, + CreatedAt: createdAt, + Mimetype: mimetype, + Data: []byte{}, + }) + require.NoError(t, err, "insert file %q", name) + return file + } + + // mkVersion creates a template version whose cached module files point at a + // file with the given properties. InsertFile is used directly because + // dbgen.File treats uuid.Nil as unset and substitutes a random creator, + // while uuid.Nil is exactly what identifies a provisionerd module archive. + mkVersion := func(name string, createdBy uuid.UUID, mimetype string, createdAt time.Time) (database.File, database.TemplateVersion) { + file := mkFile(name, createdBy, mimetype, createdAt) + tv := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + Name: name, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + _ = dbgen.TemplateVersionTerraformValues(t, db, database.TemplateVersionTerraformValue{ + TemplateVersionID: tv.ID, + CachedModuleFiles: uuid.NullUUID{UUID: file.ID, Valid: true}, + }) + return file, tv + } + + // Identified: a provisionerd module archive cached inside the window. + identified, identifiedTV := mkVersion("identified", uuid.Nil, "application/x-tar", inWindow) + // The lower bound is inclusive. + atStart, atStartTV := mkVersion("at-start", uuid.Nil, "application/x-tar", windowStart) + // The upper bound is exclusive, so this archive is known good. + atEnd, atEndTV := mkVersion("at-end", uuid.Nil, "application/x-tar", windowEnd) + // Cached before and after the window. + before, beforeTV := mkVersion("before", uuid.Nil, "application/x-tar", windowStart.Add(-time.Hour)) + after, afterTV := mkVersion("after", uuid.Nil, "application/x-tar", windowEnd.Add(time.Hour)) + // A user-uploaded template tarball shares the mimetype but has a real + // creator, so it must survive even though it is inside the window. + userUpload, userUploadTV := mkVersion("user-upload", user.ID, "application/x-tar", inWindow) + + // An unreferenced archive inside the window. Only archives referenced by a + // template version are in scope. + orphan := mkFile("orphan", uuid.Nil, "application/x-tar", inWindow) + + // when dbpurge runs + tick := awaitDoTicks(ctx, t, clk, 2) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(clk)) + defer closer.Close() + tick() // doTick() has now run. + + assertFileDeleted := func(id uuid.UUID, name string) { + t.Helper() + _, err := db.GetFileByID(ctx, id) + require.ErrorIs(t, err, sql.ErrNoRows, "%s should be deleted", name) + } + assertFileExists := func(id uuid.UUID, name string) { + t.Helper() + _, err := db.GetFileByID(ctx, id) + require.NoError(t, err, "%s should be retained", name) + } + // assertCacheRef checks the template version still exists and that its + // module cache reference was cleared only when the file was deleted. + assertCacheRef := func(tv database.TemplateVersion, wantFile uuid.UUID, wantValid bool, name string) { + t.Helper() + values, err := db.GetTemplateVersionTerraformValues(ctx, tv.ID) + require.NoError(t, err, "%s: terraform values row must be retained", name) + require.Equal(t, wantValid, values.CachedModuleFiles.Valid, "%s: cache reference validity", name) + if wantValid { + require.Equal(t, wantFile, values.CachedModuleFiles.UUID, "%s: cache reference target", name) + } + } + + // then the identified archives are deleted and their references cleared + assertFileDeleted(identified.ID, "archive inside the window") + assertCacheRef(identifiedTV, uuid.Nil, false, "archive inside the window") + assertFileDeleted(atStart.ID, "archive at the inclusive lower bound") + assertCacheRef(atStartTV, uuid.Nil, false, "archive at the inclusive lower bound") + + // and everything else is untouched + assertFileExists(atEnd.ID, "archive at the exclusive upper bound") + assertCacheRef(atEndTV, atEnd.ID, true, "archive at the exclusive upper bound") + assertFileExists(before.ID, "archive cached before the window") + assertCacheRef(beforeTV, before.ID, true, "archive cached before the window") + assertFileExists(after.ID, "archive cached after the window") + assertCacheRef(afterTV, after.ID, true, "archive cached after the window") + assertFileExists(userUpload.ID, "user-uploaded tarball") + assertCacheRef(userUploadTV, userUpload.ID, true, "user-uploaded tarball") + assertFileExists(orphan.ID, "unreferenced archive") + + // The cleanup is one-off, not a recurring purge. A second tick must not + // repeat it, so an archive inserted into the window after the first pass + // survives. This documents the latch: the window is fixed in the past and + // nothing can legitimately land in it again. + late, lateTV := mkVersion("late", uuid.Nil, "application/x-tar", inWindow) + tick() + assertFileExists(late.ID, "archive inserted after the one-off pass") + assertCacheRef(lateTV, late.ID, true, "archive inserted after the one-off pass") +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 5127d0159d0e4..d99062c3671cf 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3624,7 +3624,9 @@ CREATE TABLE user_secrets ( file_path text DEFAULT ''::text NOT NULL, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, - value_key_id text + value_key_id text, + enabled boolean DEFAULT true NOT NULL, + CONSTRAINT user_secrets_enabled_requires_target CHECK (((NOT enabled) OR (env_name <> ''::text) OR (file_path <> ''::text))) ); CREATE TABLE user_skills ( diff --git a/coderd/database/migrations/000555_legacy_none_login_to_password.down.sql b/coderd/database/migrations/000555_legacy_none_login_to_password.down.sql new file mode 100644 index 0000000000000..b6ae9ef796235 --- /dev/null +++ b/coderd/database/migrations/000555_legacy_none_login_to_password.down.sql @@ -0,0 +1,2 @@ +-- We do not track which users had login_type 'none' before this migration. +-- This is a destructive migration that cannot be undone. diff --git a/coderd/database/migrations/000555_legacy_none_login_to_password.up.sql b/coderd/database/migrations/000555_legacy_none_login_to_password.up.sql new file mode 100644 index 0000000000000..82c13214e7169 --- /dev/null +++ b/coderd/database/migrations/000555_legacy_none_login_to_password.up.sql @@ -0,0 +1,9 @@ +-- Convert legacy users created with login_type 'none' to password auth. +-- OSS deployments cannot create service accounts without Premium. Existing +-- API tokens remain valid; admins can set a password if password login is +-- desired. +UPDATE users +SET login_type = 'password' +WHERE login_type = 'none' + AND is_service_account = false + AND is_system = false; diff --git a/coderd/database/migrations/000556_user_secrets_enabled.down.sql b/coderd/database/migrations/000556_user_secrets_enabled.down.sql new file mode 100644 index 0000000000000..468b6b3bd2600 --- /dev/null +++ b/coderd/database/migrations/000556_user_secrets_enabled.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_secrets + DROP COLUMN enabled; diff --git a/coderd/database/migrations/000556_user_secrets_enabled.up.sql b/coderd/database/migrations/000556_user_secrets_enabled.up.sql new file mode 100644 index 0000000000000..20a11905f7351 --- /dev/null +++ b/coderd/database/migrations/000556_user_secrets_enabled.up.sql @@ -0,0 +1,30 @@ +-- Add an explicit enabled flag to user_secrets. +-- +-- A disabled secret stays visible and editable in the management UI, CLI, +-- and API, but is not injected into workspaces and does not satisfy any +-- "secret present" predicate. This is the single source of truth for +-- "not injected"; the agent manifest layer no longer skips rows based +-- on having both env_name and file_path empty. +-- +-- Existing rows whose env_name and file_path are both empty are flipped +-- to enabled = false. Today those rows are silently skipped during agent +-- manifest assembly, so flipping them preserves observable behavior +-- while letting the manifest stop encoding the both-empty special case. +ALTER TABLE user_secrets + ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true; + +UPDATE user_secrets +SET enabled = false +WHERE env_name = '' AND file_path = ''; + +-- Enforce the injection-target invariant in the database: an enabled +-- secret must have at least one of env_name / file_path non-empty. +-- Disabled secrets may have no targets (bulk imports use that state for +-- keys that cannot be env-injected). The API also checks this on write, +-- but the constraint is the source of truth: it closes a read-modify-write +-- race where two concurrent PATCHes each clear a different target, both +-- pass the API's post-state check, and serialize to an enabled row with +-- no targets. +ALTER TABLE user_secrets + ADD CONSTRAINT user_secrets_enabled_requires_target + CHECK (NOT enabled OR env_name <> '' OR file_path <> ''); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 11897bec83c5e..fdabc3a7df172 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1717,6 +1717,89 @@ func TestMigration000546ChatHistoryAPIKeyConstraints(t *testing.T) { } } +func TestMigration000555LegacyNoneLoginToPassword(t *testing.T) { + t.Parallel() + + const priorMigrationVersion = 554 + + sqlDB := testSQLDB(t) + + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more || version == priorMigrationVersion { + break + } + } + + ctx := testutil.Context(t, testutil.WaitSuperLong) + now := time.Now().UTC().Truncate(time.Microsecond) + + legacyNoneID := uuid.New() + serviceAccountID := uuid.New() + systemID := uuid.New() + passwordID := uuid.New() + + // A legacy machine user: login_type 'none', not a service account, not a + // system user. This is the only row the migration should convert. + _, err = sqlDB.ExecContext(ctx, + `INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type, is_service_account, is_system) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + legacyNoneID, "legacy-none", "legacy-none@test.com", []byte{}, now, now, "active", pq.StringArray{}, "none", false, false) + require.NoError(t, err) + + // A service account must keep login_type 'none' (a CHECK constraint requires + // service accounts to use 'none' and an empty email). + _, err = sqlDB.ExecContext(ctx, + `INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type, is_service_account, is_system) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + serviceAccountID, "service-account", "", []byte{}, now, now, "active", pq.StringArray{}, "none", true, false) + require.NoError(t, err) + + // A system user must be left untouched. + _, err = sqlDB.ExecContext(ctx, + `INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type, is_service_account, is_system) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + systemID, "system-user", "system@test.com", []byte{}, now, now, "active", pq.StringArray{}, "none", false, true) + require.NoError(t, err) + + // An existing password user must be left untouched. + _, err = sqlDB.ExecContext(ctx, + `INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type, is_service_account, is_system) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + passwordID, "password-user", "password@test.com", []byte("hashed"), now, now, "active", pq.StringArray{}, "password", false, false) + require.NoError(t, err) + + migrationSQL, err := os.ReadFile("000555_legacy_none_login_to_password.up.sql") + require.NoError(t, err) + _, err = sqlDB.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) + + getUser := func(t *testing.T, id uuid.UUID) (loginType, email string) { + t.Helper() + err := sqlDB.QueryRowContext(ctx, + `SELECT login_type::text, email FROM users WHERE id = $1`, id).Scan(&loginType, &email) + require.NoError(t, err) + return loginType, email + } + + // The legacy machine user is converted to password auth with its email + // preserved. + gotLoginType, gotEmail := getUser(t, legacyNoneID) + require.Equal(t, "password", gotLoginType) + require.Equal(t, "legacy-none@test.com", gotEmail) + + // Service accounts, system users, and existing password users are unchanged. + gotLoginType, _ = getUser(t, serviceAccountID) + require.Equal(t, "none", gotLoginType) + gotLoginType, _ = getUser(t, systemID) + require.Equal(t, "none", gotLoginType) + gotLoginType, _ = getUser(t, passwordID) + require.Equal(t, "password", gotLoginType) +} + func TestMigration000498SoftDeleteStaleWorkspaceAgents(t *testing.T) { t.Parallel() @@ -2168,3 +2251,100 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { "search must exclude deleted, model-only, and tool-role rows (%d %d %d)", toolMsg.ID, modelOnly.ID, deletedMsg.ID) } + +func TestMigration000556UserSecretsEnabled(t *testing.T) { + t.Parallel() + + const migrationVersion = 556 + + sqlDB := testSQLDB(t) + + // Migrate up to the migration before the one that adds the enabled + // column. + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", migrationVersion) + } + if version == migrationVersion-1 { + break + } + } + + ctx := testutil.Context(t, testutil.WaitSuperLong) + + userID := uuid.New() + envSecretID := uuid.New() + fileSecretID := uuid.New() + bothEmptySecretID := uuid.New() + + now := time.Now().UTC().Truncate(time.Microsecond) + + tx, err := sqlDB.BeginTx(ctx, nil) + require.NoError(t, err) + defer tx.Rollback() + + fixtures := []struct { + query string + args []any + }{ + { + `INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{userID, "user-secrets-enabled", "user-secrets-enabled@test.com", []byte{}, now, now, "active", pq.StringArray{}, "password"}, + }, + // env-only secret: should remain enabled after migration. + { + `INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{envSecretID, userID, "env-secret", "", "v1", "ENV_SECRET", "", now, now}, + }, + // file-only secret: should remain enabled after migration. + { + `INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{fileSecretID, userID, "file-secret", "", "v2", "", "/tmp/file-secret", now, now}, + }, + // Both env_name and file_path empty: silently skipped today by + // the agent manifest layer. Should be flipped to enabled=false + // by the migration so the behavior is preserved exactly under + // the new "always inject when enabled" rule. + { + `INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + []any{bothEmptySecretID, userID, "both-empty", "", "v3", "", "", now, now}, + }, + } + + for i, f := range fixtures { + _, err := tx.ExecContext(ctx, f.query, f.args...) + require.NoError(t, err, "fixture %d", i) + } + require.NoError(t, tx.Commit()) + + // Run the migration. + version, _, err := next() + require.NoError(t, err) + require.EqualValues(t, migrationVersion, version) + + getEnabled := func(t *testing.T, id uuid.UUID) bool { + t.Helper() + var enabled bool + err := sqlDB.QueryRowContext(ctx, + "SELECT enabled FROM user_secrets WHERE id = $1", id, + ).Scan(&enabled) + require.NoError(t, err) + return enabled + } + + require.True(t, getEnabled(t, envSecretID), + "env-only secret should remain enabled") + require.True(t, getEnabled(t, fileSecretID), + "file-only secret should remain enabled") + require.False(t, getEnabled(t, bothEmptySecretID), + "secret with both targets empty should be flipped to disabled "+ + "to preserve the previous implicit-skip behavior") +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 08b71a244efc3..74e982f5fc437 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -6244,6 +6244,7 @@ type UserSecret struct { CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"` + Enabled bool `db:"enabled" json:"enabled"` } type UserSkill struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f82ef6ea146b6..fb5434ff01411 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -133,6 +133,12 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error + // Deletes cached Terraform module archives ingested in the given time range and + // clears the template version references to them. created_by and mimetype + // identify a provisionerd-written module archive, matching the checks in + // provisionerdserver, so user-uploaded template tarballs are never removed. + // Only archives referenced by a template version are considered. + DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) // Clears a chat's pinned context resources. Used as the first half of a // clear-then-copy re-pin, and on its own when the chat's current agent // has no snapshot. @@ -302,6 +308,21 @@ type sqlcQuerier interface { // the root), we return its own ID as the root. GetAIBridgeInterceptionLineageByToolCallID(ctx context.Context, toolCallID string) (GetAIBridgeInterceptionLineageByToolCallIDRow, error) GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeInterception, error) + // Returns the most contacted destination hosts for an AI session, ordered by + // call count descending and limited to the top @limit_ rows. total_domains is + // the number of distinct domains across the whole session, used to render a + // "+N more" overflow beyond the returned rows. Only HTTP egress is considered; + // dns/git/fs boundary logs do not carry a domain in the same shape. + // + // Windowing mirrors the network_calls aggregation in ListAIBridgeSessions: + // each interception's boundary logs fall in the open interval (this seq, next + // interception's seq) within the same firewall session. The exclusive lower + // bound drops the interception's own LLM-provider call. next_seq considers all + // interceptions in the firewall session so windows never bleed across AI + // sessions that share one firewall session, and falls back to the maximum + // sequence_number for the last interception so the window stays an + // index-satisfiable range. + GetAIBridgeSessionTopDomains(ctx context.Context, arg GetAIBridgeSessionTopDomainsParams) ([]GetAIBridgeSessionTopDomainsRow, error) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error) GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, error) @@ -684,9 +705,13 @@ type sqlcQuerier interface { GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, arg GetOrganizationByNameParams) (Organization, error) // Returns AI spend limits and aggregate spend for groups in @group_ids that - // belong to @organization_id, on or after period_start until NOW. The spend - // limit is null when the group has no configured budget. + // belong to @organization_id, on or after period_start until NOW. + // spend_limit_micros is the per-member limit, null when the group has no budget. + // total_spend_limit_micros is the combined budget of the members attributed to + // the group, with each member's override replacing their share. It is null when + // the group has no budget. // The period_start parameter is normalized to its UTC calendar day. + // TODO(AIGOV-527): unify effective group resolution in a single place. GetOrganizationGroupsAISpend(ctx context.Context, arg GetOrganizationGroupsAISpendParams) ([]GetOrganizationGroupsAISpendRow, error) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error) GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (GetOrganizationResourceCountByIDRow, error) @@ -1214,6 +1239,26 @@ type sqlcQuerier interface { ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg ListAIBridgeInterceptionsTelemetrySummariesParams) ([]ListAIBridgeInterceptionsTelemetrySummariesRow, error) ListAIBridgeModelThoughtsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeModelThought, error) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams) ([]string, error) + // Returns the individual Agent Firewall network calls made during an AI + // session, ordered chronologically. All protocols are included, unlike + // GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list + // covers the same events the network_calls summary in ListAIBridgeSessions + // counts. The list is capped at @limit_ rows, so its length equals the summary + // total only for sessions at or below the cap. The summary stays authoritative + // for whole-session totals. + // + // Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each + // interception's boundary logs fall in the open interval (this seq, next + // interception's seq) within the same firewall session. The exclusive lower + // bound drops the interception's own LLM-provider call. next_seq considers all + // interceptions in the firewall session so windows never bleed across AI + // sessions that share one firewall session, and falls back to the maximum + // sequence_number for the last interception so the window stays an + // index-satisfiable range. + // created_at leads because a session can span several firewall sessions, whose + // sequence numbers are independent streams. id breaks remaining ties so the row + // that lands on the limit boundary is stable across identical requests. + ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]BoundaryLog, error) // Returns all interceptions belonging to paginated threads within a session. // Threads are paginated by (started_at, thread_id) cursor. ListAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams) ([]ListAIBridgeSessionThreadsRow, error) @@ -1224,6 +1269,12 @@ type sqlcQuerier interface { // Pagination-first strategy: identify the page of sessions cheaply via a // single GROUP BY scan, then do expensive lateral joins (tokens, prompts, // first-interception metadata) only for the ~page-size result set. + // The last interception in a session has no next row, so next_seq uses + // the largest sequence_number instead of NULL. The lookup stays a plain + // range, so the (session_id, sequence_number) index answers it alone. + // With NULL and an OR check, the index cannot bound the range: each + // interception reads every log to the end of the session and throws + // most of them away. ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams) ([]ListAIBridgeSessionsRow, error) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeTokenUsage, error) ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error) @@ -1590,6 +1641,9 @@ type sqlcQuerier interface { // Upsert a batch of (provider, model) rows from a JSON array. Each element // must have provider, model, and the four price fields; null prices are // written as SQL NULL. + // A conflicting row is only rewritten when a price differs, so updated_at + // records when a price last changed. Prices are nullable and a NULL on + // either side counts as a difference. UpsertAIModelPrices(ctx context.Context, seed json.RawMessage) error // Returns true if a new rows was inserted, false otherwise. UpsertAISeatState(ctx context.Context, arg UpsertAISeatStateParams) (bool, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 8a21c366a93d6..a5715df585ada 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -2045,6 +2045,16 @@ func TestProxyByHostname(t *testing.T) { accessURL: "https://two.coder.com", wildcardHostname: "*--suffix.two.coder.com", }, + { + name: "three", + accessURL: "https://three.coder.com:8443", + wildcardHostname: "*.wildcard.three.coder.com", + }, + { + name: "four", + accessURL: "https://four.coder.com/", + wildcardHostname: "*.wildcard.four.coder.com", + }, } for _, p := range proxies { dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{ @@ -2075,6 +2085,34 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "one", }, + { + name: "MatchAccessURLWithPort", + testHostname: "three.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "three", + }, + { + name: "MatchAccessURLWithTrailingSlash", + testHostname: "four.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "four", + }, + { + name: "RejectAccessURLPrefix", + testHostname: "one.coder", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", + }, + { + name: "RejectAccessURLTLDPrefix", + testHostname: "one.coder.co", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", + }, { name: "MatchWildcard", testHostname: "something.wildcard.one.coder.com", @@ -2082,6 +2120,13 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "one", }, + { + name: "RejectWildcardHostnamePrefix", + testHostname: "something.wildcard.one.coder", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, { name: "MatchSuffix", testHostname: "something--suffix.two.coder.com", @@ -2089,6 +2134,13 @@ func TestProxyByHostname(t *testing.T) { allowWildcardHost: true, matchProxyName: "two", }, + { + name: "RejectSuffixHostnamePrefix", + testHostname: "something--suffix.two.coder", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, { name: "ValidateHostname/1", testHostname: ".*ne.coder.com", @@ -8483,7 +8535,9 @@ func TestUserSecretsCRUDOperations(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "duplicate key value") - // Create secret with empty env_name and file_path (should succeed) + // Create secret with empty env_name and file_path. A target-less + // secret must be disabled to satisfy the + // user_secrets_enabled_requires_target constraint. secret2 := dbgen.UserSecret(t, db, database.UserSecret{ UserID: testUser.ID, Name: "unique-test-4", @@ -8491,6 +8545,8 @@ func TestUserSecretsCRUDOperations(t *testing.T) { Value: "value2", EnvName: "", // Empty env_name FilePath: "", // Empty file_path + }, func(params *database.CreateUserSecretParams) { + params.Enabled = false }) // Verify both secrets exist @@ -8505,6 +8561,83 @@ func TestUserSecretsCRUDOperations(t *testing.T) { }) } +// TestUserSecretsEnabledRequiresTargetConstraint verifies the +// user_secrets_enabled_requires_target CHECK constraint. It is the +// race-safe backstop for the injection-target invariant: the API's +// post-state check can be defeated by two concurrent PATCHes that each +// clear a different target, so the database must reject an enabled row +// with no target. +func TestUserSecretsEnabledRequiresTargetConstraint(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + user := dbgen.User(t, db, database.User{}) + + // A disabled secret may have no target. + disabled, err := db.CreateUserSecret(ctx, database.CreateUserSecretParams{ + ID: uuid.New(), + UserID: user.ID, + Name: "disabled-no-target", + Value: "v", + EnvName: "", + FilePath: "", + Enabled: false, + }) + require.NoError(t, err) + + // Enabling a target-less secret must be rejected by the constraint. + _, err = db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: disabled.Name, + UpdateEnabled: true, + Enabled: true, + }) + require.True(t, database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget), + "enabling a target-less secret should violate the constraint, got: %v", err) + + // An enabled secret with both targets set. + enabled, err := db.CreateUserSecret(ctx, database.CreateUserSecretParams{ + ID: uuid.New(), + UserID: user.ID, + Name: "enabled-both", + Value: "v", + EnvName: "ENABLED_BOTH", + FilePath: "~/enabled-both", + Enabled: true, + }) + require.NoError(t, err) + + // Clearing both targets while the secret stays enabled (the race + // outcome) must be rejected. + _, err = db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: enabled.Name, + UpdateEnvName: true, + EnvName: "", + UpdateFilePath: true, + FilePath: "", + }) + require.True(t, database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget), + "clearing both targets of an enabled secret should violate the constraint, got: %v", err) + + // Clearing both targets and disabling in the same update is allowed. + updated, err := db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: enabled.Name, + UpdateEnvName: true, + EnvName: "", + UpdateFilePath: true, + FilePath: "", + UpdateEnabled: true, + Enabled: false, + }) + require.NoError(t, err) + require.False(t, updated.Enabled) + require.Empty(t, updated.EnvName) + require.Empty(t, updated.FilePath) +} + // TestUserSecretsSoftDeleteTrigger verifies that a user's secrets // are deleted when the user is soft-deleted. func TestUserSecretsSoftDeleteTrigger(t *testing.T) { @@ -12802,45 +12935,174 @@ func TestGetOrganizationGroupsAISpend(t *testing.T) { now := monthStart.AddDate(0, 0, 14) // 2024-06-15 prevMonthLastDay := monthStart.AddDate(0, 0, -1) // 2024-05-31 - type seedRow struct { + // nullInt64 keeps the expectations below readable. + nullInt64 := func(v int64) sql.NullInt64 { + return sql.NullInt64{Int64: v, Valid: true} + } + + type groupBudget struct { + group string + limit int64 + } + type membership struct { + user string + group string + } + type override struct { + user string + group string + limit int64 + } + type spendRow struct { + user string + group string day time.Time spend int64 } + type wantGroup struct { + spendLimit sql.NullInt64 + totalLimit sql.NullInt64 + spend int64 + } + // A case declares its groups, members, overrides and spend by name. Every + // named group is created in one org and queried, and every named user is an + // org member. tests := []struct { - name string - setBudget bool - spendLimit int64 - rows []seedRow - wantCurrentSpend int64 + name string + budgets []groupBudget + members []membership + overrides []override + spend []spendRow + want map[string]wantGroup }{ { - name: "NoBudgetNoSpend", - wantCurrentSpend: 0, + name: "NoBudgetNoSpend", + want: map[string]wantGroup{ + "eng": {spendLimit: sql.NullInt64{}, totalLimit: sql.NullInt64{}}, + }, + }, + { + name: "ZeroLimitBudget", + budgets: []groupBudget{{group: "eng", limit: 0}}, + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(0), totalLimit: nullInt64(0)}, + }, + }, + { + // The group has no members, so nothing is attributed to it and the + // total is zero despite the budget. + name: "BudgetZeroSpend", + budgets: []groupBudget{{group: "eng", limit: 1_000_000}}, + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(1_000_000), totalLimit: nullInt64(0)}, + }, + }, + { + // alice has spend attributed to the group but is currently not a member, so the + // total stays zero while the spend counts. + name: "BudgetWithSpend", + budgets: []groupBudget{{group: "eng", limit: 1_000_000}}, + spend: []spendRow{{user: "alice", group: "eng", day: now, spend: 250}}, + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(1_000_000), totalLimit: nullInt64(0), spend: 250}, + }, + }, + { + name: "NoBudgetWithSpend", + spend: []spendRow{{user: "alice", group: "eng", day: now, spend: 100}}, + want: map[string]wantGroup{ + "eng": {spendLimit: sql.NullInt64{}, totalLimit: sql.NullInt64{}, spend: 100}, + }, + }, + { + name: "BudgetedTwoPlainMembers", + budgets: []groupBudget{{group: "eng", limit: 100}}, + members: []membership{{user: "alice", group: "eng"}, {user: "bob", group: "eng"}}, + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(100), totalLimit: nullInt64(200)}, + }, }, { - name: "ZeroLimitBudget", - setBudget: true, - spendLimit: 0, - wantCurrentSpend: 0, + name: "BudgetedPlainMemberPlusOverride", + budgets: []groupBudget{{group: "eng", limit: 100}}, + members: []membership{{user: "alice", group: "eng"}, {user: "bob", group: "eng"}}, + overrides: []override{{user: "alice", group: "eng", limit: 1000}}, + // The override replaces its holder's share of the group limit. + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(100), totalLimit: nullInt64(1100)}, + }, }, { - name: "BudgetZeroSpend", - setBudget: true, - spendLimit: 1_000_000, - wantCurrentSpend: 0, + name: "BudgetedOnlyOverrideMember", + budgets: []groupBudget{{group: "eng", limit: 100}}, + members: []membership{{user: "alice", group: "eng"}}, + overrides: []override{{user: "alice", group: "eng", limit: 1000}}, + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(100), totalLimit: nullInt64(1000)}, + }, }, { - name: "BudgetWithSpend", - setBudget: true, - spendLimit: 1_000_000, - rows: []seedRow{{now, 250}}, - wantCurrentSpend: 250, + name: "BudgetedZeroLimitWithMembers", + budgets: []groupBudget{{group: "eng", limit: 0}}, + members: []membership{{user: "alice", group: "eng"}, {user: "bob", group: "eng"}}, + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(0), totalLimit: nullInt64(0)}, + }, }, { - name: "NoBudgetWithSpend", - rows: []seedRow{{now, 100}}, - wantCurrentSpend: 100, + name: "UnbudgetedWithMembers", + members: []membership{{user: "alice", group: "eng"}, {user: "bob", group: "eng"}}, + want: map[string]wantGroup{ + "eng": {spendLimit: sql.NullInt64{}, totalLimit: sql.NullInt64{}}, + }, + }, + { + name: "UnbudgetedWithOverride", + members: []membership{{user: "alice", group: "eng"}}, + overrides: []override{{user: "alice", group: "eng", limit: 1000}}, + // Members of a group with no budget spend without a cap, so the + // override does not make the group's total finite. + want: map[string]wantGroup{ + "eng": {spendLimit: sql.NullInt64{}, totalLimit: sql.NullInt64{}}, + }, + }, + { + name: "MemberResolvesToHigherBudgetGroup", + budgets: []groupBudget{ + {group: "eng", limit: 100}, + {group: "platform", limit: 500}, + }, + members: []membership{ + {user: "alice", group: "eng"}, + {user: "bob", group: "eng"}, + {user: "alice", group: "platform"}, + }, + // alice is attributed to the higher-limit group, so only bob counts + // toward eng. + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(100), totalLimit: nullInt64(100)}, + "platform": {spendLimit: nullInt64(500), totalLimit: nullInt64(500)}, + }, + }, + { + name: "OverrideChargedToOtherGroup", + budgets: []groupBudget{ + {group: "eng", limit: 100}, + {group: "platform", limit: 50}, + }, + members: []membership{ + {user: "alice", group: "eng"}, + {user: "bob", group: "eng"}, + {user: "alice", group: "platform"}, + }, + overrides: []override{{user: "alice", group: "platform", limit: 1000}}, + // The override attributes alice to platform even though eng carries + // the higher group limit. + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(100), totalLimit: nullInt64(100)}, + "platform": {spendLimit: nullInt64(50), totalLimit: nullInt64(1000)}, + }, }, } @@ -12850,46 +13112,92 @@ func TestGetOrganizationGroupsAISpend(t *testing.T) { db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) - // Given: an org with a single group, optionally with a budget and seeded spend. - user := dbgen.User(t, db, database.User{}) + // Given: the groups, members, overrides and spend the case declares. org := dbgen.Organization(t, db, database.Organization{}) - group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID}) - if tt.setBudget { + groupIDs := make(map[string]uuid.UUID) + groupID := func(name string) uuid.UUID { + if id, ok := groupIDs[name]; ok { + return id + } + group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID}) + groupIDs[name] = group.ID + return group.ID + } + userIDs := make(map[string]uuid.UUID) + userID := func(name string) uuid.UUID { + if id, ok := userIDs[name]; ok { + return id + } + user := dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + userIDs[name] = user.ID + return user.ID + } + + for name := range tt.want { + groupID(name) + } + for _, b := range tt.budgets { _, err := db.UpsertGroupAIBudget(ctx, database.UpsertGroupAIBudgetParams{ - GroupID: group.ID, - SpendLimitMicros: tt.spendLimit, + GroupID: groupID(b.group), + SpendLimitMicros: b.limit, }) require.NoError(t, err) } - for _, r := range tt.rows { + for _, m := range tt.members { + dbgen.GroupMember(t, db, database.GroupMemberTable{ + GroupID: groupID(m.group), + UserID: userID(m.user), + }) + } + for _, o := range tt.overrides { + _, err := db.UpsertUserAIBudgetOverride(ctx, database.UpsertUserAIBudgetOverrideParams{ + UserID: userID(o.user), + GroupID: groupID(o.group), + SpendLimitMicros: o.limit, + }) + require.NoError(t, err) + } + for _, s := range tt.spend { _, err := db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{ - UserID: user.ID, - EffectiveGroupID: group.ID, - Day: r.day, - CostMicros: r.spend, + UserID: userID(s.user), + EffectiveGroupID: groupID(s.group), + Day: s.day, + CostMicros: s.spend, }) require.NoError(t, err) } - // When: querying spend for the group since monthStart. + // When: querying every group the case declares. + queried := make([]uuid.UUID, 0, len(groupIDs)) + for _, id := range groupIDs { + queried = append(queried, id) + } got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{ OrganizationID: org.ID, - GroupIds: []uuid.UUID{group.ID}, + GroupIds: queried, PeriodStart: monthStart, }) require.NoError(t, err) - // Then: one row is returned with the group's limit and spend. - require.Len(t, got, 1) - require.Equal(t, group.ID, got[0].GroupID) - require.Equal(t, org.ID, got[0].OrganizationID) - if tt.setBudget { - require.True(t, got[0].SpendLimitMicros.Valid, "expected configured budget") - require.Equal(t, tt.spendLimit, got[0].SpendLimitMicros.Int64, "spend_limit_micros") - } else { - require.False(t, got[0].SpendLimitMicros.Valid, "expected no configured budget") + // Then: each group reports its own limit, spend, and the combined + // limit of the members attributed to it. + require.Len(t, got, len(groupIDs)) + byID := make(map[uuid.UUID]database.GetOrganizationGroupsAISpendRow, len(got)) + for _, r := range got { + byID[r.GroupID] = r + } + for name, want := range tt.want { + row, ok := byID[groupIDs[name]] + require.True(t, ok, "group %q missing from response", name) + require.Equal(t, org.ID, row.OrganizationID) + require.Equal(t, want.spendLimit, row.SpendLimitMicros, "%s spend_limit_micros", name) + require.Equal(t, want.totalLimit, row.TotalSpendLimitMicros, "%s total_spend_limit_micros", name) + require.Equal(t, want.spend, row.CurrentSpendMicros, "%s current_spend_micros", name) } - require.Equal(t, tt.wantCurrentSpend, got[0].CurrentSpendMicros) }) } @@ -12934,10 +13242,14 @@ func TestGetOrganizationGroupsAISpend(t *testing.T) { rowA, ok := byID[groupA.ID] require.True(t, ok, "groupA missing from response") require.Equal(t, sql.NullInt64{Int64: 1_000_000, Valid: true}, rowA.SpendLimitMicros) + // Neither group has members, so the budgeted group totals zero and the + // unbudgeted one is null. + require.Equal(t, sql.NullInt64{Int64: 0, Valid: true}, rowA.TotalSpendLimitMicros) require.Equal(t, int64(250), rowA.CurrentSpendMicros) rowB, ok := byID[groupB.ID] require.True(t, ok, "groupB missing from response") require.Equal(t, sql.NullInt64{}, rowB.SpendLimitMicros) + require.Equal(t, sql.NullInt64{}, rowB.TotalSpendLimitMicros) require.Equal(t, int64(500), rowB.CurrentSpendMicros) }) @@ -13098,6 +13410,81 @@ func TestGetOrganizationGroupsAISpend(t *testing.T) { require.Equal(t, int64(25), got[0].CurrentSpendMicros, "sum must exclude prevMonthLastDay row after normalization") }) + + t.Run("EveryoneGroupCountsOrgMembers", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: an org with two members whose implicit Everyone group carries the + // only budget. + org := dbgen.Organization(t, db, database.Organization{}) + for range 2 { + user := dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + } + // The Everyone group has ID equal to the organization ID and must be + // inserted explicitly for the group_ai_budgets FK constraint. + //nolint:gocritic // Requires system context. + _, err := db.InsertAllUsersGroup(dbauthz.AsSystemRestricted(ctx), org.ID) + require.NoError(t, err) + _, err = db.UpsertGroupAIBudget(ctx, database.UpsertGroupAIBudgetParams{ + GroupID: org.ID, + SpendLimitMicros: 100, + }) + require.NoError(t, err) + + // When: querying the Everyone group. + got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{ + OrganizationID: org.ID, + GroupIds: []uuid.UUID{org.ID}, + PeriodStart: monthStart, + }) + require.NoError(t, err) + + // Then: every org member counts toward the total. + require.Len(t, got, 1) + require.Equal(t, sql.NullInt64{Int64: 100, Valid: true}, got[0].SpendLimitMicros, "spend_limit_micros") + require.Equal(t, sql.NullInt64{Int64: 200, Valid: true}, got[0].TotalSpendLimitMicros, "total_spend_limit_micros") + require.Equal(t, int64(0), got[0].CurrentSpendMicros, "current_spend_micros") + }) + + t.Run("EveryoneGroupWithoutBudgetIsUnlimited", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: an org with two members and no budget anywhere, which is where + // uncapped users are attributed. + org := dbgen.Organization(t, db, database.Organization{}) + for range 2 { + user := dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + } + //nolint:gocritic // Requires system context. + _, err := db.InsertAllUsersGroup(dbauthz.AsSystemRestricted(ctx), org.ID) + require.NoError(t, err) + + // When: querying the Everyone group. + got, err := db.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{ + OrganizationID: org.ID, + GroupIds: []uuid.UUID{org.ID}, + PeriodStart: monthStart, + }) + require.NoError(t, err) + + // Then: the total is null, so the group reads as unlimited. + require.Len(t, got, 1) + require.Equal(t, sql.NullInt64{}, got[0].SpendLimitMicros, "spend_limit_micros") + require.Equal(t, sql.NullInt64{}, got[0].TotalSpendLimitMicros, "total_spend_limit_micros") + require.Equal(t, int64(0), got[0].CurrentSpendMicros, "current_spend_micros") + }) } func TestGetGroupMembersAISpend(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1428f399832ee..a0c4ca93f13e5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1272,6 +1272,101 @@ func (q *sqlQuerier) GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeIn return items, nil } +const getAIBridgeSessionTopDomains = `-- name: GetAIBridgeSessionTopDomains :many +WITH session_boundary_logs AS ( + SELECT bl.detail + FROM aibridge_interceptions afi + LEFT JOIN LATERAL ( + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq + FROM aibridge_interceptions nxt + WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id + AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number + ) w ON true + JOIN boundary_logs bl + ON bl.session_id = afi.agent_firewall_session_id + AND bl.sequence_number > afi.agent_firewall_sequence_number + AND bl.sequence_number < w.next_seq + WHERE afi.session_id = $2::text + AND afi.ended_at IS NOT NULL + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL + AND bl.proto = 'http' +), +extracted AS ( + -- Strip an optional scheme, then keep the host up to the first port, path, + -- query, or fragment delimiter. This assumes HTTP egress detail is a plain + -- scheme+host(+port) URL: it does not handle userinfo (user@host, which + -- would be captured into the host) or IPv6 literal hosts ([::1], where the + -- leading '[' is captured and the ':' terminates early). Boundary HTTP logs + -- do not currently emit those forms; revisit this extraction if they do. + SELECT substring(detail from '^(?:[A-Za-z][A-Za-z0-9+.-]*://)?([^/:?#]+)') AS domain + FROM session_boundary_logs +), +domains AS ( + SELECT domain, COUNT(*)::bigint AS count + FROM extracted + WHERE domain IS NOT NULL AND domain != '' + GROUP BY domain +) +SELECT + -- COALESCE keeps sqlc from typing the grouped column as nullable; the + -- domains CTE already filters out NULL/empty hosts. + COALESCE(domain, '')::text AS domain, + count, + COUNT(*) OVER ()::bigint AS total_domains +FROM domains +ORDER BY count DESC, domain ASC +LIMIT COALESCE(NULLIF($1::integer, 0), 5) +` + +type GetAIBridgeSessionTopDomainsParams struct { + Limit int32 `db:"limit_" json:"limit_"` + SessionID string `db:"session_id" json:"session_id"` +} + +type GetAIBridgeSessionTopDomainsRow struct { + Domain string `db:"domain" json:"domain"` + Count int64 `db:"count" json:"count"` + TotalDomains int64 `db:"total_domains" json:"total_domains"` +} + +// Returns the most contacted destination hosts for an AI session, ordered by +// call count descending and limited to the top @limit_ rows. total_domains is +// the number of distinct domains across the whole session, used to render a +// "+N more" overflow beyond the returned rows. Only HTTP egress is considered; +// dns/git/fs boundary logs do not carry a domain in the same shape. +// +// Windowing mirrors the network_calls aggregation in ListAIBridgeSessions: +// each interception's boundary logs fall in the open interval (this seq, next +// interception's seq) within the same firewall session. The exclusive lower +// bound drops the interception's own LLM-provider call. next_seq considers all +// interceptions in the firewall session so windows never bleed across AI +// sessions that share one firewall session, and falls back to the maximum +// sequence_number for the last interception so the window stays an +// index-satisfiable range. +func (q *sqlQuerier) GetAIBridgeSessionTopDomains(ctx context.Context, arg GetAIBridgeSessionTopDomainsParams) ([]GetAIBridgeSessionTopDomainsRow, error) { + rows, err := q.db.QueryContext(ctx, getAIBridgeSessionTopDomains, arg.Limit, arg.SessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAIBridgeSessionTopDomainsRow + for rows.Next() { + var i GetAIBridgeSessionTopDomainsRow + if err := rows.Scan(&i.Domain, &i.Count, &i.TotalDomains); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getAIBridgeTokenUsagesByInterceptionID = `-- name: GetAIBridgeTokenUsagesByInterceptionID :many SELECT id, interception_id, provider_response_id, input_tokens, output_tokens, metadata, created_at, cache_read_input_tokens, cache_write_input_tokens, effective_group_id, input_price_micros, output_price_micros, cache_read_price_micros, cache_write_price_micros, cost_micros @@ -1876,6 +1971,85 @@ func (q *sqlQuerier) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeMod return items, nil } +const listAIBridgeSessionNetworkCalls = `-- name: ListAIBridgeSessionNetworkCalls :many +SELECT bl.id, bl.session_id, bl.sequence_number, bl.captured_at, bl.created_at, bl.proto, bl.method, bl.detail, bl.matched_rule, bl.owner_id +FROM aibridge_interceptions afi +LEFT JOIN LATERAL ( + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq + FROM aibridge_interceptions nxt + WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id + AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number +) w ON true +JOIN boundary_logs bl + ON bl.session_id = afi.agent_firewall_session_id + AND bl.sequence_number > afi.agent_firewall_sequence_number + AND bl.sequence_number < w.next_seq +WHERE afi.session_id = $1::text + AND afi.ended_at IS NOT NULL + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL +ORDER BY bl.created_at ASC, bl.sequence_number ASC, bl.id ASC +LIMIT COALESCE(NULLIF($2::integer, 0), 1000) +` + +type ListAIBridgeSessionNetworkCallsParams struct { + SessionID string `db:"session_id" json:"session_id"` + Limit int32 `db:"limit_" json:"limit_"` +} + +// Returns the individual Agent Firewall network calls made during an AI +// session, ordered chronologically. All protocols are included, unlike +// GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list +// covers the same events the network_calls summary in ListAIBridgeSessions +// counts. The list is capped at @limit_ rows, so its length equals the summary +// total only for sessions at or below the cap. The summary stays authoritative +// for whole-session totals. +// +// Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each +// interception's boundary logs fall in the open interval (this seq, next +// interception's seq) within the same firewall session. The exclusive lower +// bound drops the interception's own LLM-provider call. next_seq considers all +// interceptions in the firewall session so windows never bleed across AI +// sessions that share one firewall session, and falls back to the maximum +// sequence_number for the last interception so the window stays an +// index-satisfiable range. +// created_at leads because a session can span several firewall sessions, whose +// sequence numbers are independent streams. id breaks remaining ties so the row +// that lands on the limit boundary is stable across identical requests. +func (q *sqlQuerier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]BoundaryLog, error) { + rows, err := q.db.QueryContext(ctx, listAIBridgeSessionNetworkCalls, arg.SessionID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BoundaryLog + for rows.Next() { + var i BoundaryLog + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.SequenceNumber, + &i.CapturedAt, + &i.CreatedAt, + &i.Proto, + &i.Method, + &i.Detail, + &i.MatchedRule, + &i.OwnerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAIBridgeSessionThreads = `-- name: ListAIBridgeSessionThreads :many WITH paginated_threads AS ( SELECT @@ -2164,12 +2338,13 @@ LEFT JOIN LATERAL ( -- (logged at exactly its sequence number), leaving the agent's other -- egress. next_seq considers all interceptions in the firewall session so -- windows never bleed across AI sessions that share one firewall session. + -- SELECT COUNT(*)::bigint AS total, COUNT(*) FILTER (WHERE bl.matched_rule IS NULL)::bigint AS blocked FROM aibridge_interceptions afi LEFT JOIN LATERAL ( - SELECT MIN(nxt.agent_firewall_sequence_number) AS next_seq + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq FROM aibridge_interceptions nxt WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number @@ -2177,7 +2352,7 @@ LEFT JOIN LATERAL ( JOIN boundary_logs bl ON bl.session_id = afi.agent_firewall_session_id AND bl.sequence_number > afi.agent_firewall_sequence_number - AND (w.next_seq IS NULL OR bl.sequence_number < w.next_seq) + AND bl.sequence_number < w.next_seq WHERE afi.id = ANY(sr.interception_ids) AND afi.agent_firewall_session_id IS NOT NULL AND afi.agent_firewall_sequence_number IS NOT NULL @@ -2232,6 +2407,12 @@ type ListAIBridgeSessionsRow struct { // Pagination-first strategy: identify the page of sessions cheaply via a // single GROUP BY scan, then do expensive lateral joins (tokens, prompts, // first-interception metadata) only for the ~page-size result set. +// The last interception in a session has no next row, so next_seq uses +// the largest sequence_number instead of NULL. The lookup stays a plain +// range, so the (session_id, sequence_number) index answers it alone. +// With NULL and an OR check, the index cannot bound the range: each +// interception reads every log to the end of the session and throws +// most of them away. func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams) ([]ListAIBridgeSessionsRow, error) { rows, err := q.db.QueryContext(ctx, listAIBridgeSessions, arg.AfterSessionID, @@ -2860,41 +3041,119 @@ func (q *sqlQuerier) GetHighestGroupAIBudgetByUser(ctx context.Context, userID u } const getOrganizationGroupsAISpend = `-- name: GetOrganizationGroupsAISpend :many +WITH queried_groups AS ( + -- The requested groups that belong to the queried organization. + SELECT groups.id, groups.organization_id + FROM groups + WHERE groups.organization_id = $1 + AND groups.id = ANY($2::uuid[]) +), +candidate_users AS ( + -- Members of the queried groups. Uses group_members_expanded so the implicit + -- Everyone group counts. + SELECT DISTINCT member.user_id + FROM group_members_expanded member + WHERE member.group_id IN (SELECT id FROM queried_groups) +), +user_highest_group AS ( + -- Per user, the highest-limit group they belong to. Uses + -- group_members_expanded so the implicit Everyone group counts. + SELECT DISTINCT ON (member.user_id) + member.user_id, + budget.group_id, + budget.spend_limit_micros + FROM group_ai_budgets budget + JOIN group_members_expanded member ON member.group_id = budget.group_id + JOIN organizations ON organizations.id = member.organization_id + JOIN organization_members + ON organization_members.user_id = member.user_id + AND organization_members.organization_id = member.organization_id + WHERE member.user_id IN (SELECT user_id FROM candidate_users) + AND organizations.deleted = false + ORDER BY member.user_id, budget.spend_limit_micros DESC, organization_members.created_at ASC, budget.group_id ASC +), +effective AS ( + -- Effective budget group per user: an override wins over the highest-limit + -- group they belong to. Users with neither are left out, since the group they + -- fall back to has no budget and reports null. + SELECT + candidate_users.user_id, + COALESCE(override.group_id, user_highest_group.group_id) AS effective_group_id, + override.spend_limit_micros AS override_limit_micros + FROM candidate_users + LEFT JOIN user_ai_budget_overrides override ON override.user_id = candidate_users.user_id + LEFT JOIN user_highest_group ON user_highest_group.user_id = candidate_users.user_id +), +group_limits AS ( + -- Per attributed group, how many members take the group's own limit and the + -- combined limit of those carrying an override. + SELECT + effective.effective_group_id AS group_id, + count(*) FILTER (WHERE effective.override_limit_micros IS NULL) AS plain_member_count, + COALESCE(SUM(effective.override_limit_micros), 0)::BIGINT AS override_limit_sum + FROM effective + WHERE effective.effective_group_id IS NOT NULL + GROUP BY effective.effective_group_id +), +group_totals AS ( + -- Combined limit per budgeted group, counting members with no override at the + -- group's own limit and adding the overrides on top. Unbudgeted groups are + -- absent here, so the join below leaves their total null. + SELECT + queried_groups.id AS group_id, + (budget.spend_limit_micros * COALESCE(group_limits.plain_member_count, 0) + + COALESCE(group_limits.override_limit_sum, 0))::BIGINT AS total_spend_limit_micros + FROM queried_groups + JOIN group_ai_budgets budget ON budget.group_id = queried_groups.id + LEFT JOIN group_limits ON group_limits.group_id = queried_groups.id +), +group_spend AS ( + -- Spend per queried group over the period. + SELECT + spend.effective_group_id AS group_id, + COALESCE(SUM(spend.spend_micros), 0)::BIGINT AS current_spend_micros + FROM ai_user_daily_spend spend + WHERE spend.effective_group_id IN (SELECT id FROM queried_groups) + AND spend.day >= (($3::timestamptz) AT TIME ZONE 'UTC')::date + GROUP BY spend.effective_group_id +) SELECT - groups.id AS group_id, - groups.organization_id AS organization_id, + queried_groups.id AS group_id, + queried_groups.organization_id AS organization_id, budget.spend_limit_micros AS spend_limit_micros, - COALESCE(SUM(spend.spend_micros), 0)::BIGINT AS current_spend_micros -FROM groups -LEFT JOIN group_ai_budgets budget ON budget.group_id = groups.id -LEFT JOIN ai_user_daily_spend spend - ON spend.effective_group_id = groups.id - AND spend.day >= (($1::timestamptz) AT TIME ZONE 'UTC')::date -WHERE groups.organization_id = $2 - AND groups.id = ANY($3::uuid[]) -GROUP BY groups.id, budget.spend_limit_micros -ORDER BY groups.id + group_totals.total_spend_limit_micros AS total_spend_limit_micros, + COALESCE(group_spend.current_spend_micros, 0)::BIGINT AS current_spend_micros +FROM queried_groups +LEFT JOIN group_ai_budgets budget ON budget.group_id = queried_groups.id +LEFT JOIN group_totals ON group_totals.group_id = queried_groups.id +LEFT JOIN group_spend ON group_spend.group_id = queried_groups.id +ORDER BY queried_groups.id ` type GetOrganizationGroupsAISpendParams struct { - PeriodStart time.Time `db:"period_start" json:"period_start"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` GroupIds []uuid.UUID `db:"group_ids" json:"group_ids"` + PeriodStart time.Time `db:"period_start" json:"period_start"` } type GetOrganizationGroupsAISpendRow struct { - GroupID uuid.UUID `db:"group_id" json:"group_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"` - CurrentSpendMicros int64 `db:"current_spend_micros" json:"current_spend_micros"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + SpendLimitMicros sql.NullInt64 `db:"spend_limit_micros" json:"spend_limit_micros"` + TotalSpendLimitMicros sql.NullInt64 `db:"total_spend_limit_micros" json:"total_spend_limit_micros"` + CurrentSpendMicros int64 `db:"current_spend_micros" json:"current_spend_micros"` } // Returns AI spend limits and aggregate spend for groups in @group_ids that -// belong to @organization_id, on or after period_start until NOW. The spend -// limit is null when the group has no configured budget. +// belong to @organization_id, on or after period_start until NOW. +// spend_limit_micros is the per-member limit, null when the group has no budget. +// total_spend_limit_micros is the combined budget of the members attributed to +// the group, with each member's override replacing their share. It is null when +// the group has no budget. // The period_start parameter is normalized to its UTC calendar day. +// TODO(AIGOV-527): unify effective group resolution in a single place. func (q *sqlQuerier) GetOrganizationGroupsAISpend(ctx context.Context, arg GetOrganizationGroupsAISpendParams) ([]GetOrganizationGroupsAISpendRow, error) { - rows, err := q.db.QueryContext(ctx, getOrganizationGroupsAISpend, arg.PeriodStart, arg.OrganizationID, pq.Array(arg.GroupIds)) + rows, err := q.db.QueryContext(ctx, getOrganizationGroupsAISpend, arg.OrganizationID, pq.Array(arg.GroupIds), arg.PeriodStart) if err != nil { return nil, err } @@ -2906,6 +3165,7 @@ func (q *sqlQuerier) GetOrganizationGroupsAISpend(ctx context.Context, arg GetOr &i.GroupID, &i.OrganizationID, &i.SpendLimitMicros, + &i.TotalSpendLimitMicros, &i.CurrentSpendMicros, ); err != nil { return nil, err @@ -3146,11 +3406,25 @@ ON CONFLICT (provider, model) DO UPDATE SET cache_read_price = EXCLUDED.cache_read_price, cache_write_price = EXCLUDED.cache_write_price, updated_at = NOW() +WHERE ( + ai_model_prices.input_price, + ai_model_prices.output_price, + ai_model_prices.cache_read_price, + ai_model_prices.cache_write_price +) IS DISTINCT FROM ( + EXCLUDED.input_price, + EXCLUDED.output_price, + EXCLUDED.cache_read_price, + EXCLUDED.cache_write_price +) ` // Upsert a batch of (provider, model) rows from a JSON array. Each element // must have provider, model, and the four price fields; null prices are // written as SQL NULL. +// A conflicting row is only rewritten when a price differs, so updated_at +// records when a price last changed. Prices are nullable and a NULL on +// either side counts as a difference. func (q *sqlQuerier) UpsertAIModelPrices(ctx context.Context, seed json.RawMessage) error { _, err := q.db.ExecContext(ctx, upsertAIModelPrices, seed) return err @@ -14604,6 +14878,58 @@ func (q *sqlQuerier) UpdateExternalAuthLinkRefreshToken(ctx context.Context, arg return err } +const deleteCachedModuleFilesCreatedBetween = `-- name: DeleteCachedModuleFilesCreatedBetween :execrows +WITH doomed AS ( + SELECT + files.id + FROM + files + INNER JOIN + template_version_terraform_values + ON template_version_terraform_values.cached_module_files = files.id + WHERE + files.created_by = '00000000-0000-0000-0000-000000000000' + AND files.mimetype = 'application/x-tar' + AND files.created_at >= $1 + AND files.created_at < $2 +), cleared AS ( + -- The foreign key is NO ACTION, so references must be cleared before the + -- files rows can be deleted. Data-modifying CTEs always run to completion, + -- and the constraint is checked at the end of the statement. + UPDATE + template_version_terraform_values + SET + cached_module_files = NULL + WHERE + cached_module_files IN (SELECT id FROM doomed) + RETURNING 1 +) +DELETE FROM + files +USING + doomed +WHERE + files.id = doomed.id +` + +type DeleteCachedModuleFilesCreatedBetweenParams struct { + CreatedAtAfter time.Time `db:"created_at_after" json:"created_at_after"` + CreatedAtBefore time.Time `db:"created_at_before" json:"created_at_before"` +} + +// Deletes cached Terraform module archives ingested in the given time range and +// clears the template version references to them. created_by and mimetype +// identify a provisionerd-written module archive, matching the checks in +// provisionerdserver, so user-uploaded template tarballs are never removed. +// Only archives referenced by a template version are considered. +func (q *sqlQuerier) DeleteCachedModuleFilesCreatedBetween(ctx context.Context, arg DeleteCachedModuleFilesCreatedBetweenParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteCachedModuleFilesCreatedBetween, arg.CreatedAtAfter, arg.CreatedAtBefore) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getFileByHashAndCreator = `-- name: GetFileByHashAndCreator :one SELECT hash, created_at, created_by, mimetype, data, id @@ -24210,7 +24536,7 @@ WHERE ( ( $2 :: bool = true AND - url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]%)*' ) OR ( $3 :: bool = true AND @@ -29754,7 +30080,8 @@ INSERT INTO user_secrets ( value, value_key_id, env_name, - file_path + file_path, + enabled ) VALUES ( $1, $2, @@ -29763,8 +30090,9 @@ INSERT INTO user_secrets ( $5, $6, $7, - $8 -) RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id + $8, + $9 +) RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled ` type CreateUserSecretParams struct { @@ -29776,6 +30104,7 @@ type CreateUserSecretParams struct { ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"` EnvName string `db:"env_name" json:"env_name"` FilePath string `db:"file_path" json:"file_path"` + Enabled bool `db:"enabled" json:"enabled"` } func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error) { @@ -29788,6 +30117,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP arg.ValueKeyID, arg.EnvName, arg.FilePath, + arg.Enabled, ) var i UserSecret err := row.Scan( @@ -29801,6 +30131,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP &i.CreatedAt, &i.UpdatedAt, &i.ValueKeyID, + &i.Enabled, ) return i, err } @@ -29808,7 +30139,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP const deleteUserSecretByUserIDAndName = `-- name: DeleteUserSecretByUserIDAndName :one DELETE FROM user_secrets WHERE user_id = $1 AND name = $2 -RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id +RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled ` type DeleteUserSecretByUserIDAndNameParams struct { @@ -29830,12 +30161,13 @@ func (q *sqlQuerier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg De &i.CreatedAt, &i.UpdatedAt, &i.ValueKeyID, + &i.Enabled, ) return i, err } const getUserSecretByID = `-- name: GetUserSecretByID :one -SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled FROM user_secrets WHERE id = $1 ` @@ -29854,12 +30186,13 @@ func (q *sqlQuerier) GetUserSecretByID(ctx context.Context, id uuid.UUID) (UserS &i.CreatedAt, &i.UpdatedAt, &i.ValueKeyID, + &i.Enabled, ) return i, err } const getUserSecretByUserIDAndName = `-- name: GetUserSecretByUserIDAndName :one -SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled FROM user_secrets WHERE user_id = $1 AND name = $2 ` @@ -29883,6 +30216,7 @@ func (q *sqlQuerier) GetUserSecretByUserIDAndName(ctx context.Context, arg GetUs &i.CreatedAt, &i.UpdatedAt, &i.ValueKeyID, + &i.Enabled, ) return i, err } @@ -29983,7 +30317,7 @@ func (q *sqlQuerier) GetUserSecretsTelemetrySummary(ctx context.Context) (GetUse const listUserSecrets = `-- name: ListUserSecrets :many SELECT id, user_id, name, description, - env_name, file_path, + env_name, file_path, enabled, created_at, updated_at FROM user_secrets WHERE user_id = $1 @@ -29997,6 +30331,7 @@ type ListUserSecretsRow struct { Description string `db:"description" json:"description"` EnvName string `db:"env_name" json:"env_name"` FilePath string `db:"file_path" json:"file_path"` + Enabled bool `db:"enabled" json:"enabled"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } @@ -30019,6 +30354,7 @@ func (q *sqlQuerier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]L &i.Description, &i.EnvName, &i.FilePath, + &i.Enabled, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -30036,7 +30372,7 @@ func (q *sqlQuerier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]L } const listUserSecretsWithValues = `-- name: ListUserSecretsWithValues :many -SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id +SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled FROM user_secrets WHERE user_id = $1 ORDER BY name ASC @@ -30065,6 +30401,7 @@ func (q *sqlQuerier) ListUserSecretsWithValues(ctx context.Context, userID uuid. &i.CreatedAt, &i.UpdatedAt, &i.ValueKeyID, + &i.Enabled, ); err != nil { return nil, err } @@ -30087,9 +30424,10 @@ SET description = CASE WHEN $4::bool THEN $5 ELSE description END, env_name = CASE WHEN $6::bool THEN $7 ELSE env_name END, file_path = CASE WHEN $8::bool THEN $9 ELSE file_path END, + enabled = CASE WHEN $10::bool THEN $11 ELSE enabled END, updated_at = CURRENT_TIMESTAMP -WHERE user_id = $10 AND name = $11 -RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id +WHERE user_id = $12 AND name = $13 +RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled ` type UpdateUserSecretByUserIDAndNameParams struct { @@ -30102,6 +30440,8 @@ type UpdateUserSecretByUserIDAndNameParams struct { EnvName string `db:"env_name" json:"env_name"` UpdateFilePath bool `db:"update_file_path" json:"update_file_path"` FilePath string `db:"file_path" json:"file_path"` + UpdateEnabled bool `db:"update_enabled" json:"update_enabled"` + Enabled bool `db:"enabled" json:"enabled"` UserID uuid.UUID `db:"user_id" json:"user_id"` Name string `db:"name" json:"name"` } @@ -30117,6 +30457,8 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up arg.EnvName, arg.UpdateFilePath, arg.FilePath, + arg.UpdateEnabled, + arg.Enabled, arg.UserID, arg.Name, ) @@ -30132,6 +30474,7 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up &i.CreatedAt, &i.UpdatedAt, &i.ValueKeyID, + &i.Enabled, ) return i, err } diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 63635b6ae22ef..f3610aa31a6fc 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -528,12 +528,19 @@ LEFT JOIN LATERAL ( -- (logged at exactly its sequence number), leaving the agent's other -- egress. next_seq considers all interceptions in the firewall session so -- windows never bleed across AI sessions that share one firewall session. + -- +-- The last interception in a session has no next row, so next_seq uses +-- the largest sequence_number instead of NULL. The lookup stays a plain +-- range, so the (session_id, sequence_number) index answers it alone. +-- With NULL and an OR check, the index cannot bound the range: each +-- interception reads every log to the end of the session and throws +-- most of them away. SELECT COUNT(*)::bigint AS total, COUNT(*) FILTER (WHERE bl.matched_rule IS NULL)::bigint AS blocked FROM aibridge_interceptions afi LEFT JOIN LATERAL ( - SELECT MIN(nxt.agent_firewall_sequence_number) AS next_seq + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq FROM aibridge_interceptions nxt WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number @@ -541,7 +548,7 @@ LEFT JOIN LATERAL ( JOIN boundary_logs bl ON bl.session_id = afi.agent_firewall_session_id AND bl.sequence_number > afi.agent_firewall_sequence_number - AND (w.next_seq IS NULL OR bl.sequence_number < w.next_seq) + AND bl.sequence_number < w.next_seq WHERE afi.id = ANY(sr.interception_ids) AND afi.agent_firewall_session_id IS NOT NULL AND afi.agent_firewall_sequence_number IS NOT NULL @@ -551,6 +558,105 @@ ORDER BY sp.session_id DESC ; +-- name: GetAIBridgeSessionTopDomains :many +-- Returns the most contacted destination hosts for an AI session, ordered by +-- call count descending and limited to the top @limit_ rows. total_domains is +-- the number of distinct domains across the whole session, used to render a +-- "+N more" overflow beyond the returned rows. Only HTTP egress is considered; +-- dns/git/fs boundary logs do not carry a domain in the same shape. +-- +-- Windowing mirrors the network_calls aggregation in ListAIBridgeSessions: +-- each interception's boundary logs fall in the open interval (this seq, next +-- interception's seq) within the same firewall session. The exclusive lower +-- bound drops the interception's own LLM-provider call. next_seq considers all +-- interceptions in the firewall session so windows never bleed across AI +-- sessions that share one firewall session, and falls back to the maximum +-- sequence_number for the last interception so the window stays an +-- index-satisfiable range. +WITH session_boundary_logs AS ( + SELECT bl.detail + FROM aibridge_interceptions afi + LEFT JOIN LATERAL ( + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq + FROM aibridge_interceptions nxt + WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id + AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number + ) w ON true + JOIN boundary_logs bl + ON bl.session_id = afi.agent_firewall_session_id + AND bl.sequence_number > afi.agent_firewall_sequence_number + AND bl.sequence_number < w.next_seq + WHERE afi.session_id = @session_id::text + AND afi.ended_at IS NOT NULL + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL + AND bl.proto = 'http' +), +extracted AS ( + -- Strip an optional scheme, then keep the host up to the first port, path, + -- query, or fragment delimiter. This assumes HTTP egress detail is a plain + -- scheme+host(+port) URL: it does not handle userinfo (user@host, which + -- would be captured into the host) or IPv6 literal hosts ([::1], where the + -- leading '[' is captured and the ':' terminates early). Boundary HTTP logs + -- do not currently emit those forms; revisit this extraction if they do. + SELECT substring(detail from '^(?:[A-Za-z][A-Za-z0-9+.-]*://)?([^/:?#]+)') AS domain + FROM session_boundary_logs +), +domains AS ( + SELECT domain, COUNT(*)::bigint AS count + FROM extracted + WHERE domain IS NOT NULL AND domain != '' + GROUP BY domain +) +SELECT + -- COALESCE keeps sqlc from typing the grouped column as nullable; the + -- domains CTE already filters out NULL/empty hosts. + COALESCE(domain, '')::text AS domain, + count, + COUNT(*) OVER ()::bigint AS total_domains +FROM domains +ORDER BY count DESC, domain ASC +LIMIT COALESCE(NULLIF(@limit_::integer, 0), 5); + +-- name: ListAIBridgeSessionNetworkCalls :many +-- Returns the individual Agent Firewall network calls made during an AI +-- session, ordered chronologically. All protocols are included, unlike +-- GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list +-- covers the same events the network_calls summary in ListAIBridgeSessions +-- counts. The list is capped at @limit_ rows, so its length equals the summary +-- total only for sessions at or below the cap. The summary stays authoritative +-- for whole-session totals. +-- +-- Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each +-- interception's boundary logs fall in the open interval (this seq, next +-- interception's seq) within the same firewall session. The exclusive lower +-- bound drops the interception's own LLM-provider call. next_seq considers all +-- interceptions in the firewall session so windows never bleed across AI +-- sessions that share one firewall session, and falls back to the maximum +-- sequence_number for the last interception so the window stays an +-- index-satisfiable range. +SELECT bl.* +FROM aibridge_interceptions afi +LEFT JOIN LATERAL ( + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq + FROM aibridge_interceptions nxt + WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id + AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number +) w ON true +JOIN boundary_logs bl + ON bl.session_id = afi.agent_firewall_session_id + AND bl.sequence_number > afi.agent_firewall_sequence_number + AND bl.sequence_number < w.next_seq +WHERE afi.session_id = @session_id::text + AND afi.ended_at IS NOT NULL + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL +-- created_at leads because a session can span several firewall sessions, whose +-- sequence numbers are independent streams. id breaks remaining ties so the row +-- that lands on the limit boundary is stable across identical requests. +ORDER BY bl.created_at ASC, bl.sequence_number ASC, bl.id ASC +LIMIT COALESCE(NULLIF(@limit_::integer, 0), 1000); + -- name: ListAIBridgeSessionThreads :many -- Returns all interceptions belonging to paginated threads within a session. -- Threads are paginated by (started_at, thread_id) cursor. diff --git a/coderd/database/queries/aicostcontrol.sql b/coderd/database/queries/aicostcontrol.sql index 6d9753ff6b295..5552e8c7af4a6 100644 --- a/coderd/database/queries/aicostcontrol.sql +++ b/coderd/database/queries/aicostcontrol.sql @@ -2,6 +2,9 @@ -- Upsert a batch of (provider, model) rows from a JSON array. Each element -- must have provider, model, and the four price fields; null prices are -- written as SQL NULL. +-- A conflicting row is only rewritten when a price differs, so updated_at +-- records when a price last changed. Prices are nullable and a NULL on +-- either side counts as a difference. INSERT INTO ai_model_prices ( provider, model, input_price, output_price, cache_read_price, cache_write_price ) @@ -18,7 +21,18 @@ ON CONFLICT (provider, model) DO UPDATE SET output_price = EXCLUDED.output_price, cache_read_price = EXCLUDED.cache_read_price, cache_write_price = EXCLUDED.cache_write_price, - updated_at = NOW(); + updated_at = NOW() +WHERE ( + ai_model_prices.input_price, + ai_model_prices.output_price, + ai_model_prices.cache_read_price, + ai_model_prices.cache_write_price +) IS DISTINCT FROM ( + EXCLUDED.input_price, + EXCLUDED.output_price, + EXCLUDED.cache_read_price, + EXCLUDED.cache_write_price +); -- name: GetAIModelPriceByProviderModel :one SELECT * @@ -122,23 +136,100 @@ WHERE user_id = @user_id -- name: GetOrganizationGroupsAISpend :many -- Returns AI spend limits and aggregate spend for groups in @group_ids that --- belong to @organization_id, on or after period_start until NOW. The spend --- limit is null when the group has no configured budget. +-- belong to @organization_id, on or after period_start until NOW. +-- spend_limit_micros is the per-member limit, null when the group has no budget. +-- total_spend_limit_micros is the combined budget of the members attributed to +-- the group, with each member's override replacing their share. It is null when +-- the group has no budget. -- The period_start parameter is normalized to its UTC calendar day. +-- TODO(AIGOV-527): unify effective group resolution in a single place. +WITH queried_groups AS ( + -- The requested groups that belong to the queried organization. + SELECT groups.id, groups.organization_id + FROM groups + WHERE groups.organization_id = @organization_id + AND groups.id = ANY(@group_ids::uuid[]) +), +candidate_users AS ( + -- Members of the queried groups. Uses group_members_expanded so the implicit + -- Everyone group counts. + SELECT DISTINCT member.user_id + FROM group_members_expanded member + WHERE member.group_id IN (SELECT id FROM queried_groups) +), +user_highest_group AS ( + -- Per user, the highest-limit group they belong to. Uses + -- group_members_expanded so the implicit Everyone group counts. + SELECT DISTINCT ON (member.user_id) + member.user_id, + budget.group_id, + budget.spend_limit_micros + FROM group_ai_budgets budget + JOIN group_members_expanded member ON member.group_id = budget.group_id + JOIN organizations ON organizations.id = member.organization_id + JOIN organization_members + ON organization_members.user_id = member.user_id + AND organization_members.organization_id = member.organization_id + WHERE member.user_id IN (SELECT user_id FROM candidate_users) + AND organizations.deleted = false + ORDER BY member.user_id, budget.spend_limit_micros DESC, organization_members.created_at ASC, budget.group_id ASC +), +effective AS ( + -- Effective budget group per user: an override wins over the highest-limit + -- group they belong to. Users with neither are left out, since the group they + -- fall back to has no budget and reports null. + SELECT + candidate_users.user_id, + COALESCE(override.group_id, user_highest_group.group_id) AS effective_group_id, + override.spend_limit_micros AS override_limit_micros + FROM candidate_users + LEFT JOIN user_ai_budget_overrides override ON override.user_id = candidate_users.user_id + LEFT JOIN user_highest_group ON user_highest_group.user_id = candidate_users.user_id +), +group_limits AS ( + -- Per attributed group, how many members take the group's own limit and the + -- combined limit of those carrying an override. + SELECT + effective.effective_group_id AS group_id, + count(*) FILTER (WHERE effective.override_limit_micros IS NULL) AS plain_member_count, + COALESCE(SUM(effective.override_limit_micros), 0)::BIGINT AS override_limit_sum + FROM effective + WHERE effective.effective_group_id IS NOT NULL + GROUP BY effective.effective_group_id +), +group_totals AS ( + -- Combined limit per budgeted group, counting members with no override at the + -- group's own limit and adding the overrides on top. Unbudgeted groups are + -- absent here, so the join below leaves their total null. + SELECT + queried_groups.id AS group_id, + (budget.spend_limit_micros * COALESCE(group_limits.plain_member_count, 0) + + COALESCE(group_limits.override_limit_sum, 0))::BIGINT AS total_spend_limit_micros + FROM queried_groups + JOIN group_ai_budgets budget ON budget.group_id = queried_groups.id + LEFT JOIN group_limits ON group_limits.group_id = queried_groups.id +), +group_spend AS ( + -- Spend per queried group over the period. + SELECT + spend.effective_group_id AS group_id, + COALESCE(SUM(spend.spend_micros), 0)::BIGINT AS current_spend_micros + FROM ai_user_daily_spend spend + WHERE spend.effective_group_id IN (SELECT id FROM queried_groups) + AND spend.day >= ((@period_start::timestamptz) AT TIME ZONE 'UTC')::date + GROUP BY spend.effective_group_id +) SELECT - groups.id AS group_id, - groups.organization_id AS organization_id, + queried_groups.id AS group_id, + queried_groups.organization_id AS organization_id, budget.spend_limit_micros AS spend_limit_micros, - COALESCE(SUM(spend.spend_micros), 0)::BIGINT AS current_spend_micros -FROM groups -LEFT JOIN group_ai_budgets budget ON budget.group_id = groups.id -LEFT JOIN ai_user_daily_spend spend - ON spend.effective_group_id = groups.id - AND spend.day >= ((@period_start::timestamptz) AT TIME ZONE 'UTC')::date -WHERE groups.organization_id = @organization_id - AND groups.id = ANY(@group_ids::uuid[]) -GROUP BY groups.id, budget.spend_limit_micros -ORDER BY groups.id; + group_totals.total_spend_limit_micros AS total_spend_limit_micros, + COALESCE(group_spend.current_spend_micros, 0)::BIGINT AS current_spend_micros +FROM queried_groups +LEFT JOIN group_ai_budgets budget ON budget.group_id = queried_groups.id +LEFT JOIN group_totals ON group_totals.group_id = queried_groups.id +LEFT JOIN group_spend ON group_spend.group_id = queried_groups.id +ORDER BY queried_groups.id; -- name: GetGroupMembersAISpend :many -- Returns each user's AI spend attributed to the queried group, on or after diff --git a/coderd/database/queries/files.sql b/coderd/database/queries/files.sql index cdf6e37ce081c..cefc3a2d02b4d 100644 --- a/coderd/database/queries/files.sql +++ b/coderd/database/queries/files.sql @@ -55,3 +55,41 @@ WHERE AND provisioner_jobs.type = 'template_version_import' AND file_id = @file_id ; + +-- name: DeleteCachedModuleFilesCreatedBetween :execrows +-- Deletes cached Terraform module archives ingested in the given time range and +-- clears the template version references to them. created_by and mimetype +-- identify a provisionerd-written module archive, matching the checks in +-- provisionerdserver, so user-uploaded template tarballs are never removed. +-- Only archives referenced by a template version are considered. +WITH doomed AS ( + SELECT + files.id + FROM + files + INNER JOIN + template_version_terraform_values + ON template_version_terraform_values.cached_module_files = files.id + WHERE + files.created_by = '00000000-0000-0000-0000-000000000000' + AND files.mimetype = 'application/x-tar' + AND files.created_at >= @created_at_after + AND files.created_at < @created_at_before +), cleared AS ( + -- The foreign key is NO ACTION, so references must be cleared before the + -- files rows can be deleted. Data-modifying CTEs always run to completion, + -- and the constraint is checked at the end of the statement. + UPDATE + template_version_terraform_values + SET + cached_module_files = NULL + WHERE + cached_module_files IN (SELECT id FROM doomed) + RETURNING 1 +) +DELETE FROM + files +USING + doomed +WHERE + files.id = doomed.id; diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index df59d3baf107f..cac44de84d4c8 100644 --- a/coderd/database/queries/proxies.sql +++ b/coderd/database/queries/proxies.sql @@ -120,7 +120,7 @@ WHERE ( ( @allow_access_url :: bool = true AND - url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]%)*' ) OR ( @allow_wildcard_hostname :: bool = true AND diff --git a/coderd/database/queries/user_secrets.sql b/coderd/database/queries/user_secrets.sql index 2bca3a0ca4b95..56e07b57ad3f0 100644 --- a/coderd/database/queries/user_secrets.sql +++ b/coderd/database/queries/user_secrets.sql @@ -13,7 +13,7 @@ WHERE id = @id; -- REST API list and get endpoints. SELECT id, user_id, name, description, - env_name, file_path, + env_name, file_path, enabled, created_at, updated_at FROM user_secrets WHERE user_id = @user_id @@ -37,7 +37,8 @@ INSERT INTO user_secrets ( value, value_key_id, env_name, - file_path + file_path, + enabled ) VALUES ( @id, @user_id, @@ -46,7 +47,8 @@ INSERT INTO user_secrets ( @value, @value_key_id, @env_name, - @file_path + @file_path, + @enabled ) RETURNING *; -- name: UpdateUserSecretByUserIDAndName :one @@ -57,6 +59,7 @@ SET description = CASE WHEN @update_description::bool THEN @description ELSE description END, env_name = CASE WHEN @update_env_name::bool THEN @env_name ELSE env_name END, file_path = CASE WHEN @update_file_path::bool THEN @file_path ELSE file_path END, + enabled = CASE WHEN @update_enabled::bool THEN @enabled ELSE enabled END, updated_at = CURRENT_TIMESTAMP WHERE user_id = @user_id AND name = @name RETURNING *; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 35ca899bac4d4..1fd901255ccaa 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7304,7 +7304,7 @@ func validateChatModelConfigProviderModel(aiProvider database.AIProvider, model return &chatModelConfigProviderModelError{ Response: codersdk.Response{ Message: "OpenRouter-like provider configured as type openai does not support slash-namespaced models.", - Detail: "Change the AI provider type to openrouter or openai-compat. The openai type strips the vendor prefix from slash-namespaced model IDs, routing to the wrong upstream provider.", + Detail: "Change the AI provider type to openrouter or openai-compat. Slash-namespaced model IDs on OpenRouter-like gateways require one of those provider types.", }, } } diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index d4762af6c37c0..f37f082cca03f 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -253,36 +253,6 @@ func (s *failNextUpdateChatModelConfigStore) UpdateChatModelConfig( return s.Store.UpdateChatModelConfig(ctx, arg) } -func requireChatUsageLimitExceededError( - t *testing.T, - err error, - wantSpentMicros int64, - wantLimitMicros int64, - wantResetsAt time.Time, -) *codersdk.ChatUsageLimitExceededResponse { - t.Helper() - - sdkErr, ok := codersdk.AsError(err) - require.True(t, ok) - require.Equal(t, http.StatusConflict, sdkErr.StatusCode()) - require.Equal(t, "Chat usage limit exceeded.", sdkErr.Message) - - limitErr := codersdk.ChatUsageLimitExceededFrom(err) - require.NotNil(t, limitErr) - require.Equal(t, "Chat usage limit exceeded.", limitErr.Message) - require.Equal(t, wantSpentMicros, limitErr.SpentMicros) - require.Equal(t, wantLimitMicros, limitErr.LimitMicros) - require.True( - t, - limitErr.ResetsAt.Equal(wantResetsAt), - "expected resets_at %s, got %s", - wantResetsAt.UTC().Format(time.RFC3339), - limitErr.ResetsAt.UTC().Format(time.RFC3339), - ) - - return limitErr -} - func enableDailyChatUsageLimit( ctx context.Context, t *testing.T, @@ -893,33 +863,6 @@ func TestPostChats(t *testing.T) { require.Equal(t, `content[0].type "image" is not supported.`, sdkErr.Detail) }) - t.Run("UsageLimitExceeded", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) - user := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) - wantResetsAt := enableDailyChatUsageLimit(ctx, t, db, 100) - - existingChat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: user.OrganizationID, - OwnerID: user.UserID, - LastModelConfigID: modelConfig.ID, - Title: "existing-limit-chat", - }) - insertAssistantCostMessage(t, db, existingChat.ID, modelConfig.ID, 100) - - _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: user.OrganizationID, - Content: []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: "over limit", - }}, - }) - requireChatUsageLimitExceededError(t, err, 100, 100, wantResetsAt) - }) - t.Run("NilOrganizationID", func(t *testing.T) { t.Parallel() @@ -7150,35 +7093,6 @@ func TestPostChatMessages(t *testing.T) { require.Equal(t, "content[0].text cannot be empty.", sdkErr.Detail) }) - t.Run("UsageLimitExceeded", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) - firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) - - chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: firstUser.OrganizationID, - Content: []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: "initial message for usage-limit test", - }}, - }) - require.NoError(t, err) - - wantResetsAt := enableDailyChatUsageLimit(ctx, t, db, 100) - insertAssistantCostMessage(t, db, chat.ID, modelConfig.ID, 100) - - _, err = client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ - Content: []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: "over limit", - }}, - }) - requireChatUsageLimitExceededError(t, err, 100, 100, wantResetsAt) - }) - t.Run("ChatNotFound", func(t *testing.T) { t.Parallel() @@ -8784,47 +8698,6 @@ func TestPatchChatMessage(t *testing.T) { require.True(t, foundFileInChat, "chat should preserve file_id after edit") }) - t.Run("UsageLimitExceeded", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) - firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) - - chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: firstUser.OrganizationID, - Content: []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: "hello before edit", - }}, - }) - require.NoError(t, err) - - messagesResult, err := client.GetChatMessages(ctx, chat.ID, nil) - require.NoError(t, err) - - var userMessageID int64 - for _, message := range messagesResult.Messages { - if message.Role == codersdk.ChatMessageRoleUser { - userMessageID = message.ID - break - } - } - require.NotZero(t, userMessageID) - - wantResetsAt := enableDailyChatUsageLimit(ctx, t, db, 100) - insertAssistantCostMessage(t, db, chat.ID, modelConfig.ID, 100) - - _, err = client.EditChatMessage(ctx, chat.ID, userMessageID, codersdk.EditChatMessageRequest{ - Content: []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: "edited over limit", - }}, - }) - requireChatUsageLimitExceededError(t, err, 100, 100, wantResetsAt) - }) - t.Run("MessageNotFound", func(t *testing.T) { t.Parallel() @@ -9665,51 +9538,6 @@ func TestRegenerateChatTitle(t *testing.T) { requireSDKError(t, err, http.StatusUnauthorized) }) - t.Run("UsageLimitExceeded", func(t *testing.T) { - t.Parallel() - - ctx := testutil.Context(t, testutil.WaitLong) - client, db := newChatClientWithDatabase(t) - firstUser := coderdtest.CreateFirstUser(t, client.Client) - modelConfig := createChatModelConfig(t, client) - - chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ - OrganizationID: firstUser.OrganizationID, - Content: []codersdk.ChatInputPart{{ - Type: codersdk.ChatInputPartTypeText, - Text: "chat over usage limit", - }}, - }) - require.NoError(t, err) - - wantResetsAt := enableDailyChatUsageLimit(ctx, t, db, 100) - insertAssistantCostMessage(t, db, chat.ID, modelConfig.ID, 100) - - _, err = db.UpdateChatStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateChatStatusParams{ - ID: chat.ID, - Status: database.ChatStatusWaiting, - WorkerID: uuid.NullUUID{}, - StartedAt: sql.NullTime{}, - HeartbeatAt: sql.NullTime{}, - LastError: pqtype.NullRawMessage{}, - }) - require.NoError(t, err) - - _, err = client.RegenerateChatTitle(ctx, chat.ID) - limitErr := codersdk.ChatUsageLimitExceededFrom(err) - require.NotNil(t, limitErr) - require.Equal(t, "Chat usage limit exceeded.", limitErr.Message) - require.Equal(t, int64(100), limitErr.SpentMicros) - require.Equal(t, int64(100), limitErr.LimitMicros) - require.True( - t, - limitErr.ResetsAt.Equal(wantResetsAt), - "expected resets_at %s, got %s", - wantResetsAt.UTC().Format(time.RFC3339), - limitErr.ResetsAt.UTC().Format(time.RFC3339), - ) - }) - t.Run("PasteOnlyChat", func(t *testing.T) { t.Parallel() diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index 47867e17b2c8b..7c7a28c07f3a3 100644 --- a/coderd/httpmw/workspaceagent.go +++ b/coderd/httpmw/workspaceagent.go @@ -109,7 +109,7 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil return } - subject, _, err := UserRBACSubject( + subject, userStatus, err := UserRBACSubject( ctx, opts.DB, row.WorkspaceTable.OwnerID, @@ -129,6 +129,12 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil }) return } + if userStatus != database.UserStatusActive { + httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ + Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", userStatus), + }) + return + } ctx = context.WithValue(ctx, workspaceAgentContextKey{}, row.WorkspaceAgent) ctx = context.WithValue(ctx, latestBuildContextKey{}, row.WorkspaceBuild) diff --git a/coderd/httpmw/workspaceagent_test.go b/coderd/httpmw/workspaceagent_test.go index 378d75927cc78..c18e7aab91de7 100644 --- a/coderd/httpmw/workspaceagent_test.go +++ b/coderd/httpmw/workspaceagent_test.go @@ -1,7 +1,10 @@ package httpmw_test import ( + "context" "database/sql" + "encoding/json" + "io" "net/http" "net/http/httptest" "testing" @@ -61,6 +64,38 @@ func TestWorkspaceAgent(t *testing.T) { require.Equal(t, http.StatusOK, res.StatusCode) }) + t.Run("InactiveUser", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + authToken := uuid.New() + req, rtr, workspace, _ := setup(t, db, authToken, httpmw.ExtractWorkspaceAgentAndLatestBuild( + httpmw.ExtractWorkspaceAgentAndLatestBuildConfig{ + DB: db, + Optional: false, + }), + ) + + _, err := db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{ + ID: workspace.OwnerID, + Status: database.UserStatusSuspended, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + rw := httptest.NewRecorder() + req.Header.Set(codersdk.SessionTokenHeader, authToken.String()) + rtr.ServeHTTP(rw, req) + + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + var response codersdk.Response + require.NoError(t, json.Unmarshal(body, &response)) + require.Contains(t, response.Message, `User is not active (status = "suspended")`) + }) + t.Run("Latest", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index 5dfcc43851dee..d5760cfc1cead 100644 --- a/coderd/notifications/dispatch/smtp.go +++ b/coderd/notifications/dispatch/smtp.go @@ -6,7 +6,9 @@ import ( "crypto/tls" "crypto/x509" _ "embed" + "encoding/base64" "fmt" + "mime" "mime/multipart" "mime/quotedprintable" "net" @@ -18,6 +20,7 @@ import ( "sync" "text/template" "time" + "unicode/utf8" "github.com/emersion/go-sasl" smtp "github.com/emersion/go-smtp" @@ -66,7 +69,7 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm return nil, xerrors.Errorf("render subject: %w", err) } - htmlBody := markdown.HTMLFromMarkdown(bodyTmpl) + htmlBody := markdown.HTMLFromNotificationMarkdown(bodyTmpl) plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl) if err != nil { return nil, xerrors.Errorf("render plaintext body: %w", err) @@ -202,7 +205,7 @@ func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) Delivery multipartWriter := multipart.NewWriter(multipartBuffer) _, _ = fmt.Fprintf(msg, "From: %s\r\n", headerFrom) _, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(recipients, ", ")) - _, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject) + _, _ = fmt.Fprintf(msg, "Subject: %s\r\n", encodeHeaderValue(subject)) _, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname()) _, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) _, _ = fmt.Fprintf(msg, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) @@ -573,3 +576,69 @@ func (s *SMTPHandler) password() (string, error) { } return s.cfg.Auth.Password.String(), nil } + +const ( + encodedWordPrefix = "=?utf-8?b?" + encodedWordSuffix = "?=" + // RFC 2047 limits an encoded-word to 75 characters including its + // delimiters, and base64 expands three bytes to four characters. + encodedWordMaxBytes = (75 - len(encodedWordPrefix) - len(encodedWordSuffix)) / 4 * 3 + + // maxHeaderValueOctets is the longest value emitted unfolded. RFC 5322 caps + // a line at 998 octets; the rest of the budget covers the field name. + maxHeaderValueOctets = 900 +) + +// encodeHeaderValue prepares a rendered value for use as a header value. Line +// breaks become spaces so the value cannot terminate the header and inject +// another. +func encodeHeaderValue(value string) string { + if strings.ContainsAny(value, "\r\n") { + value = strings.Map(func(r rune) rune { + if r == '\r' || r == '\n' { + return ' ' + } + return r + }, value) + } + // A forged encoded-word is printable ASCII, which mime.WordEncoder passes + // through untouched for the recipient's client to decode. + if strings.Contains(value, "=?") { + return encodeWords(value) + } + // Length is measured on the encoded form: Q-encoding expands a non-ASCII + // rune to three characters per byte, so a short value can still exceed the + // line limit. WordEncoder separates words with a space rather than folding, + // so anything over the limit goes to encodeWords. + if encoded := mime.QEncoding.Encode("utf-8", value); len(encoded) <= maxHeaderValueOctets { + return encoded + } + return encodeWords(value) +} + +// encodeWords emits value as RFC 2047 base64 encoded-words, joined with CRLF +// and a space so they both concatenate per RFC 2047 and fold per RFC 5322. +func encodeWords(value string) string { + var words []string + for len(value) > 0 { + n := encodedWordMaxBytes + if n >= len(value) { + n = len(value) + } else { + // Each encoded-word must decode on its own, so a multi-byte rune + // cannot straddle two of them. + for n > 0 && !utf8.RuneStart(value[n]) { + n-- + } + if n == 0 { + // A rune wider than the budget: emit it whole rather than + // splitting it into something undecodable. + _, n = utf8.DecodeRuneInString(value) + } + } + words = append(words, encodedWordPrefix+ + base64.StdEncoding.EncodeToString([]byte(value[:n]))+encodedWordSuffix) + value = value[n:] + } + return strings.Join(words, "\r\n ") +} diff --git a/coderd/notifications/dispatch/smtp/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl index cecba560af21f..2deb5505a4a8d 100644 --- a/coderd/notifications/dispatch/smtp/html.gotmpl +++ b/coderd/notifications/dispatch/smtp/html.gotmpl @@ -3,7 +3,7 @@ - {{ .Labels._subject }} + {{ .Labels._subject | html }}
@@ -11,23 +11,23 @@ {{ app_name | html }} Logo

- {{ .Labels._subject }} + {{ .Labels._subject | html }}

-

Hi {{ .UserName }},

+

Hi {{ .UserName | html }},

{{ .Labels._body }}
{{ range $action := .Actions }} - - {{ $action.Label }} + + {{ $action.Label | html }} {{ end }}
-

© {{ current_year }} Coder. All rights reserved - {{ base_url }}

-

Click here to manage your notification settings

-

Stop receiving emails like this

+

© {{ current_year | html }} Coder. All rights reserved - {{ base_url | html }}

+

Click here to manage your notification settings

+

Stop receiving emails like this

diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go index 2e7dff8cbecd6..187191d7c876e 100644 --- a/coderd/notifications/dispatch/smtp_internal_test.go +++ b/coderd/notifications/dispatch/smtp_internal_test.go @@ -2,6 +2,7 @@ package dispatch import ( "html" + "mime" "strings" "testing" @@ -9,8 +10,100 @@ import ( "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" + markdown "github.com/coder/coder/v2/coderd/render" ) +// Benign values, so a test measures only what its own payload injected. +func templateHelpers() map[string]any { + return map[string]any{ + "base_url": func() string { return "https://coder.example.com" }, + "current_year": func() string { return "2026" }, + "logo_url": func() string { return "https://coder.example.com/logo.png" }, + "app_name": func() string { return "Coder" }, + } +} + +func TestSMTPHTMLTemplateEscapesUntrustedValues(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + title string + userName string + actions []types.TemplateAction + injected string + }{ + { + name: "EntityEncodedAnchorInSubject", + title: `Template "<a href="https://attacker.example/login">Re-authenticate now</a>" deleted`, + userName: "Bobby", + injected: `Re-authenticate now`, + }, + { + name: "EntityEncodedImageInSubject", + title: `Workspace "<img src=x onerror="alert(1)">" marked dormant`, + userName: "Bobby", + injected: ``, + }, + { + name: "RawHTMLInUserName", + title: "Account suspended", + userName: `Bobby `, + injected: ``, + }, + { + name: "RawHTMLInActionLabel", + title: "Account suspended", + userName: "Bobby", + actions: []types.TemplateAction{ + {Label: ``, URL: "https://coder.example.com/"}, + }, + injected: ``, + }, + { + name: "RawHTMLInActionURL", + title: "Account suspended", + userName: "Bobby", + actions: []types.TemplateAction{ + {Label: "Open Coder", URL: `https://coder.example.com/?x=`}, + }, + injected: ``, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Decodes the entities, so the title arrives as live markup. + subject, err := markdown.PlaintextFromMarkdown(tc.title) + require.NoError(t, err) + + // Actions are set as the template sees them. The enqueuer renders + // them into JSON first, which rejects a `"` of its own accord. + payload := types.MessagePayload{ + NotificationTemplateID: "00000000-0000-0000-0000-000000000000", + UserName: tc.userName, + Actions: tc.actions, + Labels: map[string]string{ + "_subject": subject, + "_body": "

Test body

", + }, + } + + got, err := render.GoTemplate(htmlTemplate, payload, templateHelpers()) + require.NoError(t, err) + + escaped := html.EscapeString(tc.injected) + require.NotEqual(t, tc.injected, escaped, + "case carries no HTML to escape, so it guards nothing") + + require.NotContains(t, got, tc.injected, + "untrusted markup reached the rendered email: %s", got) + require.Contains(t, got, escaped, + "the value must still be displayed, entity encoded: %s", got) + }) + } +} + func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { t.Parallel() @@ -27,12 +120,9 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { "_body": "

Test body

", }, } - helpers := map[string]any{ - "base_url": func() string { return "https://coder.example.com" }, - "current_year": func() string { return "2026" }, - "logo_url": func() string { return logoURL }, - "app_name": func() string { return appName }, - } + helpers := templateHelpers() + helpers["logo_url"] = func() string { return logoURL } + helpers["app_name"] = func() string { return appName } got, err := render.GoTemplate(htmlTemplate, payload, helpers) require.NoError(t, err) @@ -43,6 +133,65 @@ func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) { require.False(t, strings.Contains(got, logoURL), "raw logo URL must not be rendered") } +// The template escapes every value it interpolates except _body, which is +// trusted rendered Markdown. The three values here cannot carry markup in +// production, so this test is the only thing that fails if their escaping is +// removed. +func TestSMTPHTMLTemplateEscapesTrustedValues(t *testing.T) { + t.Parallel() + + const injected = `a"onclick=alert(1)` + + for _, tc := range []struct { + name string + apply func(*types.MessagePayload, map[string]any) + }{ + { + // net/url preserves a quote in the query and --access-url is + // validated for its scheme only, so an operator can land this. + name: "BaseURL", + apply: func(_ *types.MessagePayload, h map[string]any) { + h["base_url"] = func() string { return "https://coder.example.com/?q=" + injected } + }, + }, + { + name: "CurrentYear", + apply: func(_ *types.MessagePayload, h map[string]any) { + h["current_year"] = func() string { return injected } + }, + }, + { + name: "NotificationTemplateID", + apply: func(p *types.MessagePayload, _ map[string]any) { + p.NotificationTemplateID = injected + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + payload := types.MessagePayload{ + NotificationTemplateID: "00000000-0000-0000-0000-000000000000", + UserName: "Test User", + Labels: map[string]string{ + "_subject": "Test notification", + "_body": "

Test body

", + }, + } + helpers := templateHelpers() + tc.apply(&payload, helpers) + + got, err := render.GoTemplate(htmlTemplate, payload, helpers) + require.NoError(t, err) + + require.NotContains(t, got, injected, + "raw value reached the rendered email: %s", got) + require.Contains(t, got, html.EscapeString(injected), + "the value must still be displayed, entity encoded: %s", got) + }) + } +} + func TestValidateFromAddr(t *testing.T) { t.Parallel() @@ -116,3 +265,102 @@ func TestValidateFromAddr(t *testing.T) { }) } } + +func TestEncodeHeaderValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + want string + }{ + { + name: "ascii is unchanged", + value: `User account "bobby" suspended`, + want: `User account "bobby" suspended`, + }, + { + name: "crlf is folded", + value: "Subject\r\nBcc: attacker@example.com", + want: "Subject Bcc: attacker@example.com", + }, + { + name: "bare newline is folded", + value: "Subject\nBcc: attacker@example.com", + want: "Subject Bcc: attacker@example.com", + }, + { + name: "non-ascii is q-encoded", + value: "Konto gelöscht", + want: "=?utf-8?q?Konto_gel=C3=B6scht?=", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := encodeHeaderValue(tc.value) + require.Equal(t, tc.want, got) + // The result must never be able to terminate its own header. + require.NotContains(t, got, "\r") + require.NotContains(t, got, "\n") + }) + } +} + +// TestEncodeHeaderValueEncodedWord covers a forged RFC 2047 encoded-word, which +// is printable ASCII and so passes mime.WordEncoder through to the client. +func TestEncodeHeaderValueEncodedWord(t *testing.T) { + t.Parallel() + + // Decodes to "URGENT: verify your account". + const forged = "=?utf-8?B?VVJHRU5UOiB2ZXJpZnkgeW91ciBhY2NvdW50?=" + got := encodeHeaderValue(forged + " shared a chat with you") + + // The forged word must not survive as something a client would decode. + require.NotContains(t, got, forged) + + // Decoded rather than compared: chunk boundaries are an implementation detail. + decoded, err := new(mime.WordDecoder).DecodeHeader(got) + require.NoError(t, err) + require.Equal(t, forged+" shared a chat with you", decoded) +} + +// TestEncodeHeaderValueFolds covers RFC 5322's 998-octet line limit, which +// mime.WordEncoder does not fold for. +func TestEncodeHeaderValueFolds(t *testing.T) { + t.Parallel() + + for name, value := range map[string]string{ + "non-ascii": strings.Repeat("é", 600), + "ascii": strings.Repeat("a b ", 400), + // A rune that does not divide evenly into the per-word budget must not + // be split across two encoded-words: each has to decode on its own. + "multibyte": strings.Repeat("日本語", 400), + // Under the raw byte limit and over it once Q-encoded, so these fail + // unless the gate measures the encoded form. + "200 accented runes": strings.Repeat("é", 200), + "300 cjk runes": strings.Repeat("日", 300), + "200 emoji": strings.Repeat("🎉", 200), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := encodeHeaderValue(value) + for _, line := range strings.Split(got, "\r\n") { + require.LessOrEqual(t, len(line), 998, + "a header line exceeds RFC 5322's limit: %d octets", len(line)) + } + // A CRLF must begin a continuation, or this is injection not folding. + for _, after := range strings.Split(got, "\r\n")[1:] { + require.True(t, strings.HasPrefix(after, " "), + "a CRLF was not followed by folding whitespace: %q", got) + } + + decoded, err := new(mime.WordDecoder).DecodeHeader(got) + require.NoError(t, err) + require.Equal(t, value, decoded) + }) + } +} diff --git a/coderd/notifications/dispatch/smtp_test.go b/coderd/notifications/dispatch/smtp_test.go index ee9b6a3d7a76d..c1624d9a326c6 100644 --- a/coderd/notifications/dispatch/smtp_test.go +++ b/coderd/notifications/dispatch/smtp_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "log" + "strings" "sync" "testing" @@ -632,3 +633,130 @@ func TestSMTPEnvelopeAndHeaders(t *testing.T) { }) } } + +// TestSMTPSubjectHeader: a rendered subject must not terminate the Subject +// header, and a non-ASCII one must be RFC 2047 encoded rather than raw 8-bit. +func TestSMTPSubjectHeader(t *testing.T) { + t.Parallel() + + const ( + hello = "localhost" + to = "bob@bob.com" + body = "This is the body" + ) + + tests := []struct { + name string + // title is the rendered title template handed to the dispatcher. + title string + // wantSubject, when set, is the exact Subject header value. + wantSubject string + // wantSubjectContains are substrings the single Subject line must hold, + // used where pinning exact output would test glamour, not the header. + wantSubjectContains []string + // wantAbsent must not appear anywhere in the transmitted message. + wantAbsent string + }{ + { + name: "plain subject", + title: "This is the subject", + wantSubject: "This is the subject", + }, + { + name: "newline cannot inject a header", + // PlaintextFromMarkdown keeps the paragraph break, so this reaches + // the header writer with newlines in it. + title: "Innocent subject\n\nBcc: attacker@example.com", + wantSubjectContains: []string{"Innocent subject", "Bcc: attacker@example.com"}, + wantAbsent: "\r\nBcc:", + }, + { + name: "non-ascii subject is encoded", + title: "Konto gelöscht", + wantSubject: "=?utf-8?q?Konto_gel=C3=B6scht?=", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + + cfg := codersdk.NotificationsEmailConfig{ + Hello: serpent.String(hello), + From: serpent.String("system@coder.com"), + } + + backend := smtptest.NewBackend(smtptest.Config{AuthMechanisms: []string{}}) + srv, listen, err := smtptest.CreateMockSMTPServer(backend, false) + require.NoError(t, err) + t.Cleanup(func() { + assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed) + }) + + var hp serpent.HostPort + require.NoError(t, hp.Set(listen.Addr().String())) + cfg.Smarthost = serpent.String(hp.String()) + + handler := dispatch.NewSMTPHandler(cfg, logger.Named("smtp")) + + var wg sync.WaitGroup + wg.Go(func() { + assert.NoError(t, srv.Serve(listen)) + }) + + require.Eventually(t, func() bool { + cl, err := smtptest.PingClient(listen, false, false) + if err != nil { + return false + } + _ = cl.Close() + return true + }, testutil.WaitShort, testutil.IntervalFast) + + payload := types.MessagePayload{ + Version: "1.0", + UserEmail: to, + Labels: make(map[string]string), + } + + dispatchFn, err := handler.Dispatcher(payload, tc.title, body, helpers()) + require.NoError(t, err) + + retryable, err := dispatchFn(ctx, uuid.New()) + require.NoError(t, err) + require.False(t, retryable) + + msg := backend.LastMessage() + require.NotNil(t, msg) + + // Assertions are scoped to the header block, which a blank line ends. + headers, _, found := strings.Cut(msg.Contents, "\r\n\r\n") + require.True(t, found, "message has no header/body separator") + + // The header must occupy exactly one line, whatever the value held. + require.Equal(t, 1, strings.Count(headers, "Subject: "), + "exactly one Subject header must be present") + _, after, found := strings.Cut(headers, "Subject: ") + require.True(t, found, "no Subject header in %q", headers) + subject, _, found := strings.Cut(after, "\r\n") + require.True(t, found, "Subject header is not CRLF terminated") + + if tc.wantSubject != "" { + require.Equal(t, tc.wantSubject, subject) + } + for _, want := range tc.wantSubjectContains { + require.Contains(t, subject, want) + } + if tc.wantAbsent != "" { + require.NotContains(t, headers, tc.wantAbsent, + "a value must not be able to inject an additional header") + } + + require.NoError(t, srv.Shutdown(ctx)) + wg.Wait() + }) + } +} diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 2c76d0e2df40a..0ef64fc044c2d 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -45,6 +45,7 @@ import ( "github.com/coder/coder/v2/coderd/notifications/dispatch/smtptest" "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/coderd/rbac" + markdown "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/util/syncmap" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -2410,3 +2411,90 @@ func (n *acquireSignalingInterceptor) AcquireNotificationMessages(ctx context.Co n.acquiredChan <- struct{}{} return messages, err } + +// renderCapture records what the notifier renders, so a test can assert on what +// a dispatcher would receive. +type renderCapture struct { + mu sync.Mutex + title, body string + captured chan struct{} + once sync.Once +} + +func newRenderCapture() *renderCapture { + return &renderCapture{captured: make(chan struct{})} +} + +func (c *renderCapture) Dispatcher(_ types.MessagePayload, title, body string, _ template.FuncMap) (dispatch.DeliveryFunc, error) { + return func(_ context.Context, _ uuid.UUID) (bool, error) { + c.mu.Lock() + c.title, c.body = title, body + c.mu.Unlock() + c.once.Do(func() { close(c.captured) }) + return false, nil + }, nil +} + +func (c *renderCapture) wait(t *testing.T) (title, body string) { + t.Helper() + testutil.TryReceive(testutil.Context(t, testutil.WaitLong), t, c.captured) + c.mu.Lock() + defer c.mu.Unlock() + return c.title, c.body +} + +// TestNotificationMarkdownInjection is the end-to-end regression test for +// https://linear.app/codercom/issue/SEC-93. +func TestNotificationMarkdownInjection(t *testing.T) { + t.Parallel() + + const payload = "Eve\n## URGENT: SSO certificate expiring\n" + + "[Re-authenticate now](https://coder-sso.attacker.example/login)" + + ctx := dbauthz.AsNotifier(testutil.Context(t, testutil.WaitSuperLong)) + store, pubsub := dbtestutil.NewDB(t) + logger := testutil.Logger(t) + + method := database.NotificationMethodSmtp + cfg := defaultNotificationsConfig(method) + capture := newRenderCapture() + + mgr, err := notifications.NewManager(cfg, store, pubsub, defaultHelpers(), createMetrics(), logger.Named("manager")) + require.NoError(t, err) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ + method: capture, + database.NotificationMethodInbox: &fakeHandler{}, + }) + t.Cleanup(func() { + assert.NoError(t, mgr.Stop(ctx)) + }) + + enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"), quartz.NewReal()) + require.NoError(t, err) + user := createSampleUser(t, store) + + // WHEN: the notification interpolates an attacker-controlled display name + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateUserAccountSuspended, map[string]string{ + "suspended_account_name": "eve", + "suspended_account_user_name": payload, + "initiator": "admin", + "account_type": "user", + }, "test") + require.NoError(t, err) + + mgr.Run(ctx) + _, body := capture.wait(t) + + // THEN: the rendered Markdown carries no structure from the display name. + html := markdown.HTMLFromNotificationMarkdown(body) + plain, err := markdown.PlaintextFromMarkdown(body) + require.NoError(t, err) + + for _, tag := range []string{" - You've reached your monthly AI budget limit + You've reached your monthly AI budget limit

- You've reached your monthly AI budget limit + You've reached your monthly AI budget limit

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden index 3927ab28e31dd..4d5ffdf4744c3 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - You're approaching your monthly AI budget limit + You're approaching your monthly AI budget limit

- You're approaching your monthly AI budget limit + You're approaching your monthly AI budget limit

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden index 769d5595dbc3e..b4a2d53763c8c 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskCompleted.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-workspace' completed + Task 'my-workspace' completed

- Task 'my-workspace' completed + Task 'my-workspace' completed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden index 5d0879bc82da2..1a17d690186d4 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskFailed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-workspace' failed + Task 'my-workspace' failed

- Task 'my-workspace' failed + Task 'my-workspace' failed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden index 578e39e91a293..0c4fea1cf9d84 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskIdle.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-workspace' is idle + Task 'my-workspace' is idle

- Task 'my-workspace' is idle + Task 'my-workspace' is idle

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden index 58a1f098f77e0..f22fc19c5b6da 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskPaused.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-task' is paused + Task 'my-task' is paused

- Task 'my-task' is paused + Task 'my-task' is paused

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden index 81d2498b579e4..4d71d3c8ec9d2 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskResumed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-task' has resumed + Task 'my-task' has resumed

- Task 'my-task' has resumed + Task 'my-task' has resumed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden index 21356601f6255..b0cc318c350cc 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTaskWorking.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Task 'my-workspace' is working + Task 'my-workspace' is working

- Task 'my-workspace' is working + Task 'my-workspace' is working

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden index 75af5a264e644..3103a58b97ab3 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeleted.html.golden @@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8 - Template "Bobby's Template" deleted + Template "Bobby's Template" deleted

- Template "Bobby's Template" deleted + Template "Bobby's Template" deleted

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden index 70c27eed18667..6eea0d7a8dbfa 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateTemplateDeprecated.html.golden @@ -35,7 +35,7 @@ Content-Type: text/html; charset=UTF-8 - Template 'alpha' has been deprecated + Template 'alpha' has been deprecated

- Template 'alpha' has been deprecated + Template 'alpha' has been deprecated

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden index 011ef84ebfb1c..ff4ee4976af59 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivated.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - User account "bobby" activated + User account "bobby" activated

- User account "bobby" activated + User account "bobby" activated

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden index 6fc619e4129a0..fa16f497bb9ed 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreated.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - User account "bobby" created + User account "bobby" created

- User account "bobby" created + User account "bobby" created

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden index cfcb22beec139..e52d1b71ab892 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeleted.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - User account "bobby" deleted + User account "bobby" deleted

- User account "bobby" deleted + User account "bobby" deleted

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden index 9664bc8892442..99cddd3593b11 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspended.html.golden @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 - User account "bobby" suspended + User account "bobby" suspended

- User account "bobby" suspended + User account "bobby" suspended

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden index 12e29c47ed078..819fc0d0e8504 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserRequestedOneTimePasscode.html.golden @@ -56,10 +56,10 @@ argin: 8px 0 32px; line-height: 1.5;">
=20 +2-4cdb-87f1-0486f1bea415&email=3Dbobby%2Fdrop-table%2Buser%40coder.com"= + style=3D"display: inline-block; padding: 13px 24px; background-color: #020= +617; color: #f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4= +px;"> Reset password =20 diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutoUpdated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutoUpdated.html.golden index 2304fbf01bdbf..378f13e534d72 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutoUpdated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutoUpdated.html.golden @@ -30,7 +30,8 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" updated automatically + Workspace "bobby-workspace" updated automatically</title= +> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -45,7 +46,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Workspace "bobby-workspace" updated automatically + Workspace "bobby-workspace" updated automatically </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutobuildFailed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutobuildFailed.html.golden index c132ffb47d9c1..7cb61a6701104 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutobuildFailed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutobuildFailed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Workspace "bobby-workspace" autobuild failed + Workspace "bobby-workspace" autobuild failed

- Workspace "bobby-workspace" autobuild failed + Workspace "bobby-workspace" autobuild failed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutostopReminder.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutostopReminder.html.golden index 350896eb0eb3b..175cf87ece91c 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutostopReminder.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceAutostopReminder.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Your workspace "bobby-workspace" will stop soon + Your workspace "bobby-workspace" will stop soon

- Your workspace "bobby-workspace" will stop soon + Your workspace "bobby-workspace" will stop soon

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden index 9fccba0b1f239..97f6e720a2861 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden @@ -28,7 +28,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace 'bobby-workspace' has been created + Workspace 'bobby-workspace' has been created

- Workspace 'bobby-workspace' has been created + Workspace 'bobby-workspace' has been created

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden index fcc9b57f17b9f..46ebe956cb2f8 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" deleted + Workspace "bobby-workspace" deleted

- Workspace "bobby-workspace" deleted + Workspace "bobby-workspace" deleted

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden index 7c1f7192b1fc8..cf1e96a2edac3 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDeleted_CustomAppearance.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" deleted + Workspace "bobby-workspace" deleted

- Workspace "bobby-workspace" deleted + Workspace "bobby-workspace" deleted

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden index ea9e1b697957b..a6ee986bd9d43 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant.html.golden @@ -34,7 +34,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" marked as dormant + Workspace "bobby-workspace" marked as dormant

- Workspace "bobby-workspace" marked as dormant + Workspace "bobby-workspace" marked as dormant

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden index e41eeb19fee03..97c4d95bdf8e2 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" marked as dormant + Workspace "bobby-workspace" marked as dormant

- Workspace "bobby-workspace" marked as dormant + Workspace "bobby-workspace" marked as dormant

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden index 2f7bb2771c8a9..f1b67ebc39c50 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManualBuildFailed.html.golden @@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8 - Workspace "bobby-workspace" manual build failed + Workspace "bobby-workspace" manual build failed

- Workspace "bobby-workspace" manual build failed + Workspace "bobby-workspace" manual build failed

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden index 0e70293b09065..d971cd94c1541 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden @@ -31,7 +31,8 @@ Content-Type: text/html; charset=UTF-8 - Workspace 'bobby-workspace' has been manually updated + Workspace 'bobby-workspace' has been manually updated</t= +itle> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -46,7 +47,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Workspace 'bobby-workspace' has been manually updated + Workspace 'bobby-workspace' has been manually updated </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden index 3937a96cd930e..a3fa836651455 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceMarkedForDeletion.html.golden @@ -31,7 +31,7 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Workspace "bobby-workspace" marked for deletion + Workspace "bobby-workspace" marked for deletion

- Workspace "bobby-workspace" marked for deletion + Workspace "bobby-workspace" marked for deletion

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden index 1e65a1eab12fc..15bffe4a810d7 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk.html.golden @@ -27,7 +27,8 @@ Content-Type: text/html; charset=UTF-8 - Your workspace "bobby-workspace" is low on volume space + Your workspace "bobby-workspace" is low on volume space<= +/title> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -42,7 +43,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Your workspace "bobby-workspace" is low on volume space + Your workspace "bobby-workspace" is low on volume space </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk_MultipleVolumes.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk_MultipleVolumes.html.golden index aad0c2190c25a..1f134cb07399c 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk_MultipleVolumes.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfDisk_MultipleVolumes.html.golden @@ -31,7 +31,8 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Your workspace "bobby-workspace" is low on volume space + Your workspace "bobby-workspace" is low on volume space<= +/title> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -46,7 +47,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Your workspace "bobby-workspace" is low on volume space + Your workspace "bobby-workspace" is low on volume space </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfMemory.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfMemory.html.golden index b75c2032003ee..44a0e92b24921 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfMemory.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceOutOfMemory.html.golden @@ -28,7 +28,8 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Your workspace "bobby-workspace" is low on memory + Your workspace "bobby-workspace" is low on memory</title= +> </head> <body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-= ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel= @@ -43,7 +44,7 @@ er Logo" style=3D"height: 40px;" /> </div> <h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m= argin: 8px 0 32px; line-height: 1.5;"> - Your workspace "bobby-workspace" is low on memory + Your workspace "bobby-workspace" is low on memory </h1> <div style=3D"line-height: 1.5;"> <p>Hi Bobby,</p> diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountActivated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountActivated.html.golden index b86fd4bf6395d..b2c0fb7c1afc2 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountActivated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountActivated.html.golden @@ -27,7 +27,7 @@ Content-Type: text/html; charset=UTF-8 <meta charset=3D"UTF-8" /> <meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale= =3D1.0" /> - <title>Your account "bobby" has been activated + Your account "bobby" has been activated

- Your account "bobby" has been activated + Your account "bobby" has been activated

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden index 277195a2bd427..74dee9477963e 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateYourAccountSuspended.html.golden @@ -25,7 +25,7 @@ Content-Type: text/html; charset=UTF-8 - Your account "bobby" has been suspended + Your account "bobby" has been suspended

- Your account "bobby" has been suspended + Your account "bobby" has been suspended

Hi Bobby,

diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden index 09bf9431cdeed..6ae1b693ac2df 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceResourceReplaced.json.golden @@ -38,5 +38,5 @@ "title": "There might be a problem with a recently claimed prebuilt workspace", "title_markdown": "There might be a problem with a recently claimed prebuilt workspace", "body": "Workspace my-workspace was claimed from a prebuilt workspace by prebuilds-claimer.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\ndocker_container[0] was replaced due to changes to env, hostname\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see this guide (https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the particle-accelerator preset.", - "body_markdown": "\nWorkspace **my-workspace** was claimed from a prebuilt workspace by **prebuilds-claimer**.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\n- _docker_container[0]_ was replaced due to changes to _env, hostname_\n\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see [this guide](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the **particle-accelerator** preset.\n" + "body_markdown": "\nWorkspace **my-workspace** was claimed from a prebuilt workspace by **prebuilds-claimer**.\n\nDuring the claim, Terraform destroyed and recreated the following resources\nbecause one or more immutable attributes changed:\n\n- _docker_container\\[0\\]_ was replaced due to changes to _env, hostname_\n\n\nWhen Terraform must change an immutable attribute, it replaces the entire resource.\nIf you’re using prebuilds to speed up provisioning, unexpected replacements will slow down\nworkspace startup—even when claiming a prebuilt environment.\n\nFor tips on preventing replacements and improving claim performance, see [this guide](https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces#preventing-resource-replacement).\n\nNOTE: this prebuilt workspace used the **particle-accelerator** preset.\n" } \ No newline at end of file diff --git a/coderd/notifications/types/escape.go b/coderd/notifications/types/escape.go new file mode 100644 index 0000000000000..d267cc7651ed6 --- /dev/null +++ b/coderd/notifications/types/escape.go @@ -0,0 +1,58 @@ +package types + +import "github.com/coder/coder/v2/coderd/render" + +// EscapedForMarkdown returns a copy of the payload whose string values have +// Markdown structure neutralized, for rendering the title and body templates. +// The receiver is left untouched: the stored payload keeps the values as they +// were enqueued, which is what the webhook dispatcher surfaces to consumers and +// what the SMTP dispatcher escapes at its own HTML sinks. +func (p MessagePayload) EscapedForMarkdown() MessagePayload { + out := p + out.UserName = render.EscapeMarkdown(p.UserName) + + if p.Labels != nil { + labels := make(map[string]string, len(p.Labels)) + for k, v := range p.Labels { + labels[k] = render.EscapeMarkdown(v) + } + out.Labels = labels + } + + if p.Data != nil { + data := make(map[string]any, len(p.Data)) + for k, v := range p.Data { + data[k] = escapeValue(v) + } + out.Data = data + } + return out +} + +// escapeValue walks a decoded JSON value and escapes its string leaves. Numbers, +// booleans and nulls pass through unchanged so that template comparisons such as +// `{{if gt $version.failed_count 1}}` keep working. +// +// Nested keys are escaped too: a key is content whenever a template ranges with +// two variables, as the resource replacements body does over Terraform resource +// addresses. +func escapeValue(v any) any { + switch t := v.(type) { + case string: + return render.EscapeMarkdown(t) + case map[string]any: + out := make(map[string]any, len(t)) + for k, vv := range t { + out[render.EscapeMarkdown(k)] = escapeValue(vv) + } + return out + case []any: + out := make([]any, len(t)) + for i, vv := range t { + out[i] = escapeValue(vv) + } + return out + default: + return v + } +} diff --git a/coderd/notifications/types/escape_test.go b/coderd/notifications/types/escape_test.go new file mode 100644 index 0000000000000..f10e332a42f94 --- /dev/null +++ b/coderd/notifications/types/escape_test.go @@ -0,0 +1,132 @@ +package types_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/notifications/types" +) + +func TestEscapedForMarkdown(t *testing.T) { + t.Parallel() + + t.Run("EscapesLabelsAndUserName", func(t *testing.T) { + t.Parallel() + + payload := types.MessagePayload{ + UserName: "Eve [Re-auth](https://attacker.example)", + Labels: map[string]string{ + "suspended_account_user_name": "Eve\n## URGENT\n[Re-auth](https://attacker.example)", + "limit_source": "user_override", + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, `Eve \[Re-auth\]\(https://attacker.example\)`, escaped.UserName) + require.NotContains(t, escaped.Labels["suspended_account_user_name"], "[Re-auth](") + // Control values must survive so template conditionals keep matching. + require.Equal(t, "user_override", escaped.Labels["limit_source"]) + }) + + t.Run("LeavesReceiverUntouched", func(t *testing.T) { + t.Parallel() + + // The webhook dispatcher surfaces the payload verbatim, so the original + // must not mutate. + payload := types.MessagePayload{ + UserName: "Eve [x](https://attacker.example)", + Labels: map[string]string{"name": "bobby-workspace", "risky": "[x](https://attacker.example)"}, + Data: map[string]any{"user": map[string]any{"name": "[x](https://attacker.example)"}}, + } + + _ = payload.EscapedForMarkdown() + + require.Equal(t, "Eve [x](https://attacker.example)", payload.UserName) + require.Equal(t, "[x](https://attacker.example)", payload.Labels["risky"]) + require.Equal(t, "[x](https://attacker.example)", + payload.Data["user"].(map[string]any)["name"]) + }) + + t.Run("RecursesIntoData", func(t *testing.T) { + t.Parallel() + + payload := types.MessagePayload{ + Data: map[string]any{ + "user": map[string]any{"name": "[x](https://attacker.example)"}, + "archived_chats": []any{ + map[string]any{"title": "[x](https://attacker.example)"}, + }, + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, `\[x\]\(https://attacker.example\)`, + escaped.Data["user"].(map[string]any)["name"]) + require.Equal(t, `\[x\]\(https://attacker.example\)`, + escaped.Data["archived_chats"].([]any)[0].(map[string]any)["title"]) + }) + + t.Run("PreservesNonStringLeaves", func(t *testing.T) { + t.Parallel() + + // Body templates compare numbers, as {{if gt $version.failed_count 1}} + // does, so coercing them to strings would break the comparison. + payload := types.MessagePayload{ + Data: map[string]any{ + "failed_count": 3.0, + "enabled": true, + "absent": nil, + "versions": []any{map[string]any{"failed_count": 1.0}}, + }, + } + + escaped := payload.EscapedForMarkdown() + require.Equal(t, 3.0, escaped.Data["failed_count"]) + require.Equal(t, true, escaped.Data["enabled"]) + require.Nil(t, escaped.Data["absent"]) + require.Equal(t, 1.0, escaped.Data["versions"].([]any)[0].(map[string]any)["failed_count"]) + }) + + t.Run("NilMapsStayNil", func(t *testing.T) { + t.Parallel() + + escaped := types.MessagePayload{}.EscapedForMarkdown() + require.Nil(t, escaped.Labels) + require.Nil(t, escaped.Data) + }) + + t.Run("EscapesNestedMapKeys", func(t *testing.T) { + t.Parallel() + + // A nested key is content when a template ranges with two variables. + payload := types.MessagePayload{ + Data: map[string]any{ + "replacements": map[string]any{ + "[Re-auth](https://attacker.example/login)": "paths", + "null_resource.ok": "other", + }, + }, + } + + escaped := payload.EscapedForMarkdown() + replacements, ok := escaped.Data["replacements"].(map[string]any) + require.True(t, ok) + require.Contains(t, replacements, `\[Re-auth\]\(https://attacker.example/login\)`) + require.NotContains(t, replacements, "[Re-auth](https://attacker.example/login)") + // A key with nothing to escape must stay resolvable by name. + require.Contains(t, replacements, "null_resource.ok") + }) + + t.Run("LeavesTopLevelDataKeysAlone", func(t *testing.T) { + t.Parallel() + + // Top-level .Data keys are dereferenced by name, not content, so escaping + // one breaks the lookup. + payload := types.MessagePayload{ + Data: map[string]any{"failed_builds": []any{"x"}}, + } + + require.Contains(t, payload.EscapedForMarkdown().Data, "failed_builds") + }) +} diff --git a/coderd/render/escape.go b/coderd/render/escape.go new file mode 100644 index 0000000000000..7c7d2b65f79db --- /dev/null +++ b/coderd/render/escape.go @@ -0,0 +1,188 @@ +package render + +import "strings" + +// Character classes for EscapeMarkdown, split by where each character carries +// structural meaning. The split is what keeps escaping away from the enum-like +// label values that body templates compare with `eq`, such as "user_override", +// "bobby-workspace" and "1.5": escaping one changes template control flow. +const ( + // inlineCritical characters can produce a link, an image, an angle autolink, + // or forge an escape from anywhere in a value, so they are always escaped. + // + // Backtick is here, not in blockStart, because a fence's info string is an + // HTML sink: gomarkdown writes it into class="language-..." unescaped, and + // SkipHTML does not apply to a CodeBlock node. Escaping only the leading + // backtick would leave two, which open an inline code span. + inlineCritical = "\\[]()!<`" + + // blockStart characters carry structural meaning only as the first + // non-space character of a line, so they are escaped only there. + blockStart = `#-+.>|` + + // leadingEmphasis characters carry inline meaning anywhere but also open a + // block construct in leading position: "* " starts a bullet list, and three + // or more of either character starts a thematic break. Escaping them only + // there costs emphasis that begins on a line boundary. + leadingEmphasis = `*_` + + // foldStart characters also carry meaning only at the start of a line, but + // glamour does not honor a backslash before them, so escaping would leave a + // literal backslash in the plaintext part. The preceding line break becomes + // a space instead, denying them the line-start position. + // + // ":" is here for the GFM delimiter row ":-- | --:" as well as definition + // lists. Escaping "|" does not reach that row: its pipes are mid-line. + foldStart = `=~:` + + // maxLeadingSpaces is the widest indentation a line may keep, since four + // spaces open an indented code block and a space cannot be escaped. + maxLeadingSpaces = 3 +) + +// EscapeMarkdown neutralizes Markdown structure in an untrusted value so that it +// renders as literal text through both HTMLFromNotificationMarkdown and +// PlaintextFromMarkdown. Line breaks are preserved so multi-line values keep +// their shape. Other control characters are dropped, being the carrier for SMTP +// header injection. +// +// Known residual: a template that wraps the value in a code span. CommonMark +// does not process escapes inside one, so the backslashes emitted here reach +// the reader. Nothing here can detect that, since the sink is decided after +// this runs. +func EscapeMarkdown(s string) string { + if s == "" { + return s + } + + lines := strings.Split(stripControl(s), "\n") + var b strings.Builder + b.Grow(len(s) + len(s)/8) + + for i, line := range lines { + if i > 0 { + // Joining a fold-start line to the previous one takes it out of + // leading position. + if opensFoldConstruct(line) { + _ = b.WriteByte(' ') + } else { + _ = b.WriteByte('\n') + } + } + // The first line has no preceding break to fold, so escaping is the only + // lever left there. + _, _ = b.WriteString(escapeLine(line, i == 0 && isLeadingFoldConstruct(line))) + } + return b.String() +} + +// stripControl keeps line breaks, turns the other whitespace controls into +// spaces and drops the rest. Carriage returns are folded rather than kept so a +// value cannot terminate an SMTP header. +func stripControl(s string) string { + if strings.IndexFunc(s, isStrippable) < 0 { + return s + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch { + case r == '\n': + _, _ = b.WriteRune(r) + case r == '\r' || r == '\t' || r == '\v' || r == '\f': + _, _ = b.WriteRune(' ') + case r < 0x20 || r == 0x7f: + // Dropped. + default: + _, _ = b.WriteRune(r) + } + } + return b.String() +} + +func isStrippable(r rune) bool { + return r != '\n' && (r < 0x20 || r == 0x7f) +} + +// escapeLine escapes one line's structural characters and truncates its +// indentation to maxLeadingSpaces. +// +// escapeFold additionally escapes a leading "=" or "~". Only EscapeMarkdown's +// first line passes it, and only when that line really is a fold construct. +func escapeLine(line string, escapeFold bool) string { + var b strings.Builder + b.Grow(len(line)) + + leading := true + spaces := 0 + // digitRun reports whether the line so far is nothing but indentation and + // digits, which is the only position where "." opens an ordered list. + digitRun := false + for i, r := range line { + switch { + case r < 0x80 && strings.ContainsRune(inlineCritical, r): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case leading && r == ' ': + // Indentation keeps the next character in leading position. + if spaces < maxLeadingSpaces { + _, _ = b.WriteRune(r) + spaces++ + } + continue + case leading && r < 0x80 && strings.ContainsRune(blockStart+leadingEmphasis, r): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case leading && escapeFold && (r == '=' || r == '~'): + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + case digitRun && r == '.' && closesMarker(line, i): + // The "1." of an ordered list. Its sibling "1)" needs no case + // because ")" is inlineCritical and is always escaped. + _ = b.WriteByte('\\') + _, _ = b.WriteRune(r) + default: + _, _ = b.WriteRune(r) + } + digitRun = (leading || digitRun) && r >= '0' && r <= '9' + leading = false + } + return b.String() +} + +// closesMarker reports whether the single-byte list-marker delimiter at i is +// followed by a space or ends the line, as CommonMark requires of a marker. +// That requirement is what keeps a value such as "1.5" out of the escaped set. +// Tabs need no handling: stripControl has already folded them into spaces. +func closesMarker(line string, i int) bool { + return i+1 == len(line) || line[i+1] == ' ' +} + +// opensFoldConstruct reports whether a line's first non-space character is a +// foldStart character. Approximate on purpose: it only decides whether to drop +// a line break, which costs nothing. +func opensFoldConstruct(line string) bool { + t := strings.TrimLeft(line, " ") + if t == "" { + return false + } + return strings.ContainsRune(foldStart, rune(t[0])) +} + +// isLeadingFoldConstruct reports whether a line is itself a tilde fence opener +// or a Setext "=" underline, rather than merely starting with one of those +// characters. Exact, because it governs escaping and the backslash is visible: +// "=> next" must not acquire one, while "~~~" must, since an unterminated fence +// at the start of a title renders the Subject, and heading empty. +// +// Indentation is ignored: escapeLine truncates it to maxLeadingSpaces, which +// still leaves the line able to open a block. ":" is excluded because a +// definition list or table needs a preceding line that a first line lacks. +func isLeadingFoldConstruct(line string) bool { + t := strings.TrimLeft(line, " ") + if strings.HasPrefix(t, "~~~") { + return true + } + t = strings.TrimRight(t, " ") + return t != "" && strings.Trim(t, "=") == "" +} diff --git a/coderd/render/escape_internal_test.go b/coderd/render/escape_internal_test.go new file mode 100644 index 0000000000000..8cf2078f92bfa --- /dev/null +++ b/coderd/render/escape_internal_test.go @@ -0,0 +1,313 @@ +package render + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// asciiPunctuation is every ASCII punctuation character, the set CommonMark +// declares escapable. +const asciiPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + +// TestEscapableSet pins the escapes each renderer honors, which is what +// EscapeMarkdown's character classes are derived from. +func TestEscapableSet(t *testing.T) { + t.Parallel() + + // Measured against gomarkdown and glamour as vendored today. + const ( + wantHTML = "!#$&()*+-.:<>[\\]^_`{|}~" + wantPlain = "!#()*+-.<>[\\]_`{|}" + ) + + var gotHTML, gotPlain strings.Builder + for _, r := range asciiPunctuation { + escaped := `X\` + string(r) + `Y` + + // HTML escapes markup characters, so compare the entity form. + wantLiteral := "X" + string(r) + "Y" + switch r { + case '<': + wantLiteral = "X<Y" + case '>': + wantLiteral = "X>Y" + case '&': + wantLiteral = "X&Y" + case '"': + wantLiteral = "X"Y" + } + html := strings.TrimSuffix(strings.TrimPrefix(HTMLFromMarkdown(escaped), "<p>"), "</p>") + if html == wantLiteral { + _, _ = gotHTML.WriteRune(r) + } + + plain, err := PlaintextFromMarkdown(escaped) + require.NoError(t, err) + if plain == "X"+string(r)+"Y" { + _, _ = gotPlain.WriteRune(r) + } + } + + require.Equal(t, wantHTML, gotHTML.String(), + "the set of characters gomarkdown honors as escapes has changed; re-derive EscapeMarkdown's character classes") + require.Equal(t, wantPlain, gotPlain.String(), + "the set of characters glamour honors as escapes has changed; re-derive EscapeMarkdown's character classes") + + // Every character EscapeMarkdown escapes must be honored by both renderers. + for _, r := range inlineCritical + blockStart + leadingEmphasis { + assert.Contains(t, wantHTML, string(r), "gomarkdown does not honor \\%s", string(r)) + assert.Contains(t, wantPlain, string(r), "glamour does not honor \\%s", string(r)) + } + // foldStart characters are folded precisely because they are not escapable. + for _, r := range foldStart { + assert.NotContains(t, wantPlain, string(r), + "glamour now honors \\%s, so it could be escaped instead of folded", string(r)) + } +} + +// TestEscapeMarkdownControlValues guards the label values body templates compare +// with `eq`. Escaping one silently changes template control flow. +func TestEscapeMarkdownControlValues(t *testing.T) { + t.Parallel() + + for _, v := range []string{ + "user_override", // migrations/000553, {{if eq .Labels.limit_source "user_override"}} + "service", // migrations/000568, {{if eq .Labels.account_type "service"}} + "0", // migrations/000480, {{if eq .Data.retention_days "0"}} + "autobuild", + "initiator", + "user-override", + "1.5", + "10.0.0.1", + "bobby-workspace", + } { + require.Equal(t, v, EscapeMarkdown(v), "escaping changed a control value") + } +} + +func TestEscapeMarkdown(t *testing.T) { + t.Parallel() + + // Emphasis and code tags are accepted residuals: no destination. + structuralTags := []string{ + "<a ", "<img ", "<h1", "<h2", "<h3", "<h4", "<h5", "<h6", + "<ul", "<ol", "<hr", "<blockquote", "<table", + } + + t.Run("NeutralisesStructure", func(t *testing.T) { + t.Parallel() + + type structureCase struct { + name string + value string + // inertRaw marks a value neutralized by something other than marker + // escaping. Leaving it unset on one fails the liveness check below. + inertRaw bool + } + + for _, tc := range []structureCase{ + {name: "DisclosurePayload", value: "Eve\n## URGENT: SSO certificate expiring\n[Re-authenticate now](https://coder-sso.attacker.example/login)"}, + {name: "InlineLink", value: "[Re-authenticate now](https://attacker.example/login)"}, + // A link reference definition is not recognized mid-paragraph. + {name: "ReferenceLink", value: "[Re-auth][1]\n\n[1]: https://attacker.example", inertRaw: true}, + {name: "Image", value: "![px](https://tracker.attacker.example/p.gif)"}, + {name: "AngleAutolink", value: "Eve <https://attacker.example>"}, + // Neutralized by autolinking being off. See + // TestEscapeMarkdownNoAutolink. + {name: "BareURL", value: "Eve https://attacker.example/login", inertRaw: true}, + {name: "Mailto", value: "Eve mailto:eve@attacker.example", inertRaw: true}, + {name: "ATXHeading", value: "Eve\n## URGENT"}, + {name: "SetextH1", value: "URGENT: re-auth required\n===\nx"}, + {name: "SetextH1Spaced", value: "Eve\n=== \n#### x"}, + {name: "SetextH1Repeated", value: "Eve\n===\n===\nx"}, + {name: "SetextH2", value: "Eve\n---\nx"}, + {name: "ThematicBreak", value: "Eve\n----\nx"}, + {name: "ThematicBreakStars", value: "Eve\n***\nnext"}, + {name: "ThematicBreakUnderscores", value: "Eve\n___\nnext"}, + {name: "ThematicBreakSpacedStars", value: "Eve\n* * *\nnext"}, + {name: "ThematicBreakSpacedUnderscores", value: "Eve\n_ _ _\nnext"}, + {name: "BulletList", value: "Eve\n- one\n- two"}, + {name: "BulletListStar", value: "Eve\n* one\n* two"}, + {name: "BulletListStarIndented", value: "Eve\n * one\n * two"}, + {name: "OrderedList", value: "Eve\n1. one\n2. two"}, + {name: "OrderedListMultiDigit", value: "Eve\n99. one\n100. two"}, + {name: "OrderedListParen", value: "Eve\n1) one\n2) two"}, + {name: "Blockquote", value: "Eve\n> quoted"}, + // Neutralized by the Tables extension being off; the escaper's own + // handling is covered by TestEscapeMarkdownColon. + {name: "Table", value: "a | b\n--- | ---\nc | d", inertRaw: true}, + // Neutralized by the safelink policy. See + // TestEscapeMarkdownNoAutolink/SafelinkRejectsUnsafeSchemes. + {name: "JavascriptScheme", value: "[click](javascript:alert(1))", inertRaw: true}, + // Asserts escaping does not re-enable the value's own backslashes. + {name: "EscapeForging", value: `Eve \[Re-auth\](https://attacker.example)`, inertRaw: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Rendered twice, the second with line breaks doubled: only a + // blank line lets a block construct interrupt a paragraph. + rawProducedTag := false + for _, value := range []string{tc.value, strings.ReplaceAll(tc.value, "\n", "\n\n")} { + escaped := EscapeMarkdown(value) + html := HTMLFromNotificationMarkdown(suspendedBody(escaped)) + plain, err := PlaintextFromMarkdown(suspendedBody(escaped)) + require.NoError(t, err) + raw := HTMLFromNotificationMarkdown(suspendedBody(value)) + + for _, tag := range structuralTags { + assert.NotContains(t, html, tag, "value %q rendered HTML: %s", value, html) + rawProducedTag = rawProducedTag || strings.Contains(raw, tag) + } + // A backslash the value did not contain means a character the + // renderer does not honor was escaped. + if !strings.Contains(value, `\`) { + assert.NotContains(t, html, `\`, "value %q leaked a literal backslash into HTML", value) + assert.NotContains(t, plain, `\`, "value %q leaked a literal backslash into plaintext", value) + } + } + + // Without this, a row whose value can never reach a line-start + // position passes whether or not EscapeMarkdown runs. + if !tc.inertRaw { + assert.True(t, rawProducedTag, + "vacuous row: %q produces no structural tag even unescaped, so the assertions above guard nothing; fix the value or set inertRaw with a reason", + tc.value) + } + }) + } + }) + + t.Run("PreservesBenignValues", func(t *testing.T) { + t.Parallel() + + // Also why this change leaves the notification golden files untouched. + for _, value := range []string{ + "William Tables", + "bobby-workspace", + "Bobby's Template", + "O'Brien-Smith (Eng) 100%", + "autodeleted due to dormancy (autobuild)", + "José Müller 日本語", + // Documented multi-line custom notification, see + // docs/admin/monitoring/notifications/index.md. + "Test results:\n • ✅ success", + "Test results:\n • ❌ failed (3 tests failed)", + } { + t.Run(value, func(t *testing.T) { + t.Parallel() + + escaped := EscapeMarkdown(value) + require.Equal(t, HTMLFromNotificationMarkdown(suspendedBody(value)), HTMLFromNotificationMarkdown(suspendedBody(escaped))) + + wantPlain, err := PlaintextFromMarkdown(suspendedBody(value)) + require.NoError(t, err) + gotPlain, err := PlaintextFromMarkdown(suspendedBody(escaped)) + require.NoError(t, err) + require.Equal(t, wantPlain, gotPlain) + }) + } + }) + + t.Run("ControlCharacters", func(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + value string + want string + }{ + {"KeepsNewlines", "a\nb", "a\nb"}, + {"FoldsCarriageReturn", "a\r\nb", "a \nb"}, + {"FoldsTab", "a\tb", "a b"}, + {"FoldsVerticalTab", "a\vb", "a b"}, + {"DropsNul", "a\x00b", "ab"}, + {"DropsBell", "a\x07b", "ab"}, + {"DropsDelete", "a\x7fb", "ab"}, + {"Empty", "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, EscapeMarkdown(tc.value)) + }) + } + }) + + t.Run("AngleBracketsAreNeutralised", func(t *testing.T) { + t.Parallel() + + // "Ops <ops@example.com>" used to render as a mailto anchor; turning it + // into text is a deliberate behavior change. + html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown("Ops <ops@example.com>"))) + require.NotContains(t, html, "<a ") + require.Contains(t, html, "<ops@example.com>") + }) + + t.Run("EmphasisIsNotEscaped", func(t *testing.T) { + t.Parallel() + + // Mid-line "*" and "_" carry no destination and escaping "_" corrupts + // control values. Backtick reaches the info-string sink, so it is escaped. + require.Equal(t, "Eve *_\\`~", EscapeMarkdown("Eve *_`~")) + + // Leading "*" and "_" open a list or thematic break. "~" is denied the + // line-start position by the fold, glamour not honoring "\~". + require.Equal(t, "\\*_\\`~", EscapeMarkdown("*_`~")) + require.Equal(t, "\\_*\\`~", EscapeMarkdown("_*`~")) + }) +} + +// TestEscapeMarkdownNoAutolink: a URL in an untrusted value must not become an +// anchor, while links in the trusted template markdown keep working. +func TestEscapeMarkdownNoAutolink(t *testing.T) { + t.Parallel() + + t.Run("UntrustedValueProducesNoAnchor", func(t *testing.T) { + t.Parallel() + + for _, value := range []string{ + "Eve https://attacker.example/login", + "Eve [Re-auth](https://attacker.example/login)", + "Eve <https://attacker.example>", + "Eve mailto:eve@attacker.example", + "Eve http://attacker.example", + } { + html := HTMLFromNotificationMarkdown("Account **" + EscapeMarkdown(value) + "** suspended.") + assert.NotContains(t, html, "<a ", "value %q produced an anchor", value) + } + }) + + t.Run("TrustedTemplateLinksStillRender", func(t *testing.T) { + t.Parallel() + + // Shapes taken from shipped notification body templates. + for _, markdown := range []string{ + "marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of x", + "See [the docs](https://coder.com/docs/admin/templates/troubleshooting).", + } { + html := HTMLFromNotificationMarkdown(markdown) + assert.Contains(t, html, `<a href="https://coder.com/docs/`, "trusted link did not render: %s", html) + } + }) + + t.Run("HTMLFromMarkdownStillAutolinks", func(t *testing.T) { + t.Parallel() + + // The shared renderer keeps Autolink, so the OIDC signups-disabled page + // still linkifies. Safelink does now apply; see TestHTMLFromMarkdownSafelink. + require.Contains(t, HTMLFromMarkdown("see https://coder.com/docs"), `<a href="https://coder.com/docs"`) + }) + + t.Run("SafelinkRejectsUnsafeSchemes", func(t *testing.T) { + t.Parallel() + + for _, dest := range []string{"javascript:alert(1)", "data:text/html;base64,PHNjcmlwdD4="} { + html := HTMLFromNotificationMarkdown(fmt.Sprintf("[click](%s)", dest)) + assert.NotContains(t, html, "<a ", "unsafe scheme %q was linked", dest) + } + }) +} diff --git a/coderd/render/escape_sink_internal_test.go b/coderd/render/escape_sink_internal_test.go new file mode 100644 index 0000000000000..5080f9817bd41 --- /dev/null +++ b/coderd/render/escape_sink_internal_test.go @@ -0,0 +1,321 @@ +package render + +import ( + "strings" + "testing" + + "github.com/gomarkdown/markdown/parser" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + xhtml "golang.org/x/net/html" +) + +// permissive is the grammar notificationExtensions used to enable. Tests that +// must exercise the escaper rather than the allowlist render against it. +const permissive = parser.CommonExtensions | parser.HardLineBreak + +// suspendedBody mirrors the live TemplateUserAccountSuspended body: the +// untrusted value sits mid-paragraph with trusted text on both sides. +func suspendedBody(value string) string { + return "The account belongs to **" + value + "** and it was suspended by **rob**." +} + +// TestEscapeMarkdownFenceInfo covers the info-string sink that made backtick +// inlineCritical: a `"` closes the class attribute and a `>` closes the tag. +func TestEscapeMarkdownFenceInfo(t *testing.T) { + t.Parallel() + + const info = `"><a/href="https://attacker.example/login">Click here` + + // Each payload needs a line after the closing fence, or the template's + // trailing text lands on it and the fence stops being one. + for name, value := range map[string]string{ + "Anchor": "Eve\n\n```" + info + "\nhidden\n```\nmore", + "Image": "Eve\n\n```\"><img/src=x/onerror=alert(1)>\nhidden\n```\nmore", + "Tilde": "Eve\n\n~~~" + info + "\nhidden\n~~~\nmore", + "AtStart": "```" + info + "\nhidden\n```\nmore", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown(value))) + assert.NotContains(t, html, `class="language-`, + "an info string reached the class attribute: %s", html) + assert.NotContains(t, html, "<a/", "the info string produced an anchor: %s", html) + assert.NotContains(t, html, "<img/", "the info string produced an image: %s", html) + }) + } + + // Liveness: unescaped, the anchor payload must reach the sink. + raw := HTMLFromNotificationMarkdown(suspendedBody("Eve\n\n```" + info + "\nhidden\n```\nmore")) + require.Contains(t, raw, `class="language-`, + "vacuous test: the payload no longer reaches the info-string sink even unescaped") +} + +// TestEscapeMarkdownColon covers ":", rendered under CommonExtensions so the +// shipped allowlist, which kills these constructs anyway, cannot carry the test. +func TestEscapeMarkdownColon(t *testing.T) { + t.Parallel() + + for name, value := range map[string]string{ + "DefinitionList": "Term\n: definition", + "TableColonBoth": "a | b\n:-- | --:\nc | d", + "TableColonCentre": "a | b\n:-: | :-:\nc | d", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + escaped := EscapeMarkdown(value) + html := renderHTML(suspendedBody(escaped), permissive) + plain, err := PlaintextFromMarkdown(suspendedBody(escaped)) + require.NoError(t, err) + + for _, tag := range []string{"<table", "<dl", "<dt", "<dd"} { + assert.NotContains(t, html, tag, "value %q rendered %s", value, html) + } + assert.NotContains(t, plain, `\`, "folding ':' should not leak a backslash: %q", plain) + + // Liveness: unescaped, each value must produce one of those tags. + raw := renderHTML(suspendedBody(value), permissive) + assert.True(t, + strings.Contains(raw, "<table") || strings.Contains(raw, "<dl"), + "vacuous row: %q produces no table or definition list even unescaped", value) + }) + } +} + +// TestNotificationExtensionsDropUnusedGrammar pins the allowlist: each construct +// is openable from an untrusted value and used by no template. +func TestNotificationExtensionsDropUnusedGrammar(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct{ markdown, tag string }{ + "Tables": {"a | b\n:-- | --:\nc | d", "<table"}, + "DefinitionLists": {"Term\n: definition", "<dl"}, + "MathJax": {"Eve $x^2$ end", `class="math`}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + require.Contains(t, renderHTML(tc.markdown, permissive), tc.tag, + "the construct is no longer reachable under CommonExtensions, so this test guards nothing") + assert.NotContains(t, HTMLFromNotificationMarkdown(tc.markdown), tc.tag, + "notificationExtensions still enables %s", name) + }) + } + + // What shipped templates do use must keep rendering. + for _, tc := range []struct{ markdown, want string }{ + {"see [the docs](https://coder.com/docs/x).", `<a href="https://coder.com/docs/x"`}, + {"Your workspace **foo** was suspended.", "<strong>foo</strong>"}, + {"Resources:\n\n- one\n- two\n", "<li>"}, + {"marked as [**dormant**](https://coder.com/docs/y) because", `<a href="https://coder.com/docs/y"`}, + } { + assert.Contains(t, HTMLFromNotificationMarkdown(tc.markdown), tc.want) + } +} + +// TestEscapeMarkdownIndentedCode covers the one construct with no escape: a +// space cannot be escaped, so the indent run is truncated instead. +func TestEscapeMarkdownIndentedCode(t *testing.T) { + t.Parallel() + + for name, value := range map[string]string{ + "FourSpaces": "Eve\n\n hidden", + "EightSpaces": "Eve\n\n hidden", + "SingleBreak": "Eve\n hidden", + "DeepInList": "Eve\n\n - hidden", + "OnlyIndented": " hidden", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + html := HTMLFromNotificationMarkdown(suspendedBody(EscapeMarkdown(value))) + assert.NotContains(t, html, "<pre", "value %q produced a code block: %s", value, html) + }) + } + + // Indentation up to the cap is preserved, so documented multi-line custom + // notification values keep their shape. + require.Equal(t, "Test results:\n • ok", EscapeMarkdown("Test results:\n • ok")) + require.Equal(t, "a\n b", EscapeMarkdown("a\n b")) + require.Equal(t, "a\n b", EscapeMarkdown("a\n b")) +} + +// TestEscapeMarkdownEmptyLinkDestination covers the panic html.Safelink +// introduced: parser.IsSafeURL slices a destination before bounds-checking it. +func TestEscapeMarkdownEmptyLinkDestination(t *testing.T) { + t.Parallel() + + for _, md := range []string{ + "[our docs]()", "![px]()", "[a]( )", "[](https://coder.com)", "[a](x)", + } { + assert.NotPanics(t, func() { _ = HTMLFromNotificationMarkdown(md) }, "markdown %q", md) + // Reachable outside notifications, via OIDCConfig.SignupsDisabledText. + assert.NotPanics(t, func() { _ = HTMLFromMarkdown(md) }, "markdown %q", md) + } + + // Destinations Safelink still permits must keep rendering. + for _, tc := range []struct{ md, want string }{ + {"[a](https://coder.com)", `<a href="https://coder.com"`}, + {"[a](/path)", `<a href="/path"`}, + {"[a](./p)", `<a href="./p"`}, + {"[a](mailto:x@y.z)", `<a href="mailto:x@y.z"`}, + } { + assert.Contains(t, HTMLFromNotificationMarkdown(tc.md), tc.want) + } + + // And the ones it rejects must stay rejected. + for _, md := range []string{"[a](javascript:alert(1))", "[a](data:text/html;base64,eA==)"} { + assert.NotContains(t, HTMLFromNotificationMarkdown(md), "<a ", "unsafe scheme linked: %s", md) + } + + // Safelink also drops these two, a silent loss for a template author rather + // than a security property. Pinned so renderHTML's comment cannot drift. + for _, md := range []string{"[a](#anchor)", "[a](docs/x.md)"} { + assert.NotContains(t, HTMLFromNotificationMarkdown(md), "<a ", + "destination %q now renders an anchor; update the comment on renderHTML", md) + } +} + +// TestEscapeMarkdownLeadingFoldConstruct covers a first line the fold cannot +// reach. Escaping costs a visible backslash, so the untouched cases matter too. +func TestEscapeMarkdownLeadingFoldConstruct(t *testing.T) { + t.Parallel() + + t.Run("TitleKeepsItsTrustedText", func(t *testing.T) { + t.Parallel() + + for _, value := range []string{"~~~", "~~~~", "~~~x", "~~~ ", " ~~~"} { + subject, err := PlaintextFromMarkdown(EscapeMarkdown(value) + " shared a chat with you") + require.NoError(t, err) + assert.Contains(t, subject, "shared a chat with you", + "value %q swallowed the trusted subject text", value) + } + + // The backtick spelling is closed by backtick being inlineCritical. + subject, err := PlaintextFromMarkdown(EscapeMarkdown("```") + " shared a chat with you") + require.NoError(t, err) + require.Equal(t, "``` shared a chat with you", subject) + }) + + t.Run("SetextCannotPromoteATrustedLine", func(t *testing.T) { + t.Parallel() + + for _, value := range []string{"===", "=", "===\nx", " === "} { + html := HTMLFromNotificationMarkdown( + "Trusted line\n" + EscapeMarkdown(value) + "\nTrusted trailer.") + assert.NotContains(t, html, "<h1", "value %q promoted a heading: %s", value, html) + } + }) + + t.Run("NonConstructsAreUntouched", func(t *testing.T) { + t.Parallel() + + // These begin with a fold character without being a construct; escaping + // them would put a backslash in front of an ordinary display name. + for _, value := range []string{ + "=> next", "~tilde name", "= x", "~~strike~~", "=?utf-8?q?x?=", + "~", "~~", "=== and more", "a\n===", + } { + plain, err := PlaintextFromMarkdown(EscapeMarkdown(value) + " end") + require.NoError(t, err) + assert.NotContains(t, plain, `\`, + "value %q was escaped when it is not a fold construct", value) + } + }) +} + +// TestRecoverToEscapedSource drives renderHTML's panic guard directly: safeURL +// closed the only input known to panic it. +func TestRecoverToEscapedSource(t *testing.T) { + t.Parallel() + + const src = `<script>alert(1)</script> & "quoted"` + + got := recoverToEscapedSource(src, func() string { panic("boom") }) + assert.Equal(t, xhtml.EscapeString(src), got) + // The point of escaping rather than returning the source: no markup escapes. + assert.NotContains(t, got, "<script>") + + assert.Equal(t, "rendered", + recoverToEscapedSource(src, func() string { return "rendered" })) +} + +// TestHTMLFromMarkdownSafelink pins the behavior change Safelink brought to the +// shared renderer, called by OIDCConfig.SignupsDisabledText. +func TestHTMLFromMarkdownSafelink(t *testing.T) { + t.Parallel() + + // Unsafe schemes stopped linking here, not just in notifications. + for _, md := range []string{"[a](javascript:alert(1))", "[a](data:text/html;base64,eA==)"} { + assert.NotContains(t, HTMLFromMarkdown(md), "<a ", "unsafe scheme linked: %s", md) + } + + // Fragment and bare relative destinations stopped linking too, a silent loss + // rather than a security property. See renderHTML. + for _, md := range []string{"[a](#anchor)", "[a](docs/x.md)"} { + assert.NotContains(t, HTMLFromMarkdown(md), "<a ", "destination %q now links", md) + } + + // What the signups-disabled text actually uses must keep working. + for _, tc := range []struct{ md, want string }{ + {"see https://coder.com/docs", `<a href="https://coder.com/docs"`}, + {"[docs](https://coder.com/docs)", `<a href="https://coder.com/docs"`}, + {"contact [us](mailto:support@coder.com)", `<a href="mailto:support@coder.com"`}, + {"**bold** and _italic_", "<strong>bold</strong>"}, + } { + assert.Contains(t, HTMLFromMarkdown(tc.md), tc.want) + } +} + +// TestEscapeMarkdownResiduals pins the one gap EscapeMarkdown cannot close. +func TestEscapeMarkdownResiduals(t *testing.T) { + t.Parallel() + + t.Run("CodeSpanSwallowsEscapes", func(t *testing.T) { + t.Parallel() + + // CommonMark does not process escapes inside a code span, and the + // workspace out-of-disk body wraps a value in one. + html := HTMLFromNotificationMarkdown("The volume `" + EscapeMarkdown("config[0]") + "` is full.") + require.Contains(t, html, `config\[0\]`, + "if this no longer leaks, the residual is closed and this test should become an assertion that it stays closed") + }) +} + +// TestEscapeMarkdownNoStrayBackslash asserts the no-stray-backslash invariant +// across every interpolation position a shipped template provides. +func TestEscapeMarkdownNoStrayBackslash(t *testing.T) { + t.Parallel() + + positions := map[string]func(string) string{ + "midline": suspendedBody, + "linestart": func(v string) string { return v + " shared a chat with you." }, + "afterblank": func(v string) string { return "Hi.\n\n" + v + "\n\nRegards." }, + "listitem": func(v string) string { return "Resources:\n\n- " + v + "\n" }, + "trailing": func(v string) string { return "The account belongs to **" + v + "**" }, + } + + for _, value := range []string{ + "William Tables", "bobby-workspace", "Bobby's Template", + "O'Brien-Smith (Eng) 100%", "José Müller 日本語", + "config[0]", "vol(1)", "1.5", "user_override", + } { + for name, pos := range positions { + t.Run(name+"/"+value, func(t *testing.T) { + t.Parallel() + + md := pos(EscapeMarkdown(value)) + html := HTMLFromNotificationMarkdown(md) + plain, err := PlaintextFromMarkdown(md) + require.NoError(t, err) + + assert.NotContains(t, html, `\`, "stray backslash in HTML: %s", html) + assert.NotContains(t, plain, `\`, "stray backslash in plaintext: %q", plain) + assert.False(t, strings.Contains(html, `class="language-`), + "benign value reached the info-string sink: %s", html) + }) + } + } +} diff --git a/coderd/render/markdown.go b/coderd/render/markdown.go index ed0c16bc84042..7cf9bf266daf6 100644 --- a/coderd/render/markdown.go +++ b/coderd/render/markdown.go @@ -113,12 +113,86 @@ func PlaintextFromMarkdown(markdown string) (string, error) { return strings.TrimSpace(output), nil } +// notificationExtensions is an allowlist. Shipped templates use only core +// CommonMark, so Tables, DefinitionLists, MathJax and Autolink are absent; each +// is openable from an untrusted label value. Adding one back means revisiting +// EscapeMarkdown. +const notificationExtensions = parser.NoIntraEmphasis | parser.HardLineBreak + func HTMLFromMarkdown(markdown string) string { - p := parser.NewWithExtensions(parser.CommonExtensions | parser.HardLineBreak) // Added HardLineBreak. + return renderHTML(markdown, parser.CommonExtensions|parser.HardLineBreak) // Added HardLineBreak. +} + +// HTMLFromNotificationMarkdown converts a rendered notification body to HTML. +// Unlike HTMLFromMarkdown it does not autolink bare URLs, because notification +// bodies interpolate attacker-controlled label values. +func HTMLFromNotificationMarkdown(markdown string) string { + return renderHTML(markdown, notificationExtensions) +} + +// longestURLPath is the longest relative-path prefix parser.IsSafeURL compares +// against. Derived so a dependency bump that adds a longer one stays correct. +var longestURLPath = func() int { + longest := 0 + for _, p := range parser.Paths { + if len(p) > longest { + longest = len(p) + } + } + return longest +}() + +// safeURL wraps parser.IsSafeURL, which slices a destination to each candidate +// prefix length before checking it is that long, and so panics on a short one +// with no spare capacity, as "[docs]()" produces. Padding the capacity keeps +// the slice in bounds; IsSafeURL's own guards still decide the result. +func safeURL(url []byte) bool { + if cap(url) < longestURLPath { + padded := make([]byte, len(url), longestURLPath) + copy(padded, url) + url = padded + } + return parser.IsSafeURL(url) +} + +// recoverToEscapedSource runs render and, if it panics, returns the source +// HTML-escaped instead: the notification still arrives, showing Markdown +// source, and no markup escapes. Kept separate from renderHTML so the recovery +// is testable, safeURL having closed the only input known to panic. +func recoverToEscapedSource(markdown string, render func() string) (out string) { + defer func() { + if r := recover(); r != nil { + out = xhtml.EscapeString(markdown) + } + }() + return render() +} + +// renderHTML converts Markdown to HTML. Input is untrusted, so a parser panic +// is recovered rather than taking down the dispatcher. +// +// Safelink silently drops fragment and bare relative destinations, so [a](#x) +// and [a](docs/x.md) render without an anchor. /path, ./path, mailto: and +// http(s):// still work. +func renderHTML(markdown string, extensions parser.Extensions) string { + return recoverToEscapedSource(markdown, func() string { + return renderHTMLUnsafe(markdown, extensions) + }) +} + +// renderHTMLUnsafe is renderHTML without the panic guard. +func renderHTMLUnsafe(markdown string, extensions parser.Extensions) string { + p := parser.NewWithExtensions(extensions) + p.IsSafeURLOverride = safeURL doc := p.Parse([]byte(markdown)) renderer := html.NewRenderer(html.RendererOptions{ - Flags: html.CommonFlags | html.SkipHTML, + // Safelink restricts generated hrefs to trusted schemes, which keeps + // javascript: and data: out of rendered output. + Flags: html.CommonFlags | html.SkipHTML | html.Safelink, }) + // Safelink routes every destination through parser.IsSafeURL, which panics + // on a short one. The hook lives on the renderer, not on its options. + renderer.IsSafeURLOverride = safeURL return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer))) } diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 305c149a6b80b..a626c9faa1d37 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -2103,6 +2103,10 @@ func TestUserSecretsTelemetry(t *testing.T) { }, func(p *database.CreateUserSecretParams) { p.EnvName = "" p.FilePath = "" + // A target-less secret must be disabled to satisfy the + // user_secrets_enabled_requires_target constraint. Disabled + // secrets are still counted in the telemetry breakdown. + p.Enabled = false }) _, snap := collectSnapshot(ctx, t, db, nil) @@ -2149,9 +2153,12 @@ func TestUserSecretsTelemetry(t *testing.T) { // Clear EnvName and FilePath so the unique // (user_id, env_name) and (user_id, file_path) // indexes don't collide across multiple secrets - // for the same user. + // for the same user. Target-less secrets must be + // disabled to satisfy the + // user_secrets_enabled_requires_target constraint. p.EnvName = "" p.FilePath = "" + p.Enabled = false }) } } @@ -2261,6 +2268,9 @@ func TestUserSecretsTelemetry(t *testing.T) { }, func(p *database.CreateUserSecretParams) { p.EnvName = "" p.FilePath = "" + // Target-less secrets must be disabled to satisfy the + // user_secrets_enabled_requires_target constraint. + p.Enabled = false }) clock := quartz.NewMock(t) diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 463ce8365128b..709b6d3764b65 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -153,13 +153,19 @@ func TestUserLogin(t *testing.T) { t.Run("LoginTypeNone", func(t *testing.T) { t.Parallel() - anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { - r.Password = "" - r.UserLoginType = codersdk.LoginTypeNone + client, db := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + noneUser := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeNone, + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: first.OrganizationID, + UserID: noneUser.ID, }) - _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ - Email: anotherUser.Email, + _, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ + Email: noneUser.Email, Password: "SomeSecurePassword!", }) require.Error(t, err) diff --git a/coderd/users.go b/coderd/users.go index 77c6954c6cc8b..a9151bd6c60f6 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -488,6 +488,13 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { req.UserLoginType = codersdk.LoginTypePassword } + if !req.ServiceAccount && req.UserLoginType == codersdk.LoginTypeNone { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Login type 'none' requires a service account.", + }) + return + } + if req.UserLoginType != codersdk.LoginTypePassword && req.Password != "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Password cannot be set for non-password (%q) authentication.", req.UserLoginType), diff --git a/coderd/users_test.go b/coderd/users_test.go index bf35cf1d7f4ff..1b3916fe9b72a 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -287,6 +287,62 @@ func TestPostLogin(t *testing.T) { require.NotContains(t, apiErr.Message, string(codersdk.LoginTypeOIDC)) }) + // Regression: the legacy `login_type = 'none'` migration converts these + // accounts to password auth, but they have no password hash. Converting + // login type must never let someone authenticate with an empty or guessed + // password. + t.Run("ConvertedNoneUserHasNoUsablePassword", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // A legacy machine user was created with login_type 'none' and no + // password. dbgen.User substitutes a random hash for an empty one, so + // clear it explicitly to match the real account. + noneUser := dbgen.User(t, db, database.User{ + Email: "legacy-machine-user@coder.com", + LoginType: database.LoginTypeNone, + }) + //nolint:gocritic // Test setup requires a system context to clear the hash. + err := db.UpdateUserHashedPassword(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedPasswordParams{ + ID: noneUser.ID, + HashedPassword: []byte{}, + }) + require.NoError(t, err) + + // Apply the migration's conversion: login_type 'none' -> 'password'. + //nolint:gocritic // Test setup requires a system context to convert the login type. + _, err = db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ + NewLoginType: database.LoginTypePassword, + UserID: noneUser.ID, + }) + require.NoError(t, err) + + // Neither an empty password nor a guessed one may authenticate. An empty + // password is rejected by request validation (400); a non-empty guess + // fails the hash comparison against the empty stored hash (401). Both must + // deny access. + cases := []struct { + name string + password string + wantStatus int + }{ + {"EmptyPassword", "", http.StatusBadRequest}, + {"GuessedPassword", "hunter2", http.StatusUnauthorized}, + } + for _, tc := range cases { + anonClient := codersdk.New(client.URL) + _, err := anonClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: noneUser.Email, + Password: tc.password, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr, "%s must not authenticate", tc.name) + require.Equal(t, tc.wantStatus, apiErr.StatusCode(), "%s", tc.name) + } + }) + t.Run("Suspended", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() @@ -952,18 +1008,17 @@ func TestPostUsers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ OrganizationIDs: []uuid.UUID{first.OrganizationID}, Email: "another@user.org", Username: "someone-else", Password: "", UserLoginType: codersdk.LoginTypeNone, }) - require.NoError(t, err) - - found, err := client.User(ctx, user.ID.String()) - require.NoError(t, err) - require.Equal(t, found.LoginType, codersdk.LoginTypeNone) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "service account") }) t.Run("CreateOIDCLoginType", func(t *testing.T) { diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index 633983cb612ee..fe5e41b50b4e4 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -28,6 +28,13 @@ const ( userSecretsEnvBytesLimitConstraint database.CheckConstraint = "user_secrets_per_user_env_bytes_limit" ) +// errUserSecretInjectionTargetRequired signals that a PATCH would leave an +// enabled secret with both env_name and file_path empty. It is returned +// from the patchUserSecret transaction so the handler can map it to a 400. +// Creates enforce the same invariant in +// codersdk.ValidateCreateUserSecretRequest. +var errUserSecretInjectionTargetRequired = xerrors.New("enabled user secret must have at least one of env_name or file_path set") + // @Summary Create a new user secret // @ID create-a-new-user-secret // @Security CoderSessionToken @@ -62,6 +69,11 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { return } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + secret, err := api.Database.CreateUserSecret(ctx, database.CreateUserSecretParams{ ID: uuid.New(), UserID: user.ID, @@ -71,12 +83,17 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { ValueKeyID: sql.NullString{}, EnvName: req.EnvName, FilePath: req.FilePath, + Enabled: enabled, }) if err != nil { if validations := userSecretConflictValidationErrors(err); len(validations) > 0 { writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations) return } + if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 { + writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations) + return + } if resp, ok := userSecretLimitResponse(err); ok { httpapi.Write(ctx, rw, http.StatusBadRequest, resp) return @@ -155,6 +172,10 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) { failedIndex := -1 err = api.Database.InTx(func(tx database.Store) error { for i, sreq := range reqs { + enabled := true + if sreq.Enabled != nil { + enabled = *sreq.Enabled + } s, txErr := tx.CreateUserSecret(ctx, database.CreateUserSecretParams{ ID: uuid.New(), UserID: user.ID, @@ -164,6 +185,7 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) { ValueKeyID: sql.NullString{}, EnvName: sreq.EnvName, FilePath: sreq.FilePath, + Enabled: enabled, }) if txErr != nil { failedIndex = i @@ -185,6 +207,15 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) { writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, conflicts) return } + if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 { + if index >= 0 { + for i := range validations { + validations[i].Field = fmt.Sprintf("secrets[%d].%s", index, validations[i].Field) + } + } + writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations) + return + } if resp, ok := userSecretLimitResponse(err); ok { if index >= 0 { resp.Detail = fmt.Sprintf("Entry secrets[%d] (%q): %s", index, reqs[index].Name, resp.Detail) @@ -319,7 +350,7 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { return } - if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil { + if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil && req.Enabled == nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "At least one field must be provided.", }) @@ -342,6 +373,8 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { EnvName: "", UpdateFilePath: req.FilePath != nil, FilePath: "", + UpdateEnabled: req.Enabled != nil, + Enabled: false, } if req.Value != nil { params.Value = *req.Value @@ -355,6 +388,9 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { if req.FilePath != nil { params.FilePath = *req.FilePath } + if req.Enabled != nil { + params.Enabled = *req.Enabled + } // Pre-read the secret inside a transaction so the audit diff has both an // "old" and "new" snapshot. @@ -375,6 +411,26 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { } aReq.Old = old + // Reject patches that would leave an enabled secret with both + // env_name and file_path empty. Evaluated against the post-update + // state so atomic env<->file swaps still succeed, and so targets + // can be cleared when the same PATCH also disables the secret. + postEnvName := old.EnvName + if req.EnvName != nil { + postEnvName = *req.EnvName + } + postFilePath := old.FilePath + if req.FilePath != nil { + postFilePath = *req.FilePath + } + postEnabled := old.Enabled + if req.Enabled != nil { + postEnabled = *req.Enabled + } + if postEnabled && postEnvName == "" && postFilePath == "" { + return errUserSecretInjectionTargetRequired + } + updated, err := tx.UpdateUserSecretByUserIDAndName(ctx, params) if err != nil { return xerrors.Errorf("update user secret: %w", err) @@ -388,6 +444,17 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { httpapi.ResourceNotFound(rw) return } + if errors.Is(err, errUserSecretInjectionTargetRequired) { + writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, []codersdk.ValidationError{{ + Field: codersdk.UserSecretEnvNameField, + Detail: codersdk.UserSecretInjectionTargetRequiredDetail, + }}) + return + } + if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 { + writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations) + return + } if validations := userSecretConflictValidationErrors(err); len(validations) > 0 { writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations) return @@ -518,6 +585,22 @@ func userSecretLimitResponse(err error) (codersdk.Response, bool) { return codersdk.Response{}, false } +// userSecretInjectionTargetValidationErrors maps the +// user_secrets_enabled_requires_target CHECK violation to a field-level +// validation error. The database constraint is the race-safe source of +// truth for the injection-target invariant: concurrent PATCHes can each +// clear a different target and pass the handler's own post-state check, +// so the constraint is what ultimately rejects an enabled target-less row. +func userSecretInjectionTargetValidationErrors(err error) []codersdk.ValidationError { + if database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget) { + return []codersdk.ValidationError{{ + Field: codersdk.UserSecretEnvNameField, + Detail: codersdk.UserSecretInjectionTargetRequiredDetail, + }} + } + return nil +} + func userSecretConflictValidationErrors(err error) []codersdk.ValidationError { switch { case database.IsUniqueViolation(err, database.UniqueUserSecretsUserNameIndex): diff --git a/coderd/usersecrets_audit_test.go b/coderd/usersecrets_audit_test.go index ba1fdd96f3b6b..9cf38dcc2ff3d 100644 --- a/coderd/usersecrets_audit_test.go +++ b/coderd/usersecrets_audit_test.go @@ -29,13 +29,24 @@ func TestUserSecretAudit(t *testing.T) { // collide in the shared user's secret namespace. return strings.ReplaceAll(t.Name(), "/", "-") } + genEnvName := func(t *testing.T) string { + // Same derivation as genSecretName, but in the + // SCREAMING_SNAKE_CASE shape env names require. Every + // secret needs at least one of env_name or file_path, + // and the per-user UNIQUE index makes empty-injection + // not an option. + name := strings.ReplaceAll(t.Name(), "/", "_") + name = strings.ReplaceAll(name, "-", "_") + return strings.ToUpper(name) + } t.Run("CreateEmitsLog", func(t *testing.T) { auditor.ResetLogs() secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: genSecretName(t), - Value: "ghp_xxxxxxxxxxxx", + Name: genSecretName(t), + Value: "ghp_xxxxxxxxxxxx", + EnvName: genEnvName(t), }) require.NoError(t, err) @@ -51,8 +62,9 @@ func TestUserSecretAudit(t *testing.T) { auditor.ResetLogs() secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: genSecretName(t), - Value: "old", + Name: genSecretName(t), + Value: "old", + EnvName: genEnvName(t), }) require.NoError(t, err) @@ -77,8 +89,9 @@ func TestUserSecretAudit(t *testing.T) { auditor.ResetLogs() secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: genSecretName(t), - Value: "value", + Name: genSecretName(t), + Value: "value", + EnvName: genEnvName(t), }) require.NoError(t, err) @@ -138,8 +151,9 @@ func TestUserSecretAudit(t *testing.T) { name := genSecretName(t) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: name, - Value: "value", + Name: name, + Value: "value", + EnvName: genEnvName(t), }) require.NoError(t, err) // Reset to ignore the created log. We are only testing that the @@ -159,8 +173,9 @@ func TestUserSecretAudit(t *testing.T) { secretName := genSecretName(t) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: secretName, - Value: "value", + Name: secretName, + Value: "value", + EnvName: genEnvName(t), }) require.NoError(t, err) // Discard the create log so the assertion below only sees audit entries diff --git a/coderd/usersecrets_test.go b/coderd/usersecrets_test.go index 4fedcb2dec3a7..1b4a6c9776888 100644 --- a/coderd/usersecrets_test.go +++ b/coderd/usersecrets_test.go @@ -87,14 +87,16 @@ func TestPostUserSecret(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "dup-secret", - Value: "value1", + Name: "dup-secret", + Value: "value1", + EnvName: "DUP_SECRET_ENV_1", }) require.NoError(t, err) _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "dup-secret", - Value: "value2", + Name: "dup-secret", + Value: "value2", + EnvName: "DUP_SECRET_ENV_2", }) requireSecretValidationEqualsError(t, err, http.StatusConflict, "name", "name already in use") }) @@ -206,6 +208,63 @@ func TestPostUserSecret(t *testing.T) { }) requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "must not exceed") }) + + t.Run("MissingInjectionTarget", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "missing-target-secret", + Value: "value", + }) + requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path") + }) + + t.Run("DisabledByDefault", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + disabled := false + secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "create-disabled", + Value: "value", + EnvName: "CREATE_DISABLED", + Enabled: &disabled, + }) + require.NoError(t, err) + assert.False(t, secret.Enabled) + }) + + t.Run("DisabledWithoutTarget", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + // A disabled secret may omit both injection targets. Bulk + // imports rely on this for keys that cannot be env-injected. + disabled := false + secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "create-disabled-no-target", + Value: "value", + Enabled: &disabled, + }) + require.NoError(t, err) + assert.False(t, secret.Enabled) + assert.Empty(t, secret.EnvName) + assert.Empty(t, secret.FilePath) + }) + + t.Run("EnabledByDefault", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "create-default-enabled", + Value: "value", + EnvName: "CREATE_DEFAULT_ENABLED", + }) + require.NoError(t, err) + assert.True(t, secret.Enabled) + }) } func TestPostUserSecretForbiddenForAnotherUser(t *testing.T) { @@ -216,8 +275,9 @@ func TestPostUserSecretForbiddenForAnotherUser(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := memberClient.CreateUserSecret(ctx, owner.UserID.String(), codersdk.CreateUserSecretRequest{ - Name: "forbidden", - Value: "value", + Name: "forbidden", + Value: "value", + EnvName: "FORBIDDEN", }) var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) @@ -240,14 +300,16 @@ func TestGetUserSecrets(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "list-secret-a", - Value: "value-a", + Name: "list-secret-a", + Value: "value-a", + EnvName: "LIST_SECRET_A", }) require.NoError(t, err) _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "list-secret-b", - Value: "value-b", + Name: "list-secret-b", + Value: "value-b", + EnvName: "LIST_SECRET_B", }) require.NoError(t, err) @@ -327,8 +389,9 @@ func TestPatchUserSecret(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "patch-nofields-secret", - Value: "my-value", + Name: "patch-nofields-secret", + Value: "my-value", + EnvName: "PATCH_NOFIELDS_ENV", }) require.NoError(t, err) @@ -365,8 +428,9 @@ func TestPatchUserSecret(t *testing.T) { require.NoError(t, err) _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "conflict-env-2", - Value: "value2", + Name: "conflict-env-2", + Value: "value2", + FilePath: "/tmp/conflict-env-2", }) require.NoError(t, err) @@ -389,8 +453,9 @@ func TestPatchUserSecret(t *testing.T) { require.NoError(t, err) _, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "conflict-fp-2", - Value: "value2", + Name: "conflict-fp-2", + Value: "value2", + EnvName: "CONFLICT_FP_2", }) require.NoError(t, err) @@ -406,8 +471,9 @@ func TestPatchUserSecret(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "patch-invalid-env", - Value: "good-value", + Name: "patch-invalid-env", + Value: "good-value", + FilePath: "/tmp/patch-invalid-env", }) require.NoError(t, err) @@ -423,8 +489,9 @@ func TestPatchUserSecret(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "patch-invalid-file-path", - Value: "good-value", + Name: "patch-invalid-file-path", + Value: "good-value", + EnvName: "PATCH_INVALID_FILE_PATH", }) require.NoError(t, err) @@ -440,8 +507,9 @@ func TestPatchUserSecret(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "patch-invalid-val", - Value: "good-value", + Name: "patch-invalid-val", + Value: "good-value", + EnvName: "PATCH_INVALID_VAL", }) require.NoError(t, err) @@ -451,6 +519,121 @@ func TestPatchUserSecret(t *testing.T) { }) requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "null bytes") }) + + t.Run("ToggleEnabled", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "toggle-enabled", + Value: "value", + EnvName: "TOGGLE_ENABLED", + }) + require.NoError(t, err) + require.True(t, secret.Enabled) + + disable := false + updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "toggle-enabled", codersdk.UpdateUserSecretRequest{ + Enabled: &disable, + }) + require.NoError(t, err) + assert.False(t, updated.Enabled) + // Other fields should be unchanged. + assert.Equal(t, "TOGGLE_ENABLED", updated.EnvName) + + enable := true + updated, err = client.UpdateUserSecret(ctx, codersdk.Me, "toggle-enabled", codersdk.UpdateUserSecretRequest{ + Enabled: &enable, + }) + require.NoError(t, err) + assert.True(t, updated.Enabled) + }) + + t.Run("ClearingBothTargetsRejected", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "clear-both", + Value: "value", + EnvName: "CLEAR_BOTH_ENV", + }) + require.NoError(t, err) + + // PATCH that clears env_name while file_path is also empty + // should be rejected: the row stays enabled but would have no + // injection target. + empty := "" + _, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-both", codersdk.UpdateUserSecretRequest{ + EnvName: &empty, + }) + requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path") + }) + + t.Run("ClearingTargetsWhileDisablingAllowed", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "clear-and-disable", + Value: "value", + EnvName: "CLEAR_AND_DISABLE", + }) + require.NoError(t, err) + + // Clearing the last target is allowed when the same PATCH also + // disables the secret: only enabled secrets need a target. + empty := "" + disabled := false + updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{ + EnvName: &empty, + Enabled: &disabled, + }) + require.NoError(t, err) + assert.False(t, updated.Enabled) + assert.Empty(t, updated.EnvName) + + // Re-enabling without restoring a target is rejected. + enable := true + _, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{ + Enabled: &enable, + }) + requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path") + + // Re-enabling and restoring a target in one PATCH succeeds. + envName := "CLEAR_AND_DISABLE" + updated, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{ + EnvName: &envName, + Enabled: &enable, + }) + require.NoError(t, err) + assert.True(t, updated.Enabled) + assert.Equal(t, "CLEAR_AND_DISABLE", updated.EnvName) + }) + + t.Run("AtomicEnvFileSwap", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + + _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "atomic-swap", + Value: "value", + EnvName: "ATOMIC_SWAP_ENV", + }) + require.NoError(t, err) + + // Clearing env_name and setting file_path in the same PATCH must + // succeed: the post-update row still has an injection target. + empty := "" + newPath := "/tmp/atomic-swap" + updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "atomic-swap", codersdk.UpdateUserSecretRequest{ + EnvName: &empty, + FilePath: &newPath, + }) + require.NoError(t, err) + assert.Equal(t, "", updated.EnvName) + assert.Equal(t, "/tmp/atomic-swap", updated.FilePath) + }) } func requireSecretValidationContainsError(t *testing.T, err error, status int, field string, detailContains string) { @@ -510,8 +693,9 @@ func TestUserSecretLimits(t *testing.T) { var firstSecret codersdk.UserSecret for i := 0; i < codersdk.MaxUserSecretsPerUserCount; i++ { s, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: fmt.Sprintf("count-limit-%03d", i), - Value: "x", + Name: fmt.Sprintf("count-limit-%03d", i), + Value: "x", + FilePath: fmt.Sprintf("/tmp/count-limit-%03d", i), }) require.NoError(t, err) if i == 0 { @@ -521,8 +705,9 @@ func TestUserSecretLimits(t *testing.T) { // POST: the 51st secret is rejected. _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "one-too-many", - Value: "x", + Name: "one-too-many", + Value: "x", + FilePath: "/tmp/one-too-many", }) requireSecretAPIError(t, err, http.StatusBadRequest, "at most") @@ -537,8 +722,9 @@ func TestUserSecretLimits(t *testing.T) { // Other-user isolation: the second user's budget is independent. _, err = otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "other-user-secret", - Value: "x", + Name: "other-user-secret", + Value: "x", + FilePath: "/tmp/other-user-secret", }) require.NoError(t, err) }) @@ -702,8 +888,9 @@ func TestDeleteUserSecret(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "delete-me-secret", - Value: "my-value", + Name: "delete-me-secret", + Value: "my-value", + EnvName: "DELETE_ME_SECRET", }) require.NoError(t, err) diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go index 0cddeba7a16b1..e14846eeda3a7 100644 --- a/coderd/usersecretsimport_test.go +++ b/coderd/usersecretsimport_test.go @@ -179,8 +179,9 @@ func TestImportUserSecretsConflict(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "EXISTING", - Value: "original", + Name: "EXISTING", + Value: "original", + EnvName: "EXISTING", }) require.NoError(t, err) auditor.ResetLogs() @@ -217,8 +218,9 @@ func TestImportUserSecretsLimits(t *testing.T) { for i := 0; i < codersdk.MaxUserSecretsPerUserCount-1; i++ { _, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: fmt.Sprintf("prefill-%03d", i), - Value: "original", + Name: fmt.Sprintf("prefill-%03d", i), + Value: "original", + FilePath: fmt.Sprintf("/tmp/prefill-%03d", i), }) require.NoError(t, err) } diff --git a/coderd/util/xurl/xurl.go b/coderd/util/xurl/xurl.go new file mode 100644 index 0000000000000..2db01ec82e33e --- /dev/null +++ b/coderd/util/xurl/xurl.go @@ -0,0 +1,51 @@ +// Package xurl contains small helpers extending the standard net/url +// package. +package xurl + +import ( + "net/url" + "strings" + "unicode" +) + +// ContainsEncodedPath reports whether u's path is unsafe to forward to +// another server with its escaping intact, because that server could resolve +// it to a different path than the one it was matched on. +// +// [net/http.ServeMux] matches routes on the escaped path and only cleans +// literal dot segments, while upstreams decode percent-encoded unreserved +// characters (RFC 3986 section 6.2.2.2) and then remove dot segments. A path +// such as /v1/models/%2e%2e/files therefore matches a /v1/models/ route +// locally but is served as /v1/files upstream. +// +// Each escaped segment is decoded exactly once. It returns true if a segment +// fails to decode, or if a decoded segment: +// - contains a "." or ".." element, also when split on an escaped "/" or "\" +// for components that decode reserved characters, +// - still contains "%", which only arises from multiple encoding and would +// let a component that decodes twice produce a dot segment, +// - contains control characters. +// +// Escaped separators in ordinary segments, such as /models/org%2Fmodel, are +// allowed. +func ContainsEncodedPath(u *url.URL) bool { + for _, seg := range strings.Split(u.EscapedPath(), "/") { + decoded, err := url.PathUnescape(seg) + if err != nil { + return true + } + if strings.ContainsRune(decoded, '%') || strings.ContainsFunc(decoded, unicode.IsControl) { + return true + } + for _, part := range strings.FieldsFunc(decoded, isPathSeparator) { + if part == "." || part == ".." { + return true + } + } + } + return false +} + +func isPathSeparator(r rune) bool { + return r == '/' || r == '\\' +} diff --git a/coderd/util/xurl/xurl_test.go b/coderd/util/xurl/xurl_test.go new file mode 100644 index 0000000000000..7c81661ff0fd1 --- /dev/null +++ b/coderd/util/xurl/xurl_test.go @@ -0,0 +1,57 @@ +package xurl_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/util/xurl" +) + +func TestContainsEncodedPath(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + path string + want bool + }{ + {name: "Plain", path: "/v1/models"}, + {name: "ModelID", path: "/v1/models/gpt-4.1"}, + {name: "ResourceID", path: "/v1/responses/resp_abc123"}, + {name: "TrailingSlash", path: "/v1/models/"}, + {name: "EscapedSeparator", path: "/v1/models/org%2Fmodel"}, + {name: "EscapedSpace", path: "/v1/models/a%20b"}, + {name: "DotsInName", path: "/v1/models/..model"}, + {name: "LiteralDotDot", path: "/v1/models/../files", want: true}, + {name: "LiteralDot", path: "/v1/./models", want: true}, + {name: "EncodedDotDot", path: "/v1/models/%2e%2e/files", want: true}, + {name: "EncodedDotDotUpper", path: "/v1/models/%2E%2E/files", want: true}, + {name: "MixedDotDot", path: "/v1/models/.%2e/files", want: true}, + {name: "EncodedDot", path: "/v1/%2e/models", want: true}, + {name: "DotDotEscapedSlash", path: "/v1/models/..%2F..%2Ffiles", want: true}, + {name: "DotDotInsideSegment", path: "/v1/models/a%2F..%2Fb", want: true}, + {name: "DotDotEscapedBackslash", path: "/v1/models/..%5Cfiles", want: true}, + {name: "DoubleEncodedDotDot", path: "/v1/models/%252e%252e/files", want: true}, + {name: "TripleEncodedDotDot", path: "/v1/models/%25252e%25252e/files", want: true}, + {name: "EncodedNUL", path: "/v1/models/a%00b", want: true}, + {name: "EncodedNewline", path: "/v1/models/a%0Ab", want: true}, + {name: "InvalidEscape", path: "/v1/models/%zz", want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Build the URL the way the server does for an incoming + // request line, preserving the client's escaping. + u, err := url.ParseRequestURI(tc.path) + if err != nil { + // Paths the server itself refuses to parse never reach + // the handler. + require.True(t, tc.want, "unexpected parse error: %v", err) + return + } + require.Equal(t, tc.want, xurl.ContainsEncodedPath(u)) + }) + } +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index f41921c7bb5f2..50c829349d9ca 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -38,6 +38,7 @@ import ( "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentcontainers/acmock" "github.com/coder/coder/v2/agent/agentcontainers/watcher" + "github.com/coder/coder/v2/agent/agentcontext" "github.com/coder/coder/v2/agent/agenttest" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentapi/metadatabatcher" @@ -3243,6 +3244,54 @@ func TestWorkspaceAgentPushContextState(t *testing.T) { require.False(t, resp.GetAccepted()) } +// TestWorkspaceAgentPushContextStateDisabled verifies the +// --disable-workspace-agent-context-sync kill switch end to end over a +// real dRPC connection: the handler's Unimplemented code must survive +// the transport and be translated by the agent's DRPCPusher into +// ErrPushUnimplemented, which is what terminates the agent's RunPush +// loop instead of retrying with backoff. Nothing may be persisted. +func TestWorkspaceAgentPushContextStateDisabled(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.DisableWorkspaceAgentContextSync = true + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + user := coderdtest.CreateFirstUser(t, client) + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + require.Len(t, r.Agents, 1) + agentID := r.Agents[0].ID + + ctx := testutil.Context(t, testutil.WaitLong) + + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(r.AgentToken)) + aAPI, _, err := agentClient.ConnectRPC210(ctx) + require.NoError(t, err) + defer func() { + cErr := aAPI.DRPCConn().Close() + require.NoError(t, cErr) + }() + + // Push through the same adapter the agent's RunPush loop uses so + // the test breaks if either side of the Unimplemented contract + // changes. + pusher := agentcontext.NewDRPCPusher(aAPI) + resp, err := pusher.PushContextState(ctx, &agentcontext.PushRequest{ + Version: 1, + Initial: true, + }) + require.ErrorIs(t, err, agentcontext.ErrPushUnimplemented) + require.Nil(t, resp) + + // The rejected push must not have persisted anything. + _, err = db.GetLatestWorkspaceAgentContextSnapshot(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // Test assertions read agent-pushed rows directly from the store. + require.ErrorIs(t, err, sql.ErrNoRows) +} + func requireGetManifest(ctx context.Context, t testing.TB, aAPI agentproto.DRPCAgentClient) agentsdk.Manifest { mp, err := aAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) require.NoError(t, err) diff --git a/coderd/workspaceagentsrpc.go b/coderd/workspaceagentsrpc.go index 1c4e14c35527f..9e7de438e31a0 100644 --- a/coderd/workspaceagentsrpc.go +++ b/coderd/workspaceagentsrpc.go @@ -192,6 +192,7 @@ func (api *API) workspaceAgentRPC(rw http.ResponseWriter, r *http.Request) { // Optional: UpdateAgentMetricsFn: api.UpdateAgentMetrics, ContextDirtyMarker: contextDirtyMarker, + ContextSyncDisabled: api.DeploymentValues.DisableWorkspaceAgentContextSync.Value(), }, workspace, workspaceAgent) streamID := tailnet.StreamID{ diff --git a/coderd/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go index 1595462d19177..5d083c131f990 100644 --- a/coderd/workspaceagentsrpc_test.go +++ b/coderd/workspaceagentsrpc_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "storj.io/drpc/drpcerr" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" @@ -17,6 +18,8 @@ import ( "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/tailnet" + tailnetproto "github.com/coder/coder/v2/tailnet/proto" "github.com/coder/coder/v2/testutil" ) @@ -109,6 +112,47 @@ func TestWorkspaceAgentReportStats(t *testing.T) { } } +func TestWorkspaceAgentRPC_TailnetMethods(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + workspace := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent().Do() + + agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(workspace.AgentToken)) + conn, err := agentClient.ConnectRPC(ctx) + require.NoError(t, err) + t.Cleanup(func() { + _ = conn.Close() + }) + + tailnetClient := tailnetproto.NewDRPCTailnetClient(conn) + _, err = tailnetClient.RefreshResumeToken(ctx, &tailnetproto.RefreshResumeTokenRequest{}) + require.Error(t, err) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + updates, err := tailnetClient.WorkspaceUpdates(ctx, &tailnetproto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(user.UserID), + }) + if err == nil { + _, err = updates.Recv() + } + require.Error(t, err) + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + telemetry, err := tailnetClient.PostTelemetry(ctx, &tailnetproto.TelemetryRequest{}) + require.NoError(t, err) + require.NotNil(t, telemetry) + + agentAPI := agentproto.NewDRPCAgentClient(conn) + _, err = agentAPI.GetManifest(ctx, &agentproto.GetManifestRequest{}) + require.NoError(t, err) +} + func TestAgentAPI_LargeManifest(t *testing.T) { t.Parallel() diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 8db2858e01e32..db6f77ab95142 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -115,6 +115,15 @@ func TestWorkspaceApplicationAuth(t *testing.T) { redirectURI: "https://proxy.test.coder.com/path", expectRedirect: "https://proxy.test.coder.com/path", }, + { + name: "RejectProxyAccessURLPrefix", + accessURL: "https://test.coder.com", + appHostname: "*.test.coder.com", + proxyURL: "https://proxy.test.coder.com", + proxyAppHostname: "*.proxy.test.coder.com", + redirectURI: "https://proxy.test.coder/path", + expectRedirect: "", + }, { name: "ProxySubdomainOK", accessURL: "https://test.coder.com", diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 9678defee55af..73a8d8241cbcf 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1,6 +1,7 @@ package coderd import ( + "cmp" "context" "database/sql" "encoding/json" @@ -81,15 +82,17 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { workspaceBuild, workspace, data.jobs[0], - data.resources, - data.metadata, - data.agents, - data.apps, - data.appStatuses, - data.scripts, - data.logSources, + newWorkspaceBuildIndex( + data.resources, + data.metadata, + data.agents, + data.apps, + data.appStatuses, + data.scripts, + data.logSources, + nil, + ), data.templateVersions[0], - nil, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -291,15 +294,17 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ workspaceBuild, workspace, data.jobs[0], - data.resources, - data.metadata, - data.agents, - data.apps, - data.appStatuses, - data.scripts, - data.logSources, + newWorkspaceBuildIndex( + data.resources, + data.metadata, + data.agents, + data.apps, + data.appStatuses, + data.scripts, + data.logSources, + data.provisionerDaemons, + ), data.templateVersions[0], - data.provisionerDaemons, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -616,15 +621,8 @@ func (api *API) postWorkspaceBuildsInternal( *workspaceBuild, workspace, queuePos, - []database.WorkspaceResource{}, - []database.WorkspaceResourceMetadatum{}, - []database.WorkspaceAgent{}, - []database.WorkspaceApp{}, - []database.WorkspaceAppStatus{}, - []database.GetWorkspaceAgentScriptsByAgentIDsRow{}, - []database.WorkspaceAgentLogSource{}, + newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons), database.TemplateVersion{}, - provisionerDaemons, ) if err != nil { return codersdk.WorkspaceBuild{}, httperror.NewResponseError( @@ -1245,6 +1243,78 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab }, nil } +// workspaceBuildIndex groups build rows by the ID they are looked up with. +// Conversion reorders rows within a bucket in place, so an index must be read +// by a single goroutine. +type workspaceBuildIndex struct { + resourcesByJobID map[uuid.UUID][]database.WorkspaceResource + metadataByResourceID map[uuid.UUID][]database.WorkspaceResourceMetadatum + agentsByResourceID map[uuid.UUID][]database.WorkspaceAgent + appsByAgentID map[uuid.UUID][]database.WorkspaceApp + statusesByAgentID map[uuid.UUID][]database.WorkspaceAppStatus + scriptsByAgentID map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow + logSourcesByAgentID map[uuid.UUID][]database.WorkspaceAgentLogSource + daemonsByJobID map[uuid.UUID][]database.ProvisionerDaemon +} + +// newWorkspaceBuildIndex makes one pass over each slice, copying rows into +// per-ID buckets. The input slices are neither modified nor aliased. +func newWorkspaceBuildIndex( + workspaceResources []database.WorkspaceResource, + resourceMetadata []database.WorkspaceResourceMetadatum, + resourceAgents []database.WorkspaceAgent, + agentApps []database.WorkspaceApp, + agentAppStatuses []database.WorkspaceAppStatus, + agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow, + agentLogSources []database.WorkspaceAgentLogSource, + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, +) *workspaceBuildIndex { + index := &workspaceBuildIndex{ + resourcesByJobID: make(map[uuid.UUID][]database.WorkspaceResource), + metadataByResourceID: make(map[uuid.UUID][]database.WorkspaceResourceMetadatum), + agentsByResourceID: make(map[uuid.UUID][]database.WorkspaceAgent), + appsByAgentID: make(map[uuid.UUID][]database.WorkspaceApp), + statusesByAgentID: make(map[uuid.UUID][]database.WorkspaceAppStatus), + scriptsByAgentID: make(map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow), + logSourcesByAgentID: make(map[uuid.UUID][]database.WorkspaceAgentLogSource), + daemonsByJobID: make(map[uuid.UUID][]database.ProvisionerDaemon), + } + for _, resource := range workspaceResources { + index.resourcesByJobID[resource.JobID] = append(index.resourcesByJobID[resource.JobID], resource) + } + for _, metadata := range resourceMetadata { + index.metadataByResourceID[metadata.WorkspaceResourceID] = append(index.metadataByResourceID[metadata.WorkspaceResourceID], metadata) + } + for _, agent := range resourceAgents { + index.agentsByResourceID[agent.ResourceID] = append(index.agentsByResourceID[agent.ResourceID], agent) + } + for _, app := range agentApps { + index.appsByAgentID[app.AgentID] = append(index.appsByAgentID[app.AgentID], app) + } + for _, status := range agentAppStatuses { + index.statusesByAgentID[status.AgentID] = append(index.statusesByAgentID[status.AgentID], status) + } + for _, script := range agentScripts { + index.scriptsByAgentID[script.WorkspaceAgentID] = append(index.scriptsByAgentID[script.WorkspaceAgentID], script) + } + for _, logSource := range agentLogSources { + index.logSourcesByAgentID[logSource.WorkspaceAgentID] = append(index.logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) + } + for _, daemon := range provisionerDaemons { + index.daemonsByJobID[daemon.JobID] = append(index.daemonsByJobID[daemon.JobID], daemon.ProvisionerDaemon) + } + // Agents within a resource are ordered by display order, then name. + for _, agents := range index.agentsByResourceID { + slices.SortFunc(agents, func(a, b database.WorkspaceAgent) int { + return cmp.Or( + cmp.Compare(a.DisplayOrder, b.DisplayOrder), + cmp.Compare(a.Name, b.Name), + ) + }) + } + return index +} + func (api *API) convertWorkspaceBuilds( workspaceBuilds []database.WorkspaceBuild, workspaces []database.Workspace, @@ -1272,6 +1342,17 @@ func (api *API) convertWorkspaceBuilds( templateVersionByID[templateVersion.ID] = templateVersion } + index := newWorkspaceBuildIndex( + workspaceResources, + resourceMetadata, + resourceAgents, + agentApps, + agentAppStatuses, + agentScripts, + agentLogSources, + provisionerDaemons, + ) + // Should never be nil for API consistency apiBuilds := []codersdk.WorkspaceBuild{} for _, build := range workspaceBuilds { @@ -1292,15 +1373,8 @@ func (api *API) convertWorkspaceBuilds( build, workspace, job, - workspaceResources, - resourceMetadata, - resourceAgents, - agentApps, - agentAppStatuses, - agentScripts, - agentLogSources, + index, templateVersion, - provisionerDaemons, ) if err != nil { return nil, xerrors.Errorf("converting workspace build: %w", err) @@ -1316,64 +1390,16 @@ func (api *API) convertWorkspaceBuild( build database.WorkspaceBuild, workspace database.Workspace, job database.GetProvisionerJobsByIDsWithQueuePositionRow, - workspaceResources []database.WorkspaceResource, - resourceMetadata []database.WorkspaceResourceMetadatum, - resourceAgents []database.WorkspaceAgent, - agentApps []database.WorkspaceApp, - agentAppStatuses []database.WorkspaceAppStatus, - agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow, - agentLogSources []database.WorkspaceAgentLogSource, + index *workspaceBuildIndex, templateVersion database.TemplateVersion, - provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow, ) (codersdk.WorkspaceBuild, error) { - resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} - for _, resource := range workspaceResources { - resourcesByJobID[resource.JobID] = append(resourcesByJobID[resource.JobID], resource) - } - metadataByResourceID := map[uuid.UUID][]database.WorkspaceResourceMetadatum{} - for _, metadata := range resourceMetadata { - metadataByResourceID[metadata.WorkspaceResourceID] = append(metadataByResourceID[metadata.WorkspaceResourceID], metadata) - } - agentsByResourceID := map[uuid.UUID][]database.WorkspaceAgent{} - for _, agent := range resourceAgents { - agentsByResourceID[agent.ResourceID] = append(agentsByResourceID[agent.ResourceID], agent) - } - appsByAgentID := map[uuid.UUID][]database.WorkspaceApp{} - for _, app := range agentApps { - appsByAgentID[app.AgentID] = append(appsByAgentID[app.AgentID], app) - } - scriptsByAgentID := map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow{} - for _, script := range agentScripts { - scriptsByAgentID[script.WorkspaceAgentID] = append(scriptsByAgentID[script.WorkspaceAgentID], script) - } - logSourcesByAgentID := map[uuid.UUID][]database.WorkspaceAgentLogSource{} - for _, logSource := range agentLogSources { - logSourcesByAgentID[logSource.WorkspaceAgentID] = append(logSourcesByAgentID[logSource.WorkspaceAgentID], logSource) - } - provisionerDaemonsForThisWorkspaceBuild := []database.ProvisionerDaemon{} - for _, provisionerDaemon := range provisionerDaemons { - if provisionerDaemon.JobID != job.ProvisionerJob.ID { - continue - } - provisionerDaemonsForThisWorkspaceBuild = append(provisionerDaemonsForThisWorkspaceBuild, provisionerDaemon.ProvisionerDaemon) - } - matchedProvisioners := db2sdk.MatchedProvisioners(provisionerDaemonsForThisWorkspaceBuild, job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) - statusesByAgentID := map[uuid.UUID][]database.WorkspaceAppStatus{} - for _, status := range agentAppStatuses { - statusesByAgentID[status.AgentID] = append(statusesByAgentID[status.AgentID], status) - } + matchedProvisioners := db2sdk.MatchedProvisioners(index.daemonsByJobID[job.ProvisionerJob.ID], job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) - resources := resourcesByJobID[job.ProvisionerJob.ID] + resources := index.resourcesByJobID[job.ProvisionerJob.ID] apiResources := make([]codersdk.WorkspaceResource, 0) resourceAgentsMinOrder := map[uuid.UUID]int32{} // map[resource.ID]minOrder for _, resource := range resources { - agents := agentsByResourceID[resource.ID] - sort.Slice(agents, func(i, j int) bool { - if agents[i].DisplayOrder != agents[j].DisplayOrder { - return agents[i].DisplayOrder < agents[j].DisplayOrder - } - return agents[i].Name < agents[j].Name - }) + agents := index.agentsByResourceID[resource.ID] apiAgents := make([]codersdk.WorkspaceAgent, 0) resourceAgentsMinOrder[resource.ID] = math.MaxInt32 @@ -1381,10 +1407,10 @@ func (api *API) convertWorkspaceBuild( for _, agent := range agents { resourceAgentsMinOrder[resource.ID] = min(resourceAgentsMinOrder[resource.ID], agent.DisplayOrder) - apps := appsByAgentID[agent.ID] - scripts := scriptsByAgentID[agent.ID] - statuses := statusesByAgentID[agent.ID] - logSources := logSourcesByAgentID[agent.ID] + apps := index.appsByAgentID[agent.ID] + scripts := index.scriptsByAgentID[agent.ID] + statuses := index.statusesByAgentID[agent.ID] + logSources := index.logSourcesByAgentID[agent.ID] apiAgent, err := db2sdk.WorkspaceAgent( api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, statuses, agent, workspace.OwnerUsername, workspace.WorkspaceTable()), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), @@ -1394,7 +1420,7 @@ func (api *API) convertWorkspaceBuild( } apiAgents = append(apiAgents, apiAgent) } - metadata := append(make([]database.WorkspaceResourceMetadatum, 0), metadataByResourceID[resource.ID]...) + metadata := append(make([]database.WorkspaceResourceMetadatum, 0), index.metadataByResourceID[resource.ID]...) apiResources = append(apiResources, convertWorkspaceResource(resource, apiAgents, metadata)) } sort.Slice(apiResources, func(i, j int) bool { diff --git a/coderd/workspacebuilds_internal_test.go b/coderd/workspacebuilds_internal_test.go new file mode 100644 index 0000000000000..f7e9e55c04d57 --- /dev/null +++ b/coderd/workspacebuilds_internal_test.go @@ -0,0 +1,299 @@ +package coderd + +import ( + "database/sql" + "fmt" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/tailnet" +) + +// buildFixture holds one batch of rows shaped like the output of +// workspaceBuildsData: a set of builds plus the flat resource, agent, app, +// script, log source, status, and daemon rows for all of them. +type buildFixture struct { + builds []database.WorkspaceBuild + workspaces []database.Workspace + jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow + templateVersions []database.TemplateVersion + resources []database.WorkspaceResource + metadata []database.WorkspaceResourceMetadatum + agents []database.WorkspaceAgent + apps []database.WorkspaceApp + statuses []database.WorkspaceAppStatus + scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow + logSources []database.WorkspaceAgentLogSource + daemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow +} + +func newBuildFixture(builds, resourcesPerBuild int, agentsPerResource int32, appsPerAgent int) buildFixture { + now := time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC) + f := buildFixture{} + for b := range builds { + workspaceID := uuid.New() + jobID := uuid.New() + templateVersionID := uuid.New() + + f.workspaces = append(f.workspaces, database.Workspace{ + ID: workspaceID, + OwnerID: uuid.New(), + OwnerUsername: fmt.Sprintf("owner-%d", b), + Name: fmt.Sprintf("workspace-%d", b), + }) + f.jobs = append(f.jobs, database.GetProvisionerJobsByIDsWithQueuePositionRow{ + ProvisionerJob: database.ProvisionerJob{ + ID: jobID, + CreatedAt: now, + StartedAt: sql.NullTime{Time: now, Valid: true}, + }, + }) + f.templateVersions = append(f.templateVersions, database.TemplateVersion{ + ID: templateVersionID, + Name: fmt.Sprintf("version-%d", b), + }) + f.builds = append(f.builds, database.WorkspaceBuild{ + ID: uuid.New(), + WorkspaceID: workspaceID, + JobID: jobID, + TemplateVersionID: templateVersionID, + BuildNumber: 1, + Transition: database.WorkspaceTransitionStart, + }) + f.daemons = append(f.daemons, database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{ + JobID: jobID, + ProvisionerDaemon: database.ProvisionerDaemon{ + ID: uuid.New(), + Name: fmt.Sprintf("daemon-%d", b), + LastSeenAt: sql.NullTime{Time: now, Valid: true}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }, + }) + + for r := range resourcesPerBuild { + resourceID := uuid.New() + f.resources = append(f.resources, database.WorkspaceResource{ + ID: resourceID, + JobID: jobID, + Transition: database.WorkspaceTransitionStart, + Type: "example", + Name: fmt.Sprintf("resource-%d-%d", b, r), + }) + f.metadata = append(f.metadata, database.WorkspaceResourceMetadatum{ + WorkspaceResourceID: resourceID, + Key: "key", + Value: sql.NullString{String: "value", Valid: true}, + }) + + for a := range agentsPerResource { + agentID := uuid.New() + f.agents = append(f.agents, database.WorkspaceAgent{ + ID: agentID, + ResourceID: resourceID, + Name: fmt.Sprintf("agent-%d-%d-%d", b, r, a), + DisplayOrder: agentsPerResource - a, + APIKeyScope: database.AgentKeyScopeEnumAll, + }) + f.scripts = append(f.scripts, database.GetWorkspaceAgentScriptsByAgentIDsRow{ + WorkspaceAgentID: agentID, + Script: "echo hello", + }) + f.logSources = append(f.logSources, database.WorkspaceAgentLogSource{ + WorkspaceAgentID: agentID, + DisplayName: "startup", + }) + + for p := range appsPerAgent { + appID := uuid.New() + f.apps = append(f.apps, database.WorkspaceApp{ + ID: appID, + AgentID: agentID, + Slug: fmt.Sprintf("app-%d-%d-%d-%d", b, r, a, p), + DisplayName: "App", + Health: database.WorkspaceAppHealthHealthy, + SharingLevel: database.AppSharingLevelOwner, + OpenIn: database.WorkspaceAppOpenInSlimWindow, + }) + f.statuses = append(f.statuses, database.WorkspaceAppStatus{ + ID: uuid.New(), + AgentID: agentID, + AppID: appID, + State: database.WorkspaceAppStatusStateComplete, + }) + } + } + } + } + return f +} + +func newConvertAPI() *API { + api := &API{ + Options: &Options{ + AgentInactiveDisconnectTimeout: time.Minute, + DeploymentValues: &codersdk.DeploymentValues{}, + }, + } + coordinator := tailnet.NewCoordinator(slog.Logger{}) + api.TailnetCoordinator.Store(&coordinator) + return api +} + +func (f buildFixture) convert(api *API) ([]codersdk.WorkspaceBuild, error) { + return api.convertWorkspaceBuilds( + f.builds, + f.workspaces, + f.jobs, + f.resources, + f.metadata, + f.agents, + f.apps, + f.statuses, + f.scripts, + f.logSources, + f.templateVersions, + f.daemons, + ) +} + +// TestConvertWorkspaceBuildsAgentOrder covers the ordering that the shared +// index is responsible for: agents come back sorted by display order. The +// fixture assigns display order in reverse of agent name, so the expected +// result is names in descending order. +func TestConvertWorkspaceBuildsAgentOrder(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(3, 2, 3, 1) + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 3) + + for _, build := range builds { + require.NotEmpty(t, build.Resources) + for _, resource := range build.Resources { + require.Len(t, resource.Agents, 3) + for i := 1; i < len(resource.Agents); i++ { + require.Greater(t, resource.Agents[i-1].Name, resource.Agents[i].Name) + } + } + } +} + +// TestConvertWorkspaceBuildsAgentNameTiebreak covers the second half of the +// agent comparison: agents sharing a display order are ordered by name. +func TestConvertWorkspaceBuildsAgentNameTiebreak(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(1, 1, 3, 1) + for i := range fixture.agents { + fixture.agents[i].DisplayOrder = 0 + } + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 1) + require.Len(t, builds[0].Resources, 1) + + agents := builds[0].Resources[0].Agents + require.Len(t, agents, 3) + for i := 1; i < len(agents); i++ { + require.Less(t, agents[i-1].Name, agents[i].Name) + } +} + +// TestConvertWorkspaceBuildsRowsPerBuild asserts that a build receives only the +// rows keyed to its own job and agents. The fixture encodes the build index in +// app slugs, so apps read from another build are detectable by slug. +func TestConvertWorkspaceBuildsRowsPerBuild(t *testing.T) { + t.Parallel() + + const ( + buildCount = 3 + resourcesPerJob = 2 + agentsPerRes = 2 + appsPerAgent = 2 + ) + + api := newConvertAPI() + fixture := newBuildFixture(buildCount, resourcesPerJob, agentsPerRes, appsPerAgent) + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, buildCount) + + for b, build := range builds { + require.Len(t, build.Resources, resourcesPerJob) + + for _, resource := range build.Resources { + require.Equal(t, fixture.jobs[b].ProvisionerJob.ID, resource.JobID) + require.Len(t, resource.Metadata, 1) + require.Len(t, resource.Agents, agentsPerRes) + + for _, agent := range resource.Agents { + require.Len(t, agent.Scripts, 1) + require.Len(t, agent.LogSources, 1) + require.Len(t, agent.Apps, appsPerAgent) + + for _, app := range agent.Apps { + require.True(t, strings.HasPrefix(app.Slug, fmt.Sprintf("app-%d-", b)), app.Slug) + require.Len(t, app.Statuses, 1) + } + } + } + } +} + +// TestConvertWorkspaceBuildsMatchedProvisioners asserts that eligible daemons +// are matched to the build whose job they were fetched for. The last build in +// the batch has no daemon rows, so it reports zero counts while the others +// report one. +func TestConvertWorkspaceBuildsMatchedProvisioners(t *testing.T) { + t.Parallel() + + api := newConvertAPI() + fixture := newBuildFixture(3, 1, 1, 1) + fixture.daemons = fixture.daemons[:len(fixture.daemons)-1] + + builds, err := fixture.convert(api) + require.NoError(t, err) + require.Len(t, builds, 3) + + for _, build := range builds[:2] { + require.NotNil(t, build.MatchedProvisioners) + require.Equal(t, 1, build.MatchedProvisioners.Count) + require.Equal(t, 1, build.MatchedProvisioners.Available) + require.True(t, build.MatchedProvisioners.MostRecentlySeen.Valid) + } + + last := builds[2] + require.NotNil(t, last.MatchedProvisioners) + require.Equal(t, 0, last.MatchedProvisioners.Count) + require.Equal(t, 0, last.MatchedProvisioners.Available) + require.False(t, last.MatchedProvisioners.MostRecentlySeen.Valid) +} + +func BenchmarkConvertWorkspaceBuilds(b *testing.B) { + for _, builds := range []int{1, 25, 100} { + b.Run(fmt.Sprintf("Builds%d", builds), func(b *testing.B) { + api := newConvertAPI() + fixture := newBuildFixture(builds, 5, 2, 4) + + b.ReportAllocs() + for b.Loop() { + if _, err := fixture.convert(api); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/coderd/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index 5a2c36f7ef96a..674bb60956f35 100644 --- a/coderd/workspaceresourceauth.go +++ b/coderd/workspaceresourceauth.go @@ -104,8 +104,7 @@ func (api *API) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter, return } - // We leave the audience blank. It's not important we validate who made the token. - payload, err := api.GoogleTokenValidator.Validate(ctx, req.JSONWebToken, "") + payload, err := api.GoogleTokenValidator.Validate(ctx, req.JSONWebToken, "coder") if err != nil { httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{ Message: "Invalid GCP identity.", diff --git a/coderd/workspaceresourceauth_test.go b/coderd/workspaceresourceauth_test.go index b327c120bc348..74a146a2d2a3e 100644 --- a/coderd/workspaceresourceauth_test.go +++ b/coderd/workspaceresourceauth_test.go @@ -291,7 +291,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { t.Parallel() instanceID := newTestInstanceID(t) - validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true) + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true, "coder") client := coderdtest.New(t, &coderdtest.Options{ GoogleTokenValidator: validator, }) @@ -310,7 +310,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { t.Parallel() instanceID := newTestInstanceID(t) - validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false, "coder") client := coderdtest.New(t, &coderdtest.Options{ GoogleTokenValidator: validator, }) @@ -329,10 +329,11 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { t.Parallel() instanceID := newTestInstanceID(t) - validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) - client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{ + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false, "coder") + client, store := setupInstanceIDWorkspace(t, &coderdtest.Options{ GoogleTokenValidator: validator, }, workspaceAgentsForInstanceID(instanceID, "dev")) + expectedAgent := requireWorkspaceAgentByInstanceIDAndName(t, store, instanceID, "dev") ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -340,13 +341,33 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { agentClient := agentsdk.New(client.URL, agentsdk.WithGoogleInstanceIdentity("", metadata)) err := agentClient.RefreshToken(ctx) require.NoError(t, err) + require.Equal(t, expectedAgent.AuthToken.String(), agentClient.SDK.SessionToken()) + }) + + t.Run("ForeignAudience", func(t *testing.T) { + t.Parallel() + + instanceID := newTestInstanceID(t) + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false, "other-service") + client, _ := setupInstanceIDWorkspace(t, &coderdtest.Options{ + GoogleTokenValidator: validator, + }, workspaceAgentsForInstanceID(instanceID, "dev")) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + agentClient := agentsdk.New(client.URL, agentsdk.WithGoogleInstanceIdentity("", metadata)) + err := agentClient.RefreshToken(ctx) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) t.Run("Ambiguous/GoogleWithSelector", func(t *testing.T) { t.Parallel() instanceID := newTestInstanceID(t) - validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) + validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false, "coder") client, store := setupInstanceIDWorkspace(t, &coderdtest.Options{ GoogleTokenValidator: validator, }, workspaceAgentsForInstanceID(instanceID, "alpha", "beta")) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index eb89dc723cf29..da8339c9ab8f5 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -830,15 +830,8 @@ func createWorkspace( ProvisionerJob: *provisionerJob, QueuePosition: 0, }, - []database.WorkspaceResource{}, - []database.WorkspaceResourceMetadatum{}, - []database.WorkspaceAgent{}, - []database.WorkspaceApp{}, - []database.WorkspaceAppStatus{}, - []database.GetWorkspaceAgentScriptsByAgentIDsRow{}, - []database.WorkspaceAgentLogSource{}, + newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons), database.TemplateVersion{}, - provisionerDaemons, ) if err != nil { return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 59072a6e0a250..5daff17e44317 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1518,28 +1518,9 @@ func (p *Server) SendMessage( return result, nil } -func (p *Server) checkUsageLimit(ctx context.Context, store database.Store, ownerID uuid.UUID, organizationID uuid.NullUUID) error { - status, err := ResolveUsageLimitStatus(ctx, store, ownerID, organizationID, time.Now()) - if err != nil { - // Fail open: never block chat due to a limit-resolution failure. - p.logger.Warn(ctx, "usage limit check failed, allowing message", - slog.F("owner_id", ownerID), - slog.Error(err), - ) - return nil - } - if status == nil { - return nil - } - // Block when current spend reaches or exceeds limit (>= ensures - // the user cannot start new conversations once the limit is hit). - if status.SpendLimitMicros != nil && status.CurrentSpend >= *status.SpendLimitMicros { - return &UsageLimitExceededError{ - LimitMicros: *status.SpendLimitMicros, - ConsumedMicros: status.CurrentSpend, - PeriodEnd: status.PeriodEnd, - } - } +// checkUsageLimit is a no-op. Usage limits (a.k.a. "Budgets") are now enforced +// by AI Gateway. +func (*Server) checkUsageLimit(_ context.Context, _ database.Store, _ uuid.UUID, _ uuid.NullUUID) error { return nil } diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 15bb8df0f4e31..0ed9206878a6f 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -1098,7 +1098,6 @@ func TestRegenerateChatTitle_PersistsAndBroadcasts(t *testing.T) { }}, nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes() - db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows) db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{UserID: ownerID, TokenName: GatewayTokenName(ownerID)}).Return(database.APIKey{ID: activeAPIKeyID, UserID: ownerID, ExpiresAt: time.Now().Add(48 * time.Hour)}, nil) db.EXPECT().GetChatMessagesByChatIDAscPaginated( gomock.Any(), @@ -1250,7 +1249,6 @@ func TestRegenerateChatTitle_SkipsPersistWhenTitleChangedConcurrently(t *testing }}, nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), providerID).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes() db.EXPECT().GetAIProviderKeysByProviderIDs(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{{ProviderID: providerID, APIKey: "test-key"}}, nil).AnyTimes() - db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows) db.EXPECT().GetChatGatewayAPIKey(gomock.Any(), database.GetChatGatewayAPIKeyParams{UserID: ownerID, TokenName: GatewayTokenName(ownerID)}).Return(database.APIKey{ID: activeAPIKeyID, UserID: ownerID, ExpiresAt: time.Now().Add(48 * time.Hour)}, nil) db.EXPECT().GetChatMessagesByChatIDAscPaginated( gomock.Any(), diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 7accb04b4649b..baf9753462313 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -1849,78 +1849,6 @@ func TestCreateChatInsertsWorkspaceAwarenessMessage(t *testing.T) { }) } -func TestCreateChatRejectsWhenUsageLimitReached(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - replica := newTestServer(t, db, ps, uuid.New()) - - ctx := testutil.Context(t, testutil.WaitLong) - user, org, model := seedChatDependencies(t, db) - - _, err := db.UpsertChatUsageLimitConfig(ctx, database.UpsertChatUsageLimitConfigParams{ - Enabled: true, - DefaultLimitMicros: 100, - Period: string(codersdk.ChatUsageLimitPeriodDay), - }) - require.NoError(t, err) - - existingChat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: org.ID, - OwnerID: user.ID, - Title: "existing-limit-chat", - LastModelConfigID: model.ID, - }) - - assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("assistant"), - }) - require.NoError(t, err) - - _ = dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: existingChat.ID, - ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, - Role: database.ChatMessageRoleAssistant, - ContentVersion: chatprompt.CurrentContentVersion, - Content: assistantContent, - TotalCostMicros: sql.NullInt64{Int64: 100, Valid: true}, - }) - - beforeChats, err := db.GetChats(ctx, database.GetChatsParams{ - OwnedOnly: true, - ViewerID: user.ID, - AfterID: uuid.Nil, - OffsetOpt: 0, - LimitOpt: 100, - }) - require.NoError(t, err) - require.Len(t, beforeChats, 1) - - _, err = replica.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - Title: "over-limit", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("hello")}, - }) - require.Error(t, err) - - var limitErr *chatd.UsageLimitExceededError - require.ErrorAs(t, err, &limitErr) - require.Equal(t, int64(100), limitErr.LimitMicros) - require.Equal(t, int64(100), limitErr.ConsumedMicros) - - afterChats, err := db.GetChats(ctx, database.GetChatsParams{ - OwnedOnly: true, - ViewerID: user.ID, - AfterID: uuid.Nil, - OffsetOpt: 0, - LimitOpt: 100, - }) - require.NoError(t, err) - require.Len(t, afterChats, len(beforeChats)) -} - func TestAutoPromoteQueuedMessagesPreservesPerTurnModelOrder(t *testing.T) { t.Parallel() @@ -2248,76 +2176,6 @@ func TestInterruptAutoPromotionIgnoresLaterUsageLimitIncrease(t *testing.T) { require.Equal(t, []string{"hello", "queued", "later queued"}, userTexts) } -func TestEditMessageRejectsWhenUsageLimitReached(t *testing.T) { - t.Parallel() - - db, ps := dbtestutil.NewDB(t) - replica := newTestServer(t, db, ps, uuid.New()) - - ctx := testutil.Context(t, testutil.WaitLong) - user, org, model := seedChatDependencies(t, db) - - _, err := db.UpsertChatUsageLimitConfig(ctx, database.UpsertChatUsageLimitConfigParams{ - Enabled: true, - DefaultLimitMicros: 100, - Period: string(codersdk.ChatUsageLimitPeriodDay), - }) - require.NoError(t, err) - - chat, err := replica.CreateChat(ctx, chatd.CreateOptions{ - OrganizationID: org.ID, - OwnerID: user.ID, - Title: "edit-limit-reached", - ModelConfigID: model.ID, - InitialUserContent: []codersdk.ChatMessagePart{codersdk.ChatMessageText("original")}, - }) - require.NoError(t, err) - - messages, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: chat.ID, - AfterID: 0, - }) - require.NoError(t, err) - require.Len(t, messages, 1) - editedMessageID := messages[0].ID - - assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("assistant"), - }) - require.NoError(t, err) - - _ = dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: chat.ID, - ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, - Role: database.ChatMessageRoleAssistant, - ContentVersion: chatprompt.CurrentContentVersion, - Content: assistantContent, - TotalCostMicros: sql.NullInt64{Int64: 100, Valid: true}, - }) - - _, err = replica.EditMessage(ctx, chatd.EditMessageOptions{ - ChatID: chat.ID, - EditedMessageID: editedMessageID, - Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("edited")}, - }) - require.Error(t, err) - - var limitErr *chatd.UsageLimitExceededError - require.ErrorAs(t, err, &limitErr) - require.Equal(t, int64(100), limitErr.LimitMicros) - require.Equal(t, int64(100), limitErr.ConsumedMicros) - - messages, err = db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ - ChatID: chat.ID, - AfterID: 0, - }) - require.NoError(t, err) - require.Len(t, messages, 2) - originalMessage := db2sdk.ChatMessage(messages[0]) - require.Len(t, originalMessage.Content, 1) - require.Equal(t, "original", originalMessage.Content[0].Text) -} - func TestEditMessageRejectsMissingMessage(t *testing.T) { t.Parallel() @@ -5660,106 +5518,6 @@ func TestActiveServer_Compaction(t *testing.T) { }) } -func TestCompactChat(t *testing.T) { - t.Parallel() - - setupAtLimitChat := func( - t *testing.T, - status database.ChatStatus, - compactable bool, - ) (context.Context, database.Store, *chatd.Server, database.Chat) { - t.Helper() - - db, ps := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitLong) - user, org, model := seedChatDependencies(t, db) - server := newTestServer(t, db, ps, uuid.New()) - - _, err := db.UpsertChatUsageLimitConfig(ctx, database.UpsertChatUsageLimitConfigParams{ - Enabled: true, - DefaultLimitMicros: 100, - Period: string(codersdk.ChatUsageLimitPeriodDay), - }) - require.NoError(t, err) - - spendChat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: org.ID, - OwnerID: user.ID, - LastModelConfigID: model.ID, - }) - spendContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("spent usage"), - }) - require.NoError(t, err) - dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: spendChat.ID, - ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, - Role: database.ChatMessageRoleAssistant, - ContentVersion: chatprompt.CurrentContentVersion, - Content: spendContent, - TotalCostMicros: sql.NullInt64{Int64: 100, Valid: true}, - }) - - chat := dbgen.Chat(t, db, database.Chat{ - OrganizationID: org.ID, - OwnerID: user.ID, - LastModelConfigID: model.ID, - Status: status, - }) - if compactable { - assistantContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("assistant response"), - }) - require.NoError(t, err) - dbgen.ChatMessage(t, db, database.ChatMessage{ - ChatID: chat.ID, - ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true}, - Role: database.ChatMessageRoleAssistant, - ContentVersion: chatprompt.CurrentContentVersion, - Content: assistantContent, - }) - } - - return ctx, db, server, chat - } - - t.Run("usage limit rejects compactable idle chat", func(t *testing.T) { - t.Parallel() - - ctx, db, server, chat := setupAtLimitChat(t, database.ChatStatusWaiting, true) - _, err := server.CompactChat(ctx, chat) - - var limitErr *chatd.UsageLimitExceededError - require.ErrorAs(t, err, &limitErr) - refreshed, err := db.GetChatByID(ctx, chat.ID) - require.NoError(t, err) - require.Equal(t, database.ChatStatusWaiting, refreshed.Status) - require.False(t, refreshed.CompactionRequestedAt.Valid) - }) - - t.Run("nothing to compact precedes usage limit", func(t *testing.T) { - t.Parallel() - - ctx, _, server, chat := setupAtLimitChat(t, database.ChatStatusWaiting, false) - _, err := server.CompactChat(ctx, chat) - - require.ErrorIs(t, err, chatd.ErrNothingToCompact) - var limitErr *chatd.UsageLimitExceededError - require.False(t, errors.As(err, &limitErr)) - }) - - t.Run("state conflict precedes usage limit", func(t *testing.T) { - t.Parallel() - - ctx, _, server, chat := setupAtLimitChat(t, database.ChatStatusRunning, true) - _, err := server.CompactChat(ctx, chat) - - require.ErrorIs(t, err, chatstate.ErrTransitionNotAllowed) - var limitErr *chatd.UsageLimitExceededError - require.False(t, errors.As(err, &limitErr)) - }) -} - func TestActiveServer_ManualCompaction(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 2db9ab5a1a49e..f4788b488e9de 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -1431,12 +1431,144 @@ func TestClassify_FallsBackToProviderMessageForDetail(t *testing.T) { " image exceeds 5 MB maximum ", 400, nil, - testProviderResponseDump("not-json"), )) require.Equal(t, "image exceeds 5 MB maximum", classified.Detail) } +func TestClassify_AnthropicPlainTextBudgetBody(t *testing.T) { + t.Parallel() + + // aibridge returns its budget error as a plain-text 403. The Anthropic + // adapter's Message is the SDK transport string without the body, so + // the budget text appears only in the dumped ResponseBody. Classify + // must still report a usage limit, not auth. + classified := chaterror.Classify(testProviderError( + `POST "https://api.example.com/v1/messages": 403 Forbidden`, + 403, + nil, + []byte("HTTP/1.1 403 Forbidden\r\n"+ + "Content-Type: text/plain; charset=utf-8\r\n"+ + "X-Content-Type-Options: nosniff\r\n"+ + "\r\n"+ + "AI budget of US$10.00 exceeded. Please contact an administrator for more details.\n"), + )) + + require.Equal(t, codersdk.ChatErrorKindUsageLimit, classified.Kind) + require.False(t, classified.Retryable) + require.Equal(t, + "AI budget of US$10.00 exceeded. Please contact an administrator for more details.", + classified.Detail) +} + +func TestClassify_ProviderResponseDumps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + message string + status int + dump []byte + wantDetail string + wantKind codersdk.ChatErrorKind + wantRetryable bool + }{ + { + // Proxies and load balancers return HTML error pages with a + // text/html Content-Type. The text/plain gate keeps them out + // of the user-facing detail, so detail falls back to the + // provider message. + name: "SkipsHTMLBody", + message: "upstream failed", + status: 502, + dump: testPlainDump("text/html", "<html><body>502 Bad Gateway</body></html>"), + wantDetail: "upstream failed", + wantKind: codersdk.ChatErrorKindTimeout, + wantRetryable: true, + }, + { + name: "SkipsWhitespaceOnlyBody", + message: "upstream failed", + status: 400, + dump: testPlainDump("text/plain", " \n\t\n"), + wantDetail: "upstream failed", + wantKind: codersdk.ChatErrorKindGeneric, + wantRetryable: false, + }, + { + name: "PlainTextBodyFirstLineOnly", + status: 400, + dump: testPlainDump("text/plain", "first line of the error\nsecond line\nthird line\n"), + wantDetail: "first line of the error", + wantKind: codersdk.ChatErrorKindGeneric, + wantRetryable: false, + }, + { + // Valid JSON without an extractable message must not leak raw + // JSON into the user-facing detail, even when served as + // text/plain. + name: "JSONBodyWithoutMessage", + message: "upstream failed", + status: 400, + dump: testPlainDump("text/plain", `{"type":"error"}`), + wantDetail: "upstream failed", + wantKind: codersdk.ChatErrorKindGeneric, + wantRetryable: false, + }, + { + // A plain-text body feeds the same pattern matching as a JSON + // message, so "quota" beats the 503 timeout signal. + name: "PlainTextQuotaBodyOn503", + status: 503, + dump: testPlainDump("text/plain", "quota exceeded for this key\n"), + wantDetail: "quota exceeded for this key", + wantKind: codersdk.ChatErrorKindUsageLimit, + wantRetryable: false, + }, + { + // A dump of a chunked response keeps the chunk framing. + // Parsing the dump as an HTTP response removes it, so the + // detail is the joined body, not a hex chunk-size line. + name: "DechunksPlainTextBody", + status: 403, + dump: []byte("HTTP/1.1 403 Forbidden\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "16\r\nAI budget of US$10.00 \r\n" + + "9\r\nexceeded.\r\n" + + "0\r\n\r\n"), + wantDetail: "AI budget of US$10.00 exceeded.", + wantKind: codersdk.ChatErrorKindUsageLimit, + wantRetryable: false, + }, + { + // Fantasy's Google adapter stores a raw message (not an HTTP + // dump) in ResponseBody. A blank line inside it is not a + // header/body separator: detail must fall back to the full + // trimmed Message, not the second paragraph. + name: "GoogleRawMessageWithBlankLine", + message: "google: model overloaded", + status: 500, + dump: []byte("model overloaded\n\nplease try again later"), + wantDetail: "google: model overloaded", + wantKind: codersdk.ChatErrorKindOverloaded, + wantRetryable: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + classified := chaterror.Classify(testProviderError(tt.message, tt.status, nil, tt.dump)) + require.Equal(t, tt.wantDetail, classified.Detail) + require.Equal(t, tt.wantKind, classified.Kind) + require.Equal(t, tt.wantRetryable, classified.Retryable) + }) + } +} + func TestClassify_UnwrapsTransportWrapperInMessageFallback(t *testing.T) { t.Parallel() @@ -1507,6 +1639,12 @@ func testProviderError( } } +func testPlainDump(contentType, body string) []byte { + return []byte("HTTP/1.1 400 Bad Request\r\n" + + "Content-Type: " + contentType + "\r\n" + + "\r\n" + body) +} + func testProviderResponseDump(body string) []byte { return []byte(`HTTP/1.1 400 Bad Request Content-Type: application/json diff --git a/coderd/x/chatd/chaterror/provider_error.go b/coderd/x/chatd/chaterror/provider_error.go index 0b3b440639446..e443519b6467a 100644 --- a/coderd/x/chatd/chaterror/provider_error.go +++ b/coderd/x/chatd/chaterror/provider_error.go @@ -1,9 +1,12 @@ package chaterror import ( + "bufio" "bytes" "encoding/json" "errors" + "io" + "mime" "net/http" "regexp" "strconv" @@ -53,13 +56,24 @@ func providerErrorDetail(providerErr *fantasy.ProviderError) string { // and headers. It understands both the top-level `{"message":...}` shape // used by many providers and the nested `{"error":{"message":...}}` // envelope. When the extracted message is itself an SDK-formatted transport -// error wrapper, the clean inner provider message is returned. +// error wrapper, the clean inner provider message is returned. For +// non-JSON text/plain bodies (e.g. aibridge's budget errors) it returns +// the trimmed first line of the body; the result surfaces in the +// user-facing detail, so other content types (proxy HTML, opaque bodies) +// and valid JSON without an extractable message yield nothing. func providerErrorResponseMessage(responseDump []byte) string { if len(responseDump) == 0 || len(responseDump) > 64*1024 { return "" } - body := providerErrorResponseBody(responseDump) - return unwrapTransportErrorMessage(jsonErrorMessage(body)) + body, textPlain := readResponseDump(responseDump) + if msg := unwrapTransportErrorMessage(jsonErrorMessage(body)); msg != "" { + return msg + } + if !textPlain || json.Valid(body) { + return "" + } + line, _, _ := strings.Cut(strings.TrimSpace(string(body)), "\n") + return strings.TrimSpace(line) } // unwrapTransportErrorMessage extracts the clean provider message from an @@ -115,14 +129,25 @@ func jsonErrorMessage(body []byte) string { return strings.TrimSpace(env.Message) } -func providerErrorResponseBody(responseDump []byte) []byte { - if _, body, ok := bytes.Cut(responseDump, []byte("\r\n\r\n")); ok { - return body +// readResponseDump parses a dumped HTTP response into its body, removing +// the status line, headers, and any chunk framing, and reports whether the +// response declares Content-Type text/plain (ignoring media-type +// parameters such as charset). Payloads that do not parse as an HTTP +// response, such as the raw message fantasy's Google adapter stores in +// ResponseBody, are returned whole. +func readResponseDump(responseDump []byte) (body []byte, textPlain bool) { + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(responseDump)), nil) + if err != nil { + return responseDump, false } - if _, body, ok := bytes.Cut(responseDump, []byte("\n\n")); ok { - return body + defer resp.Body.Close() + // The caller already bounds dumps at 64KB. + body, err = io.ReadAll(resp.Body) + if err != nil { + return responseDump, false } - return responseDump + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + return body, err == nil && mediaType == "text/plain" } func retryAfterFromHeaders(headers map[string]string) time.Duration { diff --git a/coderd/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index ad450d6a5429c..5edae77da573c 100644 --- a/coderd/x/chatd/chatprovider/chatprovider.go +++ b/coderd/x/chatd/chatprovider/chatprovider.go @@ -687,21 +687,6 @@ func orderProviders(providerSet map[string]struct{}) []string { return ordered } -// isGatewayProvider reports whether the provider routes requests to -// multiple upstream model providers using a "<provider>/<model>" model -// identifier, where the slash is part of the upstream model ID rather -// than a hint. -func isGatewayProvider(provider string) bool { - switch provider { - case fantasyvercel.Name, - fantasyopenrouter.Name, - fantasyopenaicompat.Name: - return true - default: - return false - } -} - // NormalizeProvider canonicalizes a provider name. func NormalizeProvider(provider string) string { switch strings.ToLower(strings.TrimSpace(provider)) { @@ -732,23 +717,16 @@ func ResolveModelWithProviderHint(modelName, providerHint string) (provider stri return "", "", xerrors.New("model is required") } - // Gateway providers (vercel, openrouter, openai-compat) treat the - // "<provider>/<model>" slash as part of the upstream model ID, so - // parseCanonicalModelRef would incorrectly strip the prefix and - // route to the embedded provider name instead. Honor an explicit - // gateway hint before attempting canonical-ref parsing. - if normalized := NormalizeProvider(providerHint); normalized != "" && isGatewayProvider(normalized) { - return normalized, modelName, nil + // A valid provider hint is authoritative, so preserve the model ID + // instead of interpreting its namespace as a different provider. + if provider := NormalizeProvider(providerHint); provider != "" { + return provider, modelName, nil } if provider, modelID, ok := parseCanonicalModelRef(modelName); ok { return provider, modelID, nil } - if provider := NormalizeProvider(providerHint); provider != "" { - return provider, modelName, nil - } - normalized := strings.ToLower(modelName) switch normalized { case "claude-opus-4-6": diff --git a/coderd/x/chatd/chatprovider/chatprovider_test.go b/coderd/x/chatd/chatprovider/chatprovider_test.go index b1b2a37a885f2..4245f139b01b9 100644 --- a/coderd/x/chatd/chatprovider/chatprovider_test.go +++ b/coderd/x/chatd/chatprovider/chatprovider_test.go @@ -1719,11 +1719,11 @@ func TestResolveModelWithProviderHint(t *testing.T) { wantModel: "anthropic/claude-opus-4.6", }, { - name: "OpenAIHintStripsCanonicalPrefix", + name: "OpenAIHintPreservesCanonicalPrefix", modelName: "anthropic/claude-opus-4.6", providerHint: fantasyopenai.Name, - wantProvider: fantasyanthropic.Name, - wantModel: "claude-opus-4.6", + wantProvider: fantasyopenai.Name, + wantModel: "anthropic/claude-opus-4.6", }, { name: "OpenAIHintPreservesUnknownSlashNamespace", @@ -1733,11 +1733,39 @@ func TestResolveModelWithProviderHint(t *testing.T) { wantModel: "meta-llama/llama-3-70b", }, { - name: "AnthropicHintStripsCanonicalPrefix", + name: "AnthropicHintPreservesCanonicalPrefix", modelName: "anthropic/claude-4-5-sonnet", providerHint: fantasyanthropic.Name, wantProvider: fantasyanthropic.Name, - wantModel: "claude-4-5-sonnet", + wantModel: "anthropic/claude-4-5-sonnet", + }, + { + name: "AnthropicHintPreservesBedrockNamespace", + modelName: "bedrock/claude-opus-4-8", + providerHint: fantasyanthropic.Name, + wantProvider: fantasyanthropic.Name, + wantModel: "bedrock/claude-opus-4-8", + }, + { + name: "AnthropicHintPreservesDoubleBedrockNamespace", + modelName: "bedrock/bedrock/claude-opus-4-8", + providerHint: fantasyanthropic.Name, + wantProvider: fantasyanthropic.Name, + wantModel: "bedrock/bedrock/claude-opus-4-8", + }, + { + name: "AnthropicHintPreservesColonCanonicalRef", + modelName: "openai:gpt-5.2", + providerHint: fantasyanthropic.Name, + wantProvider: fantasyanthropic.Name, + wantModel: "openai:gpt-5.2", + }, + { + name: "BedrockHintWithBareModel", + modelName: "claude-opus-4-6", + providerHint: fantasybedrock.Name, + wantProvider: fantasybedrock.Name, + wantModel: "claude-opus-4-6", }, { name: "NoHintUsesCanonicalRef", diff --git a/coderd/x/chatd/model_routing_aibridge.go b/coderd/x/chatd/model_routing_aibridge.go index 2bd724869ae99..a156481a00225 100644 --- a/coderd/x/chatd/model_routing_aibridge.go +++ b/coderd/x/chatd/model_routing_aibridge.go @@ -85,9 +85,8 @@ func (t *aiGatewayRoundTripper) RoundTrip(req *http.Request) (*http.Response, er return t.base.RoundTrip(cloned) } -// ValidateAIGatewayProviderModel rejects slash-namespaced models on -// OpenRouter-like providers typed as openai, where the provider type -// strips the vendor prefix. +// ValidateAIGatewayProviderModel rejects slash-namespaced models when an +// OpenRouter-like gateway is configured with the OpenAI provider type. func ValidateAIGatewayProviderModel(provider database.AIProvider, model string) error { if provider.Type != database.AIProviderTypeOpenai { return nil diff --git a/coderd/x/chatd/stream_sync_poller.go b/coderd/x/chatd/stream_sync_poller.go index 11e9171687e64..2a87a9af12166 100644 --- a/coderd/x/chatd/stream_sync_poller.go +++ b/coderd/x/chatd/stream_sync_poller.go @@ -101,7 +101,6 @@ func (p *streamSyncPoller) unregister(subscriber *streamSyncPollerSubscriber) { if len(chatSubscribers) == 0 { delete(p.subscribers, subscriber.chatID) } - close(subscriber.hints) } func (p *streamSyncPoller) loop() { diff --git a/coderd/x/chatd/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index 9ed08a47e5657..204639bc2a032 100644 --- a/coderd/x/chatd/title_override_internal_test.go +++ b/coderd/x/chatd/title_override_internal_test.go @@ -586,7 +586,6 @@ func TestGenerateManualTitleCandidate_UsesSyntheticAPIKey(t *testing.T) { }, nil })} - db.EXPECT().GetChatUsageLimitConfig(gomock.Any()).Return(database.ChatUsageLimitConfig{}, sql.ErrNoRows) db.EXPECT().GetChatMessagesByChatIDAscPaginated(gomock.Any(), database.GetChatMessagesByChatIDAscPaginatedParams{ ChatID: chat.ID, AfterID: 0, diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index ff3fcff369bb6..04b54744ca489 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -15,6 +15,10 @@ import ( "github.com/coder/coder/v2/coderd/util/slice" ) +// MaxAISpendLimitMicros is the highest AI spend limit that can be configured, +// $1,000,000 per member per budget period. +const MaxAISpendLimitMicros int64 = 1_000_000_000_000 + // AIBudgetLimitSource identifies which tier produced the user's // effective budget limit. type AIBudgetLimitSource string @@ -28,28 +32,26 @@ const ( AIBudgetLimitSourceGroup AIBudgetLimitSource = "group" ) -// AIGroupBudget is an AI spend limit and the tier that produced it. Both +// AIBudgetLimit is an AI spend limit and the tier that produced it. Both // fields are always populated together. -type AIGroupBudget struct { +type AIBudgetLimit struct { SpendLimitMicros int64 `json:"spend_limit_micros"` LimitSource AIBudgetLimitSource `json:"limit_source"` } // UserAIBudgetSummary is the effective AI budget for a user. When no budget // applies, the effective group falls back to the Everyone group with a null -// limit and source. +// budget. type UserAIBudgetSummary struct { UserID uuid.UUID `json:"user_id" format:"uuid"` // EffectiveGroupID is the group the spend is attributed to, falling back to // the Everyone group when no budget applies. Null only when the user has no // organization membership. EffectiveGroupID *uuid.UUID `json:"effective_group_id" format:"uuid"` - // SpendLimitMicros is the effective spend limit in micro-units. - // Null when no budget applies to the user (unlimited). - SpendLimitMicros *int64 `json:"spend_limit_micros"` - // LimitSource identifies which tier produced the limit. Null when no - // budget applies. - LimitSource *AIBudgetLimitSource `json:"limit_source"` + // EffectiveBudget is the spend limit that applies to the user, whether it + // came from a group budget or a user override. Null when no budget + // applies, leaving the user's spend unlimited. + EffectiveBudget *AIBudgetLimit `json:"effective_budget"` } // AISpendPeriodWindow is the [Start, End) window over which AI spend is @@ -84,14 +86,25 @@ type OrganizationGroupsAISpend struct { // within the active budget period. type OrganizationGroupAISpend struct { GroupID uuid.UUID `json:"group_id" format:"uuid"` - // SpendLimitMicros is the group's configured AI spend limit. Null when - // the group has no configured budget. + // SpendLimitMicros is the group's configured AI spend budget per member. + // Null when the group has no configured budget. SpendLimitMicros *int64 `json:"spend_limit_micros"` - // CurrentSpendMicros is the group's spend over the current budget - // period. + // TotalSpendLimitMicros is the currently configured combined budget of the + // members attributed to this group, with each member's override replacing + // their share. Null when the group has no budget, and zero when no members + // are attributed to it. + TotalSpendLimitMicros *int64 `json:"total_spend_limit_micros"` + // CurrentSpendMicros is the group's spend over the current budget period. CurrentSpendMicros int64 `json:"current_spend_micros"` } +// GroupAISpend is the current AI spend snapshot for a single group within +// the active budget period. +type GroupAISpend struct { + AISpendPeriodWindow + OrganizationGroupAISpend +} + // GroupMembersAISpend reports per-member AI spend attributed to a specific // group in the active budget period. type GroupMembersAISpend struct { @@ -111,7 +124,7 @@ type GroupMemberAISpend struct { // GroupBudget is the budget when the queried group is this user's // effective budget source. Null when the user's budget resolves to another // group or no budget applies to the user. - GroupBudget *AIGroupBudget `json:"group_budget"` + GroupBudget *AIBudgetLimit `json:"group_budget"` // GroupSpendMicros is the user's spend attributed to the queried group // over the current budget period. GroupSpendMicros int64 `json:"group_spend_micros"` @@ -128,7 +141,7 @@ type AIBridgeSession struct { EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"` Threads int64 `json:"threads"` TokenUsageSummary AIBridgeSessionTokenUsageSummary `json:"token_usage_summary"` - // NetworkCalls summarizes the Agent Firewall network calls made during the + // NetworkCalls summarizes the Agent Firewall network requests made during the // session. A nil value means the session did not pass through Agent // Firewall, so network call monitoring was not active, which the UI // surfaces as "Disabled". @@ -152,6 +165,13 @@ type AIBridgeSessionNetworkCallSummary struct { Blocked int64 `json:"blocked"` } +// AIBridgeSessionNetworkDomain is one destination host contacted during a +// session, with the number of network calls made to it. +type AIBridgeSessionNetworkDomain struct { + Domain string `json:"domain"` + Count int64 `json:"count"` +} + type AIBridgeListSessionsResponse struct { Count int64 `json:"count"` Sessions []AIBridgeSession `json:"sessions"` @@ -172,7 +192,23 @@ type AIBridgeSessionThreadsResponse struct { StartedAt time.Time `json:"started_at" format:"date-time"` EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"` TokenUsageSummary AIBridgeSessionThreadsTokenUsage `json:"token_usage_summary"` - Threads []AIBridgeThread `json:"threads"` + // NetworkCalls summarizes the Agent Firewall network calls made during the + // session. A nil value means the session did not pass through Agent + // Firewall, so network call monitoring was not active, which the UI + // surfaces as "Disabled". + NetworkCalls *AIBridgeSessionNetworkCallSummary `json:"network_calls,omitempty"` + // NetworkTopDomains lists the most contacted destination hosts, ordered by + // call count descending. NetworkDomainCount is the total number of distinct + // domains, used to render a "+N more" overflow beyond the listed domains. + NetworkTopDomains []AIBridgeSessionNetworkDomain `json:"network_top_domains,omitempty"` + NetworkDomainCount int64 `json:"network_domain_count,omitempty"` + // NetworkCallLogs is the chronological list of individual network calls made + // during the session, holding the earliest calls up to a server-side cap. + // NetworkCalls remains authoritative for whole-session totals, so a shorter + // list than NetworkCalls.Total means the list was truncated. Empty when the + // session did not pass through Agent Firewall. + NetworkCallLogs []AgentFirewallLog `json:"network_call_logs,omitempty"` + Threads []AIBridgeThread `json:"threads"` } // AIBridgeSessionThreadsTokenUsage represents aggregated token usage @@ -407,6 +443,7 @@ type GroupAIBudget struct { } type UpsertGroupAIBudgetRequest struct { + // SpendLimitMicros must not exceed MaxAISpendLimitMicros. SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"` } @@ -474,14 +511,15 @@ type UserAIBudgetOverride struct { type UpsertUserAIBudgetOverrideRequest struct { // GroupID is the group the user's spend is attributed to. The user must // be a member of this group. - GroupID uuid.UUID `json:"group_id" format:"uuid" validate:"required"` - SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"` + GroupID uuid.UUID `json:"group_id" format:"uuid" validate:"required"` + // SpendLimitMicros must not exceed MaxAISpendLimitMicros. + SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"` } // UserAIBudgetOverride returns the AI spend budget override configured for the given user. func (c *Client) UserAIBudgetOverride(ctx context.Context, user uuid.UUID) (UserAIBudgetOverride, error) { res, err := c.Request(ctx, http.MethodGet, - fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + fmt.Sprintf("/api/v2/users/%s/ai/budget/override", user.String()), nil, ) if err != nil { @@ -499,7 +537,7 @@ func (c *Client) UserAIBudgetOverride(ctx context.Context, user uuid.UUID) (User // UpsertUserAIBudgetOverride creates or updates the AI spend budget override for the given user. func (c *Client) UpsertUserAIBudgetOverride(ctx context.Context, user uuid.UUID, req UpsertUserAIBudgetOverrideRequest) (UserAIBudgetOverride, error) { res, err := c.Request(ctx, http.MethodPut, - fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + fmt.Sprintf("/api/v2/users/%s/ai/budget/override", user.String()), req, ) if err != nil { @@ -517,7 +555,7 @@ func (c *Client) UpsertUserAIBudgetOverride(ctx context.Context, user uuid.UUID, // DeleteUserAIBudgetOverride removes the AI spend budget override for the given user. func (c *Client) DeleteUserAIBudgetOverride(ctx context.Context, user uuid.UUID) error { res, err := c.Request(ctx, http.MethodDelete, - fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + fmt.Sprintf("/api/v2/users/%s/ai/budget/override", user.String()), nil, ) if err != nil { @@ -577,6 +615,25 @@ func (c *Client) OrganizationGroupsAISpend(ctx context.Context, organization uui return resp, json.NewDecoder(res.Body).Decode(&resp) } +// GroupAISpend returns AI spend for the given group within the active budget +// period. +func (c *Client) GroupAISpend(ctx context.Context, group uuid.UUID) (GroupAISpend, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/groups/%s/ai/spend", group.String()), + nil, + ) + if err != nil { + return GroupAISpend{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return GroupAISpend{}, ReadBodyAsError(res) + } + var resp GroupAISpend + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // GroupMembersAISpend returns AI spend attributed to the given group for the // specified users within the active budget period. At most 100 user IDs may be // requested per call, and callers with more members are expected to batch diff --git a/codersdk/deployment.go b/codersdk/deployment.go index f5430fc2faee4..514fa20db953a 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -695,6 +695,7 @@ type DeploymentValues struct { DisableOwnerWorkspaceExec serpent.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` DisableWorkspaceSharing serpent.Bool `json:"disable_workspace_sharing,omitempty" typescript:",notnull"` DisableChatSharing serpent.Bool `json:"disable_chat_sharing,omitempty" typescript:",notnull"` + DisableWorkspaceAgentContextSync serpent.Bool `json:"disable_workspace_agent_context_sync,omitempty" typescript:",notnull"` ProxyHealthStatusInterval serpent.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` EnableTerraformDebugMode serpent.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` @@ -3739,6 +3740,15 @@ communicating directly.`, Value: &c.DisableChatSharing, YAML: "disableChatSharing", }, + { + Name: "Disable Workspace Agent Context Sync", + Description: "Stop persisting workspace agent context snapshots (instructions, skills, and MCP state used for pinned chat context). When set, coderd rejects agent context pushes as unimplemented and agents stop sending them; chats cannot pin workspace context. Use this to shed the database write load of context sync on large deployments.", + Flag: "disable-workspace-agent-context-sync", + Env: "CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC", + + Value: &c.DisableWorkspaceAgentContextSync, + YAML: "disableWorkspaceAgentContextSync", + }, { Name: "Session Duration", Description: "The token expiry duration for browser sessions. Sessions may last longer if they are actively making requests, but this functionality can be disabled via --disable-session-expiry-refresh.", @@ -5261,7 +5271,6 @@ const ( ExperimentMinimumImplicitMember Experiment = "minimum-implicit-member" // Allows organizations to deviate from the default organization-member roles, in support of Gateway Accounts. ExperimentWorkspaceCapableLicensing Experiment = "workspace-capable-licensing" // Counts only users holding the workspace-create permission toward the license seat limit. ExperimentAIGatewaySeatExclusion Experiment = "ai-gateway-seat-exclusion" // Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption. - ExperimentAIGatewayCostControl Experiment = "ai-gateway-cost-control" // Enables AI Gateway cost control functionality. ExperimentChatAdvisor Experiment = "chat-advisor" // Enables the advisor tool for root agent chats. ExperimentChatVirtualDesktop Experiment = "chat-virtual-desktop" // Enables virtual desktop and computer use provider for agents. ) @@ -5290,8 +5299,6 @@ func (e Experiment) DisplayName() string { return "Workspace-Capable Licensing" case ExperimentAIGatewaySeatExclusion: return "AI Gateway Seat Exclusion" - case ExperimentAIGatewayCostControl: - return "AI Gateway Cost Control" case ExperimentChatAdvisor: return "Chat Advisor" case ExperimentChatVirtualDesktop: @@ -5317,7 +5324,6 @@ var ExperimentsKnown = Experiments{ ExperimentMinimumImplicitMember, ExperimentWorkspaceCapableLicensing, ExperimentAIGatewaySeatExclusion, - ExperimentAIGatewayCostControl, ExperimentChatAdvisor, ExperimentChatVirtualDesktop, } diff --git a/codersdk/drpcsdk/server.go b/codersdk/drpcsdk/server.go new file mode 100644 index 0000000000000..6fc9a505d932f --- /dev/null +++ b/codersdk/drpcsdk/server.go @@ -0,0 +1,39 @@ +package drpcsdk + +import ( + "runtime/debug" + + "storj.io/drpc" + "storj.io/drpc/drpcserver" + + "cdr.dev/slog/v3" +) + +// NewServer constructs a dRPC server that recovers panics from RPC handlers. +func NewServer(logger slog.Logger, handler drpc.Handler, options drpcserver.Options) *drpcserver.Server { + return drpcserver.NewWithOptions(&recoverHandler{ + logger: logger, + handler: handler, + }, options) +} + +type recoverHandler struct { + logger slog.Logger + handler drpc.Handler +} + +func (h *recoverHandler) HandleRPC(stream drpc.Stream, rpc string) (err error) { + defer func() { + if r := recover(); r != nil { + h.logger.Error(stream.Context(), + "panic serving dRPC request (recovered)", + slog.F("rpc", rpc), + slog.F("panic", r), + slog.F("stack", string(debug.Stack())), + ) + err = drpc.InternalError.New("panic serving dRPC request") + } + }() + + return h.handler.HandleRPC(stream, rpc) +} diff --git a/codersdk/drpcsdk/server_internal_test.go b/codersdk/drpcsdk/server_internal_test.go new file mode 100644 index 0000000000000..66298af68f537 --- /dev/null +++ b/codersdk/drpcsdk/server_internal_test.go @@ -0,0 +1,85 @@ +package drpcsdk + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "storj.io/drpc" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/testutil" +) + +func TestRecoverHandler(t *testing.T) { + t.Parallel() + + t.Run("Panic", func(t *testing.T) { + t.Parallel() + + const panicValue = "sensitive panic details" + sink := testutil.NewFakeSink(t) + handler := &recoverHandler{ + logger: sink.Logger(), + handler: handlerFunc(func(drpc.Stream, string) error { + panic(panicValue) + }), + } + + err := handler.HandleRPC(contextStream{ctx: t.Context()}, "/test.Service/Panic") + require.Error(t, err) + require.True(t, drpc.InternalError.Has(err)) + require.NotContains(t, err.Error(), panicValue) + + entries := sink.Entries() + require.Len(t, entries, 1) + require.Equal(t, slog.LevelError, entries[0].Level) + require.Equal(t, "panic serving dRPC request (recovered)", entries[0].Message) + require.Equal(t, "/test.Service/Panic", fieldValue(entries[0].Fields, "rpc")) + require.Equal(t, panicValue, fieldValue(entries[0].Fields, "panic")) + stackValue := fieldValue(entries[0].Fields, "stack") + stack, ok := stackValue.(string) + require.True(t, ok, "stack field must be a string, got %T", stackValue) + require.Contains(t, stack, "goroutine ") + }) + + t.Run("Error", func(t *testing.T) { + t.Parallel() + + expected := xerrors.New("handler error") + handler := &recoverHandler{ + handler: handlerFunc(func(drpc.Stream, string) error { + return expected + }), + } + + err := handler.HandleRPC(contextStream{ctx: t.Context()}, "/test.Service/Error") + require.ErrorIs(t, err, expected) + }) +} + +type handlerFunc func(drpc.Stream, string) error + +func (f handlerFunc) HandleRPC(stream drpc.Stream, rpc string) error { + return f(stream, rpc) +} + +type contextStream struct { + ctx context.Context +} + +func (s contextStream) Context() context.Context { return s.ctx } +func (contextStream) MsgSend(drpc.Message, drpc.Encoding) error { return nil } +func (contextStream) MsgRecv(drpc.Message, drpc.Encoding) error { return nil } +func (contextStream) CloseSend() error { return nil } +func (contextStream) Close() error { return nil } + +func fieldValue(fields slog.Map, name string) any { + for _, field := range fields { + if field.Name == name { + return field.Value + } + } + return nil +} diff --git a/codersdk/drpcsdk/server_test.go b/codersdk/drpcsdk/server_test.go new file mode 100644 index 0000000000000..e906daa82e0fd --- /dev/null +++ b/codersdk/drpcsdk/server_test.go @@ -0,0 +1,94 @@ +package drpcsdk_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + "storj.io/drpc" + "storj.io/drpc/drpcserver" + + "github.com/coder/coder/v2/codersdk/drpcsdk" + "github.com/coder/coder/v2/testutil" +) + +func TestNewServerRecoversPanics(t *testing.T) { + t.Parallel() + + const ( + panicRPC = "/test.Service/Panic" + echoRPC = "/test.Service/Echo" + panicValue = "sensitive panic details" + ) + + ctx := testutil.Context(t, testutil.WaitShort) + serverCtx, cancel := context.WithCancel(ctx) + defer cancel() + + client, listener := drpcsdk.MemTransportPipe() + defer func() { + _ = client.Close() + _ = listener.Close() + }() + + handler := testHandlerFunc(func(stream drpc.Stream, rpc string) error { + switch rpc { + case panicRPC: + panic(panicValue) + case echoRPC: + var message string + if err := stream.MsgRecv(&message, stringEncoding{}); err != nil { + return err + } + return stream.MsgSend(&message, stringEncoding{}) + default: + return xerrors.Errorf("unexpected RPC %q", rpc) + } + }) + server := drpcsdk.NewServer(testutil.NewFakeSink(t).Logger(), handler, drpcserver.Options{ + Manager: drpcsdk.DefaultDRPCOptions(nil), + }) + serverDone := make(chan error, 1) + go func() { + serverDone <- server.Serve(serverCtx, listener) + }() + + request, response := "request", "" + err := client.Invoke(ctx, panicRPC, stringEncoding{}, &request, &response) + require.EqualError(t, err, "internal error: panic serving dRPC request") + require.NotContains(t, err.Error(), panicValue) + + request, response = "healthy", "" + err = client.Invoke(ctx, echoRPC, stringEncoding{}, &request, &response) + require.NoError(t, err) + require.Equal(t, request, response) + + cancel() + require.NoError(t, testutil.RequireReceive(ctx, t, serverDone)) +} + +type testHandlerFunc func(drpc.Stream, string) error + +func (f testHandlerFunc) HandleRPC(stream drpc.Stream, rpc string) error { + return f(stream, rpc) +} + +type stringEncoding struct{} + +func (stringEncoding) Marshal(message drpc.Message) ([]byte, error) { + value, ok := message.(*string) + if !ok { + return nil, xerrors.Errorf("marshal %T: expected *string", message) + } + return []byte(*value), nil +} + +func (stringEncoding) Unmarshal(data []byte, message drpc.Message) error { + value, ok := message.(*string) + if !ok { + return xerrors.Errorf("unmarshal %T: expected *string", message) + } + *value = string(data) + return nil +} diff --git a/codersdk/usersecrets.go b/codersdk/usersecrets.go index 7d59d9b6d86f7..1f36100c4d7c8 100644 --- a/codersdk/usersecrets.go +++ b/codersdk/usersecrets.go @@ -18,30 +18,42 @@ type UserSecret struct { Description string `json:"description"` EnvName string `json:"env_name"` FilePath string `json:"file_path"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` + // Enabled controls whether the secret is injected into workspaces. + // Disabled secrets remain visible and editable, but are not added + // to the agent manifest, so they are not exposed as environment + // variables or written to secret files. + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` } // CreateUserSecretRequest is the payload for creating a new user -// secret. Name and Value are required. All other fields are optional -// and default to empty string. +// secret. Name and Value are required. An enabled secret must have at +// least one of EnvName or FilePath non-empty so it has an injection +// target; to keep a secret without injecting it, set Enabled to false. +// All other fields are optional and default to empty string. Enabled +// defaults to true when omitted. type CreateUserSecretRequest struct { Name string `json:"name"` Value string `json:"value"` Description string `json:"description,omitempty"` EnvName string `json:"env_name,omitempty"` FilePath string `json:"file_path,omitempty"` + Enabled *bool `json:"enabled,omitempty"` } // UpdateUserSecretRequest is the payload for partially updating a // user secret. At least one field must be non-nil. Pointer fields // distinguish "not sent" (nil) from "set to empty string" (pointer -// to empty string). +// to empty string). If the post-update row is enabled it must still +// have at least one of EnvName or FilePath non-empty; clearing both +// targets is only allowed when the secret is (or becomes) disabled. type UpdateUserSecretRequest struct { Value *string `json:"value,omitempty"` Description *string `json:"description,omitempty"` EnvName *string `json:"env_name,omitempty"` FilePath *string `json:"file_path,omitempty"` + Enabled *bool `json:"enabled,omitempty"` } func (c *Client) CreateUserSecret(ctx context.Context, user string, req CreateUserSecretRequest) (UserSecret, error) { diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index b7c020ad463b6..f26a725e25f10 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -87,6 +87,14 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec // so multiple empty env_names are allowed. if UserSecretEnvNameValid(e.key) == nil { req.EnvName = e.key + } else { + // Keys that cannot be env-injected (reserved names, invalid + // identifiers) are imported without an injection target, so + // they must be disabled: an enabled secret always has at + // least one of env_name or file_path set. The user can add + // a target and re-enable the secret afterwards. + disabled := false + req.Enabled = &disabled } reqs = append(reqs, req) } diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 7faa8e0606b60..86e616d4f443c 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -406,9 +406,13 @@ func TestParseSecretsFileBestEffortEnvName(t *testing.T) { t.Parallel() reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) require.NoError(t, err) + // Reserved keys cannot be env-injected, so they are imported + // without an injection target and therefore disabled. + disabled := false require.Equal(t, []codersdk.CreateUserSecretRequest{{ - Name: "PATH", - Value: "value", + Name: "PATH", + Value: "value", + Enabled: &disabled, }}, reqs) }) } diff --git a/codersdk/usersecretvalidation.go b/codersdk/usersecretvalidation.go index 5892d1c35c758..6fdc73058bb38 100644 --- a/codersdk/usersecretvalidation.go +++ b/codersdk/usersecretvalidation.go @@ -236,9 +236,27 @@ func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationEr if err := UserSecretFilePathValid(req.FilePath); err != nil { validations = append(validations, ValidationError{Field: UserSecretFilePathField, Detail: err.Error()}) } + // An enabled secret must have an injection target. The agent + // manifest layer relies on this invariant so it can gate injection + // solely on the enabled flag; "stored but not injected" is + // expressed by enabled=false, not by clearing both targets. + // Disabled secrets may have no target (e.g. bulk imports of keys + // that cannot be env-injected). + if req.EnvName == "" && req.FilePath == "" && (req.Enabled == nil || *req.Enabled) { + validations = append(validations, ValidationError{ + Field: UserSecretEnvNameField, + Detail: UserSecretInjectionTargetRequiredDetail, + }) + } return validations } +// UserSecretInjectionTargetRequiredDetail explains the injection-target +// invariant. It is shared by the create validator above and the PATCH +// handler's post-state check in coderd. The value is a user-facing +// validation message, not a credential. +const UserSecretInjectionTargetRequiredDetail = "An enabled secret must have at least one of env_name or file_path set. To keep a secret without injecting it, set enabled to false instead of clearing both targets." //nolint:gosec // G101: message text, not a hardcoded credential. + // UserSecretNameValid validates a user secret name. Names are used in // API route path segments, so they must not include route separators. func UserSecretNameValid(s string) error { diff --git a/codersdk/usersecretvalidation_test.go b/codersdk/usersecretvalidation_test.go index 55d7d001b7fc4..28584ac1993db 100644 --- a/codersdk/usersecretvalidation_test.go +++ b/codersdk/usersecretvalidation_test.go @@ -29,13 +29,25 @@ func TestValidateCreateUserSecretRequest(t *testing.T) { { name: "MissingValue", req: codersdk.CreateUserSecretRequest{ - Name: "missing-value-secret", + Name: "missing-value-secret", + EnvName: "MISSING_VALUE_SECRET", }, want: []codersdk.ValidationError{{ Field: "value", Detail: "Value is required.", }}, }, + { + name: "MissingInjectionTarget", + req: codersdk.CreateUserSecretRequest{ + Name: "missing-target-secret", + Value: "value", + }, + want: []codersdk.ValidationError{{ + Field: "env_name", + Detail: codersdk.UserSecretInjectionTargetRequiredDetail, + }}, + }, { name: "MultiInvalid", req: codersdk.CreateUserSecretRequest{ diff --git a/docs/about/contributing/modules.md b/docs/about/contributing/modules.md index 4c91a04da126b..d04616d02ed13 100644 --- a/docs/about/contributing/modules.md +++ b/docs/about/contributing/modules.md @@ -342,7 +342,7 @@ Use the version bump script to update versions: ```sh git add . - git commit -m "feat(git-clone):add git-clone module" + git commit -m "feat(git-clone): add git-clone module" ``` 4. **Open a pull request**: diff --git a/docs/admin/infrastructure/architecture.md b/docs/admin/infrastructure/architecture.md index 4d3c85dc21eb8..79b709a6e91cd 100644 --- a/docs/admin/infrastructure/architecture.md +++ b/docs/admin/infrastructure/architecture.md @@ -138,7 +138,15 @@ as OpenAI and Anthropic. Users authenticate through Coder instead of managing se provider API keys. All prompts, token usage, and tool invocations are recorded for compliance and cost tracking. -Learn more: [AI Gateway](../../ai-coder/ai-gateway/index.md) +AI Gateway supports 2 deployment topologies: + +- **Embedded:** `coderd` runs the AI Gateway data plane in the same process. +- **Standalone:** AI Gateway runs outside `coderd`, as replicas that serve AI traffic and send requests directly to upstream providers. + +Standalone replicas hold no durable state. `coderd` is the source of truth and the only component that writes AI Gateway state to the database. +Each replica maintains a control connection to `coderd` for Coder API key validation, provider configuration, and AI session recording, and becomes unready when that connection is unavailable. + +Refer to [AI Gateway](../../ai-coder/ai-gateway/index.md) and [standalone deployment](../../ai-coder/ai-gateway/standalone.md) for configuration and operational guidance. ### Agent Firewall diff --git a/docs/admin/infrastructure/scale-utility.md b/docs/admin/infrastructure/scale-utility.md index 5da9b0f0b1384..075b199f1ab39 100644 --- a/docs/admin/infrastructure/scale-utility.md +++ b/docs/admin/infrastructure/scale-utility.md @@ -130,7 +130,7 @@ wish to clean up all workspaces, you can run the following command: ```sh coder exp scaletest cleanup \ --cleanup-job-timeout 2h \ - --cleanup-timeout 15min + --cleanup-timeout 15m ``` This will delete all workspaces and users with the prefix `scaletest-`. diff --git a/docs/admin/infrastructure/validated-architectures/index.md b/docs/admin/infrastructure/validated-architectures/index.md index 9fa4968bf856f..8c037a643376d 100644 --- a/docs/admin/infrastructure/validated-architectures/index.md +++ b/docs/admin/infrastructure/validated-architectures/index.md @@ -123,6 +123,16 @@ or helper scripts. Please note that the Registry is a hosted service and isn't available for offline use. +### AI Gateway + +[AI Gateway](../../../ai-coder/ai-gateway/index.md) proxies AI provider traffic +and records each AI session. It runs inside `coderd` by default, and can also +run as a [standalone deployment](../../../ai-coder/ai-gateway/standalone.md) +that scales independently of the control plane. Size replicas from your own AI +request volume and `CODER_AI_GATEWAY_MAX_CONCURRENCY`. For the chart's resource +requests and autoscaling defaults, refer to the +[AI Gateway Helm chart README](https://github.com/coder/coder/blob/main/helm/ai-gateway/README.md). + ## Kubernetes Infrastructure Kubernetes is the recommended, and supported platform for deploying Coder in the diff --git a/docs/admin/integrations/dx-data-cloud.md b/docs/admin/integrations/dx-data-cloud.md index ee4f486a6ca41..0f602ea556822 100644 --- a/docs/admin/integrations/dx-data-cloud.md +++ b/docs/admin/integrations/dx-data-cloud.md @@ -30,10 +30,11 @@ If your organization already uses the Coder-DX integration, you can find a list ### CLI -Use `users list` to export the list of users to a CSV file: +Use `users list` with `jq` to export the list of users to a CSV file: ```sh -coder users list > users.csv +coder users list --output json | \ + jq -r '["username","email","created_at","status"], (.[] | [.username, .email, .created_at, .status]) | @csv' > users.csv ``` Visit the [users list](../../reference/cli/users_list.md) documentation for more options. diff --git a/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index 26c16449caa95..b67cfb7e5adc0 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -72,6 +72,13 @@ scrape_configs: apps: "coder" ``` +If you run a [standalone AI Gateway](../../ai-coder/ai-gateway/standalone.md), +each replica exports its own metrics on its own listener. Its Helm chart uses the +same `0.0.0.0:2112` default as the `coder` chart, but sets up no scrape +discovery. Refer to +[AI Gateway monitoring](../../ai-coder/ai-gateway/monitoring.md#kubernetes-discovery) +for more details. + To use the Kubernetes Prometheus operator to scrape metrics, you will need to create a `ServiceMonitor` in your Coder deployment namespace. The following is an example `ServiceMonitor`. @@ -102,6 +109,8 @@ You must first enable `coderd_agentstats_*` with the flag `CODER_PROMETHEUS_COLLECT_AGENT_STATS` before they can be retrieved from the deployment. They will always be available from the agent. +The `coder_ai_gateway_cost_control_*` metrics are exported only by `coderd`. + <!-- Code generated by 'make docs/admin/integrations/prometheus.md'. DO NOT EDIT --> | Name | Type | Description | Labels | @@ -116,7 +125,7 @@ deployment. They will always be available from the agent. | `coder_ai_gateway_cost_control_blocked_requests_total` | counter | The number of AI requests blocked because the initiator's budget was exceeded. | `group_id` | | `coder_ai_gateway_cost_control_blocked_users` | gauge | The number of users currently over their AI budget. | `group_id` | | `coder_ai_gateway_cost_control_enforcement_duration_seconds` | histogram | The duration of AI budget enforcement checks, in seconds (outcome: allowed, blocked, error). | `outcome` | -| `coder_ai_gateway_cost_control_unpriced_token_usage_records_total` | counter | The number of recorded AI token-usage records for which no model price was found (provider: anthropic, openai, copilot). | `model` `provider` | +| `coder_ai_gateway_cost_control_unpriced_token_usage_records_total` | counter | The number of recorded AI token-usage records for which no (provider_type, model) price was found. provider is the provider instance name, and provider_type is its configured type. | `model` `provider_type` `provider` | | `coder_ai_gateway_injected_tool_invocations_total` | counter | The number of times an injected MCP tool was invoked by AI Gateway. | `model` `name` `provider` `server` | | `coder_ai_gateway_interceptions_duration_seconds` | histogram | The total duration of intercepted requests, in seconds. The majority of this time will be the upstream processing of the request. AI Gateway has no control over upstream processing time, so it's just an illustrative metric. | `model` `provider` | | `coder_ai_gateway_interceptions_inflight` | gauge | The number of intercepted requests which are being processed. | `model` `provider` `route` | @@ -124,7 +133,7 @@ deployment. They will always be available from the agent. | `coder_ai_gateway_key_pool_exhaustions_total` | counter | The number of times the key pool was exhausted with no usable key (outcome: rate_limited, auth_failed). | `outcome` `provider` | | `coder_ai_gateway_key_pool_failover_attempts` | histogram | The number of keys attempted before success or exhaustion, per interception for bridged requests and per request for passthrough requests. | `provider` | | `coder_ai_gateway_key_pool_state` | gauge | The number of keys currently in each state (state: valid, temporary, permanent). | `provider` `state` | -| `coder_ai_gateway_key_pool_state_transitions_total` | counter | The number of API key state transitions during failover (reason: rate_limited, unauthorized, forbidden). | `provider` `reason` | +| `coder_ai_gateway_key_pool_state_transitions_total` | counter | The number of API key state transitions during failover (reason: rate_limited, unauthorized). | `provider` `reason` | | `coder_ai_gateway_non_injected_tool_selections_total` | counter | The number of times an AI model selected a tool to be invoked by the client. | `model` `name` `provider` | | `coder_ai_gateway_passthrough_total` | counter | The count of requests which were not intercepted but passed through to the upstream. | `method` `provider` `route` | | `coder_ai_gateway_prompts_total` | counter | The number of prompts issued by users (initiators). | `client` `initiator_id` `model` `provider` | @@ -349,7 +358,7 @@ The following metrics support native histograms: * `coderd_workspace_creation_duration_seconds` * `coderd_prebuilt_workspace_claim_duration_seconds` -* `coderd_template_coderd_template_workspace_build_duration_seconds` +* `coderd_template_workspace_build_duration_seconds` Native histograms are an **experimental** Prometheus feature that removes the need to predefine bucket boundaries and allows higher-resolution buckets that adapt to deployment characteristics. Whether a metric is exposed as classic or native depends entirely on the Prometheus server configuration (see [Prometheus docs](https://prometheus.io/docs/specs/native_histograms/) for details): diff --git a/docs/admin/monitoring/notifications/slack.md b/docs/admin/monitoring/notifications/slack.md index 7509c1fbad2d9..4d6330b66f8a7 100644 --- a/docs/admin/monitoring/notifications/slack.md +++ b/docs/admin/monitoring/notifications/slack.md @@ -196,7 +196,7 @@ To enable webhook integration in Coder, define the POST webhook endpoint matching the deployed Slack bot: ```sh -export CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT=http://localhost:6000/v1/webhook` +export CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT=http://localhost:6000/v1/webhook ``` Finally, go to the **Notification Settings** in Coder and switch the notifier to diff --git a/docs/admin/networking/port-forwarding.md b/docs/admin/networking/port-forwarding.md index a20439b1e46b9..5c7d5204d7166 100644 --- a/docs/admin/networking/port-forwarding.md +++ b/docs/admin/networking/port-forwarding.md @@ -78,7 +78,7 @@ where each segment of hostnames must not exceed 63 characters. If your app name, agent name, workspace name and username exceed 63 characters in the hostname, port forwarding via the dashboard will not work. -### From an coder_app resource +### From a coder_app resource One way to port forward is to configure a `coder_app` resource in the workspace's template. This approach shows a visual application icon in the diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index b83bcdc42aeaa..127eefd19a8b9 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -43,7 +43,7 @@ We track the following resources: | Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>active_version_id</td><td>true</td></tr><tr><td>activity_bump</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>cors_behavior</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>disable_module_cache</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_port_sharing_level</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_display_name</td><td>false</td></tr><tr><td>organization_icon</td><td>false</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>organization_name</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_autostop_notify</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_classic_parameter_flow</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> | | TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_name</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>source_example_id</td><td>false</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> | | User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>false</td></tr><tr><td>chat_spend_limit_micros</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>github_com_user_id</td><td>false</td></tr><tr><td>hashed_one_time_passcode</td><td>false</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>is_service_account</td><td>true</td></tr><tr><td>is_system</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>one_time_passcode_expires_at</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> | -| UserSecret<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>env_name</td><td>true</td></tr><tr><td>file_path</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>value</td><td>true</td></tr><tr><td>value_key_id</td><td>false</td></tr></tbody></table> | +| UserSecret<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>enabled</td><td>true</td></tr><tr><td>env_name</td><td>true</td></tr><tr><td>file_path</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>value</td><td>true</td></tr><tr><td>value_key_id</td><td>false</td></tr></tbody></table> | | UserSkill<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>content</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> | | WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>has_ai_task</td><td>false</td></tr><tr><td>has_external_agent</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_name</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>notified_autostop_deadline</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>template_version_preset_id</td><td>false</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> | | WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> | diff --git a/docs/admin/security/secrets.md b/docs/admin/security/secrets.md index 91cfdc5da4795..f8276a3ad468c 100644 --- a/docs/admin/security/secrets.md +++ b/docs/admin/security/secrets.md @@ -49,7 +49,8 @@ Users can view their public key in their account settings: User secrets are developer-managed values that Coder injects at workspace start. If a user secret targets the same environment variable name or file path as a template-provided variable or file, Coder injects the user secret into that -workspace. User secret values are covered by +workspace. A secret can be disabled, in which case it is stored but not injected +until it is re-enabled. User secret values are covered by [Database Encryption](./database-encryption.md) when it is enabled. See the [User secrets guide](../../user-guides/user-secrets.md). diff --git a/docs/admin/setup/configuration-reference.md b/docs/admin/setup/configuration-reference.md new file mode 100644 index 0000000000000..0790666d70102 --- /dev/null +++ b/docs/admin/setup/configuration-reference.md @@ -0,0 +1,1866 @@ +<!-- DO NOT EDIT | GENERATED CONTENT --> +# Configuration reference + +Coder server is configured primarily through environment variables. This page +lists every option so you can search by environment variable name, CLI flag, or +YAML key. For first-time setup guidance and worked examples, see +[Configure Control Plane Access](./index.md). + +Each option can be set through one or more of the methods below. An option lists +only the methods that apply to it. + +- An environment variable (recommended for production deployments running as a + system service, container, or Helm chart). +- A CLI flag passed to `coder server` (useful for one-off invocations + and local development). +- A key in a YAML configuration file passed with `--config`. + +For a full description of each option's accepted values and behavior, follow the +flag link into the [`coder server` CLI reference](../../reference/cli/server.md). + +Deprecated options are listed at the end of each section. + +## General + +### Allow workspace renames + +Allow users to rename their workspaces. WARNING: Renaming a workspace can cause Terraform resources that depend on the workspace name to be destroyed and recreated, potentially causing data loss. Only enable this if your templates do not use workspace names in resource identifiers, or if you understand the risks. + +- Environment variable: `CODER_ALLOW_WORKSPACE_RENAMES` +- CLI flag: [`--allow-workspace-renames`](../../reference/cli/server.md#--allow-workspace-renames) +- YAML key: `allowWorkspaceRenames` +- Default value: `false` + +### Cache directory + +The directory to cache temporary files. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility with systemd. This directory is NOT safe to be configured as a shared directory across coderd/provisionerd replicas. + +- Environment variable: `CODER_CACHE_DIRECTORY` +- CLI flag: [`--cache-dir`](../../reference/cli/server.md#--cache-dir) +- YAML key: `cacheDir` +- Default value: `~/.cache/coder` + +### Default OAuth refresh lifetime + +The default lifetime duration for OAuth2 refresh tokens. This controls how long refresh tokens remain valid after issuance or rotation. + +- Environment variable: `CODER_DEFAULT_OAUTH_REFRESH_LIFETIME` +- CLI flag: [`--default-oauth-refresh-lifetime`](../../reference/cli/server.md#--default-oauth-refresh-lifetime) +- YAML key: `defaultOAuthRefreshLifetime` +- Default value: `720h0m0s` + +### Default token lifetime + +The default lifetime duration for API tokens. This value is used when creating a token without specifying a duration, such as when authenticating the CLI or an IDE plugin. + +- Environment variable: `CODER_DEFAULT_TOKEN_LIFETIME` +- CLI flag: [`--default-token-lifetime`](../../reference/cli/server.md#--default-token-lifetime) +- YAML key: `defaultTokenLifetime` +- Default value: `168h0m0s` + +### Disable chat sharing + +Disable chat sharing. Chat ACL checking is disabled and only owners can access their chats. + +- Environment variable: `CODER_DISABLE_CHAT_SHARING` +- CLI flag: [`--disable-chat-sharing`](../../reference/cli/server.md#--disable-chat-sharing) +- YAML key: `disableChatSharing` + +### Disable owner workspace access + +Remove the permission for the 'owner' role to have workspace execution on all workspaces. This prevents the 'owner' from ssh, apps, and terminal access based on the 'owner' role. They still have their user permissions to access their own workspaces. + +- Environment variable: `CODER_DISABLE_OWNER_WORKSPACE_ACCESS` +- CLI flag: [`--disable-owner-workspace-access`](../../reference/cli/server.md#--disable-owner-workspace-access) +- YAML key: `disableOwnerWorkspaceAccess` + +### Disable path apps + +Disable workspace apps that are not served from subdomains. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured. + +- Environment variable: `CODER_DISABLE_PATH_APPS` +- CLI flag: [`--disable-path-apps`](../../reference/cli/server.md#--disable-path-apps) +- YAML key: `disablePathApps` + +### Disable workspace agent context sync + +Stop persisting workspace agent context snapshots (instructions, skills, and MCP state used for pinned chat context). When set, coderd rejects agent context pushes as unimplemented and agents stop sending them; chats cannot pin workspace context. Use this to shed the database write load of context sync on large deployments. + +- Environment variable: `CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC` +- CLI flag: [`--disable-workspace-agent-context-sync`](../../reference/cli/server.md#--disable-workspace-agent-context-sync) +- YAML key: `disableWorkspaceAgentContextSync` + +### Disable workspace sharing + +Disable workspace sharing. Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access based on the 'owner' role is also allowed unless disabled via --disable-owner-workspace-access. + +- Environment variable: `CODER_DISABLE_WORKSPACE_SHARING` +- CLI flag: [`--disable-workspace-sharing`](../../reference/cli/server.md#--disable-workspace-sharing) +- YAML key: `disableWorkspaceSharing` + +### Enable swagger endpoint + +Expose the swagger endpoint via /swagger. + +- Environment variable: `CODER_SWAGGER_ENABLE` +- CLI flag: [`--swagger-enable`](../../reference/cli/server.md#--swagger-enable) +- YAML key: `enableSwagger` + +### Experiments + +Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments. + +- Environment variable: `CODER_EXPERIMENTS` +- CLI flag: [`--experiments`](../../reference/cli/server.md#--experiments) +- YAML key: `experiments` + +### External auth GitHub default provider enable + +Enable the default GitHub external auth provider managed by Coder. + +- Environment variable: `CODER_EXTERNAL_AUTH_GITHUB_DEFAULT_PROVIDER_ENABLE` +- CLI flag: [`--external-auth-github-default-provider-enable`](../../reference/cli/server.md#--external-auth-github-default-provider-enable) +- YAML key: `externalAuthGithubDefaultProviderEnable` +- Default value: `true` + +### External token encryption keys + +Encrypt OIDC and Git authentication tokens with AES-256-GCM in the database. The value must be a comma-separated list of base64-encoded keys. Each key, when base64-decoded, must be exactly 32 bytes in length. The first key will be used to encrypt new values. Subsequent keys will be used as a fallback when decrypting. During normal operation it is recommended to only set one key unless you are in the process of rotating keys with the `coder server dbcrypt rotate` command. + +- Environment variable: `CODER_EXTERNAL_TOKEN_ENCRYPTION_KEYS` +- CLI flag: [`--external-token-encryption-keys`](../../reference/cli/server.md#--external-token-encryption-keys) + +### Postgres auth + +Type of auth to use when connecting to postgres. For AWS RDS, using IAM authentication (awsiamrds) is recommended. + +- Environment variable: `CODER_PG_AUTH` +- CLI flag: [`--postgres-auth`](../../reference/cli/server.md#--postgres-auth) +- YAML key: `pgAuth` +- Default value: `password` + +### Postgres connection max idle + +Maximum number of idle connections to the database. Set to "auto" (the default) to use max open / 3. Value must be greater or equal to 0; 0 means explicitly no idle connections. + +- Environment variable: `CODER_PG_CONN_MAX_IDLE` +- CLI flag: [`--postgres-conn-max-idle`](../../reference/cli/server.md#--postgres-conn-max-idle) +- YAML key: `pgConnMaxIdle` +- Default value: `auto` + +### Postgres connection max open + +Maximum number of open connections to the database. Defaults to 10. + +- Environment variable: `CODER_PG_CONN_MAX_OPEN` +- CLI flag: [`--postgres-conn-max-open`](../../reference/cli/server.md#--postgres-conn-max-open) +- YAML key: `pgConnMaxOpen` +- Default value: `10` + +### Postgres connection URL + +URL of a PostgreSQL database. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with "coder server postgres-builtin-url". Note that any special characters in the URL must be URL-encoded. + +- Environment variable: `CODER_PG_CONNECTION_URL` +- CLI flag: [`--postgres-url`](../../reference/cli/server.md#--postgres-url) + +### SCIM API key + +Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. + +- Environment variable: `CODER_SCIM_AUTH_HEADER` +- CLI flag: [`--scim-auth-header`](../../reference/cli/server.md#--scim-auth-header) + +### SCIM use legacy + +Use the legacy SCIM implementation instead of the SCIM 2.0 handler. This is provided for backward compatibility for existing users. + +- Environment variable: `CODER_SCIM_USE_LEGACY` +- CLI flag: [`--scim-use-legacy`](../../reference/cli/server.md#--scim-use-legacy) +- YAML key: `scimUseLegacy` +- Default value: `true` + +### SSH keygen algorithm + +The algorithm to use for generating ssh keys. Accepted values are "ed25519", "ecdsa", or "rsa4096". + +- Environment variable: `CODER_SSH_KEYGEN_ALGORITHM` +- CLI flag: [`--ssh-keygen-algorithm`](../../reference/cli/server.md#--ssh-keygen-algorithm) +- YAML key: `sshKeygenAlgorithm` +- Default value: `ed25519` + +### Support links + +Support links to display in the top right drop down menu. + +- Environment variable: `CODER_SUPPORT_LINKS` +- CLI flag: [`--support-links`](../../reference/cli/server.md#--support-links) +- YAML key: `supportLinks` + +### Terms of service URL + +A URL to an external Terms of Service that must be accepted by users when logging in. + +- Environment variable: `CODER_TERMS_OF_SERVICE_URL` +- CLI flag: [`--terms-of-service-url`](../../reference/cli/server.md#--terms-of-service-url) +- YAML key: `termsOfServiceURL` + +### Update check + +Periodically check for new releases of Coder and inform the owner. The check is performed once per day. + +- Environment variable: `CODER_UPDATE_CHECK` +- CLI flag: [`--update-check`](../../reference/cli/server.md#--update-check) +- YAML key: `updateCheck` +- Default value: `false` + +## AI Gateway + +### AI budget period + +Determines when accumulated AI spend resets to zero, aligned to UTC calendar boundaries. Only "month" is currently supported. + +- Environment variable: `CODER_AI_BUDGET_PERIOD` +- CLI flag: [`--ai-budget-period`](../../reference/cli/server.md#--ai-budget-period) +- YAML key: `ai_gateway.budget_period` +- Default value: `month` + +### AI budget policy + +Determines the effective group when a user belongs to multiple groups with AI budgets. "highest" selects the group with the largest spend limit, and is currently the only supported value. + +- Environment variable: `CODER_AI_BUDGET_POLICY` +- CLI flag: [`--ai-budget-policy`](../../reference/cli/server.md#--ai-budget-policy) +- YAML key: `ai_gateway.budget_policy` +- Default value: `highest` + +### API dump directory + +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. + +- Environment variable: `CODER_AI_GATEWAY_DUMP_DIR` +- CLI flag: [`--ai-gateway-dump-dir`](../../reference/cli/server.md#--ai-gateway-dump-dir) +- YAML key: `ai_gateway.api_dump_dir` + +### Allow BYOK + +Allow users to provide their own LLM API keys or subscriptions. When disabled, only centralized key authentication is permitted. + +- Environment variable: `CODER_AI_GATEWAY_ALLOW_BYOK` +- CLI flag: [`--ai-gateway-allow-byok`](../../reference/cli/server.md#--ai-gateway-allow-byok) +- YAML key: `ai_gateway.allow_byok` +- Default value: `true` + +### Circuit breaker enabled + +Enable the circuit breaker to protect against cascading failures from upstream AI provider overload (503, 529). + +- Environment variable: `CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED` +- CLI flag: [`--ai-gateway-circuit-breaker-enabled`](../../reference/cli/server.md#--ai-gateway-circuit-breaker-enabled) +- YAML key: `ai_gateway.circuit_breaker_enabled` +- Default value: `false` + +### Data retention duration + +Length of time to retain data such as interceptions and all related records (token, prompt, tool use). + +- Environment variable: `CODER_AI_GATEWAY_RETENTION` +- CLI flag: [`--ai-gateway-retention`](../../reference/cli/server.md#--ai-gateway-retention) +- YAML key: `ai_gateway.retention` +- Default value: `60d` + +### Enabled + +Whether to start an in-memory AI Gateway instance. + +- Environment variable: `CODER_AI_GATEWAY_ENABLED` +- CLI flag: [`--ai-gateway-enabled`](../../reference/cli/server.md#--ai-gateway-enabled) +- YAML key: `ai_gateway.enabled` +- Default value: `true` + +### Max concurrency + +Maximum number of concurrent AI Gateway requests per replica. Set to 0 to disable (unlimited). + +- Environment variable: `CODER_AI_GATEWAY_MAX_CONCURRENCY` +- CLI flag: [`--ai-gateway-max-concurrency`](../../reference/cli/server.md#--ai-gateway-max-concurrency) +- YAML key: `ai_gateway.max_concurrency` +- Default value: `0` + +### Rate limit + +Maximum number of AI Gateway requests per second per replica. Set to 0 to disable (unlimited). + +- Environment variable: `CODER_AI_GATEWAY_RATE_LIMIT` +- CLI flag: [`--ai-gateway-rate-limit`](../../reference/cli/server.md#--ai-gateway-rate-limit) +- YAML key: `ai_gateway.rate_limit` +- Default value: `0` + +### Send actor headers + +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). + +- Environment variable: `CODER_AI_GATEWAY_SEND_ACTOR_HEADERS` +- CLI flag: [`--ai-gateway-send-actor-headers`](../../reference/cli/server.md#--ai-gateway-send-actor-headers) +- YAML key: `ai_gateway.send_actor_headers` +- Default value: `false` + +### Structured logging + +Emit structured logs for AI Gateway interception records. Use this for exporting these records to external SIEM or observability systems. + +- Environment variable: `CODER_AI_GATEWAY_STRUCTURED_LOGGING` +- CLI flag: [`--ai-gateway-structured-logging`](../../reference/cli/server.md#--ai-gateway-structured-logging) +- YAML key: `ai_gateway.structured_logging` +- Default value: `false` + +### Anthropic base URL + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The base URL of the Anthropic API. + +- Environment variable: `CODER_AI_GATEWAY_ANTHROPIC_BASE_URL` +- CLI flag: [`--ai-gateway-anthropic-base-url`](../../reference/cli/server.md#--ai-gateway-anthropic-base-url) +- YAML key: `ai_gateway.anthropic_base_url` +- Default value: `https://api.anthropic.com/` + +### Anthropic key + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The key to authenticate against the Anthropic API. + +- Environment variable: `CODER_AI_GATEWAY_ANTHROPIC_KEY` +- CLI flag: [`--ai-gateway-anthropic-key`](../../reference/cli/server.md#--ai-gateway-anthropic-key) + +### Bedrock access key + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The access key to authenticate against the AWS Bedrock API. + +- Environment variable: `CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY` +- CLI flag: [`--ai-gateway-bedrock-access-key`](../../reference/cli/server.md#--ai-gateway-bedrock-access-key) + +### Bedrock access key secret + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The access key secret to use with the access key to authenticate against the AWS Bedrock API. + +- Environment variable: `CODER_AI_GATEWAY_BEDROCK_ACCESS_KEY_SECRET` +- CLI flag: [`--ai-gateway-bedrock-access-key-secret`](../../reference/cli/server.md#--ai-gateway-bedrock-access-key-secret) + +### Bedrock base URL + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The base URL to use for the AWS Bedrock API. Use this setting to specify an exact URL to use. Takes precedence over CODER_AI_GATEWAY_BEDROCK_REGION. + +- Environment variable: `CODER_AI_GATEWAY_BEDROCK_BASE_URL` +- CLI flag: [`--ai-gateway-bedrock-base-url`](../../reference/cli/server.md#--ai-gateway-bedrock-base-url) +- YAML key: `ai_gateway.bedrock_base_url` + +### Bedrock model + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The model to use when making requests to the AWS Bedrock API. + +- Environment variable: `CODER_AI_GATEWAY_BEDROCK_MODEL` +- CLI flag: [`--ai-gateway-bedrock-model`](../../reference/cli/server.md#--ai-gateway-bedrock-model) +- YAML key: `ai_gateway.bedrock_model` +- Default value: `global.anthropic.claude-sonnet-4-5-20250929-v1:0` + +### Bedrock region + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The AWS Bedrock API region to use. Constructs a base URL to use for the AWS Bedrock API in the form of 'https://bedrock-runtime.<region>.amazonaws.com'. + +- Environment variable: `CODER_AI_GATEWAY_BEDROCK_REGION` +- CLI flag: [`--ai-gateway-bedrock-region`](../../reference/cli/server.md#--ai-gateway-bedrock-region) +- YAML key: `ai_gateway.bedrock_region` + +### Bedrock small fast model + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The small fast model to use when making requests to the AWS Bedrock API. Claude Code uses Haiku-class models to perform background tasks. See https://docs.claude.com/en/docs/claude-code/settings#environment-variables. + +- Environment variable: `CODER_AI_GATEWAY_BEDROCK_SMALL_FAST_MODEL` +- CLI flag: [`--ai-gateway-bedrock-small-fastmodel`](../../reference/cli/server.md#--ai-gateway-bedrock-small-fastmodel) +- YAML key: `ai_gateway.bedrock_small_fast_model` +- Default value: `global.anthropic.claude-haiku-4-5-20251001-v1:0` + +### OpenAI base URL + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The base URL of the OpenAI API. + +- Environment variable: `CODER_AI_GATEWAY_OPENAI_BASE_URL` +- CLI flag: [`--ai-gateway-openai-base-url`](../../reference/cli/server.md#--ai-gateway-openai-base-url) +- YAML key: `ai_gateway.openai_base_url` +- Default value: `https://api.openai.com/v1/` + +### OpenAI key + +**Deprecated**: manage AI Providers from the Coder UI or HTTP API. If set, this option seeds provider configuration at startup only exactly once. It will not be used in service runtime. The key to authenticate against the OpenAI API. + +- Environment variable: `CODER_AI_GATEWAY_OPENAI_KEY` +- CLI flag: [`--ai-gateway-openai-key`](../../reference/cli/server.md#--ai-gateway-openai-key) + +## AI Gateway Proxy + +### API dump directory + +Directory for dumping MITM request/response pairs to disk for debugging. When set, each proxied request produces .req.txt and .resp.txt files organized by provider. Sensitive headers are redacted. Leave empty to disable. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_DUMP_DIR` +- CLI flag: [`--ai-gateway-proxy-dump-dir`](../../reference/cli/server.md#--ai-gateway-proxy-dump-dir) +- YAML key: `ai_gateway_proxy.api_dump_dir` + +### Allowed private CIDRs + +Comma-separated list of CIDR ranges that are permitted even though they fall within blocked private/reserved IP ranges. By default all private ranges are blocked to prevent SSRF attacks. Use this to allow access to specific internal networks. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_ALLOWED_PRIVATE_CIDRS` +- CLI flag: [`--ai-gateway-proxy-allowed-private-cidrs`](../../reference/cli/server.md#--ai-gateway-proxy-allowed-private-cidrs) +- YAML key: `ai_gateway_proxy.allowed_private_cidrs` + +### Enabled + +Enable the AI Gateway MITM Proxy for intercepting and decrypting AI provider requests. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_ENABLED` +- CLI flag: [`--ai-gateway-proxy-enabled`](../../reference/cli/server.md#--ai-gateway-proxy-enabled) +- YAML key: `ai_gateway_proxy.enabled` +- Default value: `false` + +### Listen address + +The address the AI Gateway Proxy will listen on. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_LISTEN_ADDR` +- CLI flag: [`--ai-gateway-proxy-listen-addr`](../../reference/cli/server.md#--ai-gateway-proxy-listen-addr) +- YAML key: `ai_gateway_proxy.listen_addr` +- Default value: `:8888` + +### MITM CA certificate file + +Path to the CA certificate file used to intercept (MITM) HTTPS traffic from AI clients. This CA must be trusted by AI clients for the proxy to decrypt their requests. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_CERT_FILE` +- CLI flag: [`--ai-gateway-proxy-cert-file`](../../reference/cli/server.md#--ai-gateway-proxy-cert-file) +- YAML key: `ai_gateway_proxy.cert_file` + +### MITM CA key file + +Path to the CA private key file used to intercept (MITM) HTTPS traffic from AI clients. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_KEY_FILE` +- CLI flag: [`--ai-gateway-proxy-key-file`](../../reference/cli/server.md#--ai-gateway-proxy-key-file) +- YAML key: `ai_gateway_proxy.key_file` + +### TLS certificate file + +Path to the TLS certificate file for the AI Gateway Proxy listener. Must be set together with AI Gateway Proxy TLS Key File. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_TLS_CERT_FILE` +- CLI flag: [`--ai-gateway-proxy-tls-cert-file`](../../reference/cli/server.md#--ai-gateway-proxy-tls-cert-file) +- YAML key: `ai_gateway_proxy.tls_cert_file` + +### TLS key file + +Path to the TLS private key file for the AI Gateway Proxy listener. Must be set together with AI Gateway Proxy TLS Certificate File. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_TLS_KEY_FILE` +- CLI flag: [`--ai-gateway-proxy-tls-key-file`](../../reference/cli/server.md#--ai-gateway-proxy-tls-key-file) +- YAML key: `ai_gateway_proxy.tls_key_file` + +### 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. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_TARGET` +- CLI flag: [`--ai-gateway-proxy-target`](../../reference/cli/server.md#--ai-gateway-proxy-target) +- YAML key: `ai_gateway_proxy.target` + +### Upstream proxy + +URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests through. Format: http://[user:pass@]host:port or https://[user:pass@]host:port. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_UPSTREAM` +- CLI flag: [`--ai-gateway-proxy-upstream`](../../reference/cli/server.md#--ai-gateway-proxy-upstream) +- YAML key: `ai_gateway_proxy.upstream_proxy` + +### Upstream proxy CA + +Path to a PEM-encoded CA certificate to trust for the upstream proxy's TLS connection. Only needed for HTTPS upstream proxies with certificates not trusted by the system. If not provided, the system certificate pool is used. + +- Environment variable: `CODER_AI_GATEWAY_PROXY_UPSTREAM_CA` +- CLI flag: [`--ai-gateway-proxy-upstream-ca`](../../reference/cli/server.md#--ai-gateway-proxy-upstream-ca) +- YAML key: `ai_gateway_proxy.upstream_proxy_ca` + +## Chat + +Configure the background chat processing daemon. + +### Debug logging enabled + +Force chat debug logging on for every chat, bypassing the runtime admin and user opt-in settings. + +- Environment variable: `CODER_CHAT_DEBUG_LOGGING_ENABLED` +- CLI flag: [`--chat-debug-logging-enabled`](../../reference/cli/server.md#--chat-debug-logging-enabled) +- YAML key: `chat.debugLoggingEnabled` +- Default value: `false` + +## Client + +These options change the behavior of how clients interact with the Coder. Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI. + +### CLI upgrade message + +The upgrade message to display to users when a client/server mismatch is detected. By default it instructs users to update using 'curl -L https://coder.com/install.sh | sh'. + +- Environment variable: `CODER_CLI_UPGRADE_MESSAGE` +- CLI flag: [`--cli-upgrade-message`](../../reference/cli/server.md#--cli-upgrade-message) +- YAML key: `client.cliUpgradeMessage` + +### Hide AI tasks + +Hide AI tasks from the dashboard. + +- Environment variable: `CODER_HIDE_AI_TASKS` +- CLI flag: [`--hide-ai-tasks`](../../reference/cli/server.md#--hide-ai-tasks) +- YAML key: `client.hideAITasks` +- Default value: `false` + +### SSH config options + +These SSH config options will override the default SSH config options. Provide options in "key=value" or "key value" format separated by commas. Using this incorrectly can break SSH to your deployment, use cautiously. The following options are not allowed: Host, Match, Include, ProxyCommand, ProxyJump, LocalCommand, PermitLocalCommand, RemoteCommand, KnownHostsCommand, PKCS11Provider, SecurityKeyProvider, SmartcardDevice, XAuthLocation. Option values must not contain newline, carriage return, or NUL characters. + +- Environment variable: `CODER_SSH_CONFIG_OPTIONS` +- CLI flag: [`--ssh-config-options`](../../reference/cli/server.md#--ssh-config-options) +- YAML key: `client.sshConfigOptions` + +### Web terminal renderer + +The renderer to use when opening a web terminal. Valid values are 'canvas', 'webgl', or 'dom'. + +- Environment variable: `CODER_WEB_TERMINAL_RENDERER` +- CLI flag: [`--web-terminal-renderer`](../../reference/cli/server.md#--web-terminal-renderer) +- YAML key: `client.webTerminalRenderer` +- Default value: `canvas` + +### Workspace hostname suffix + +Workspace hostnames use this suffix in SSH config and Coder Connect on Coder Desktop. By default it is coder, resulting in names like myworkspace.coder. The suffix must not start with a dot, and must not contain spaces, newlines, or glob characters (* and ?). + +- Environment variable: `CODER_WORKSPACE_HOSTNAME_SUFFIX` +- CLI flag: [`--workspace-hostname-suffix`](../../reference/cli/server.md#--workspace-hostname-suffix) +- YAML key: `client.workspaceHostnameSuffix` +- Default value: `coder` + +## Config + +Use a YAML configuration file when your server launch become unwieldy. + +### Path + +Specify a YAML file to load configuration from. + +- Environment variable: `CODER_CONFIG_PATH` +- CLI flag: [`--config`](../../reference/cli/server.md#-c---config) + +### Write config + +Write out the current server config as YAML to stdout. + +- CLI flag: [`--write-config`](../../reference/cli/server.md#--write-config) + +## Email + +Configure how emails are sent. + +### Force TLS + +Force a TLS connection to the configured SMTP smarthost. + +- Environment variable: `CODER_EMAIL_FORCE_TLS` +- CLI flag: [`--email-force-tls`](../../reference/cli/server.md#--email-force-tls) +- YAML key: `email.forceTLS` +- Default value: `false` + +### From address + +The sender's address to use. + +- Environment variable: `CODER_EMAIL_FROM` +- CLI flag: [`--email-from`](../../reference/cli/server.md#--email-from) +- YAML key: `email.from` + +### Hello + +The hostname identifying the SMTP server. + +- Environment variable: `CODER_EMAIL_HELLO` +- CLI flag: [`--email-hello`](../../reference/cli/server.md#--email-hello) +- YAML key: `email.hello` +- Default value: `localhost` + +### Smarthost + +The intermediary SMTP host through which emails are sent. + +- Environment variable: `CODER_EMAIL_SMARTHOST` +- CLI flag: [`--email-smarthost`](../../reference/cli/server.md#--email-smarthost) +- YAML key: `email.smarthost` + +### Email authentication + +Configure SMTP authentication options. + +#### Identity + +Identity to use with PLAIN authentication. + +- Environment variable: `CODER_EMAIL_AUTH_IDENTITY` +- CLI flag: [`--email-auth-identity`](../../reference/cli/server.md#--email-auth-identity) +- YAML key: `email.emailAuth.identity` + +#### Password + +Password to use with PLAIN/LOGIN authentication. + +- Environment variable: `CODER_EMAIL_AUTH_PASSWORD` +- CLI flag: [`--email-auth-password`](../../reference/cli/server.md#--email-auth-password) + +#### Password file + +File from which to load password for use with PLAIN/LOGIN authentication. + +- Environment variable: `CODER_EMAIL_AUTH_PASSWORD_FILE` +- CLI flag: [`--email-auth-password-file`](../../reference/cli/server.md#--email-auth-password-file) +- YAML key: `email.emailAuth.passwordFile` + +#### Username + +Username to use with PLAIN/LOGIN authentication. + +- Environment variable: `CODER_EMAIL_AUTH_USERNAME` +- CLI flag: [`--email-auth-username`](../../reference/cli/server.md#--email-auth-username) +- YAML key: `email.emailAuth.username` + +### Email TLS + +Configure TLS for your SMTP server target. + +#### Certificate authority file + +CA certificate file to use. + +- Environment variable: `CODER_EMAIL_TLS_CACERTFILE` +- CLI flag: [`--email-tls-ca-cert-file`](../../reference/cli/server.md#--email-tls-ca-cert-file) +- YAML key: `email.emailTLS.caCertFile` + +#### Certificate file + +Certificate file to use. + +- Environment variable: `CODER_EMAIL_TLS_CERTFILE` +- CLI flag: [`--email-tls-cert-file`](../../reference/cli/server.md#--email-tls-cert-file) +- YAML key: `email.emailTLS.certFile` + +#### Certificate key file + +Certificate key file to use. + +- Environment variable: `CODER_EMAIL_TLS_CERTKEYFILE` +- CLI flag: [`--email-tls-cert-key-file`](../../reference/cli/server.md#--email-tls-cert-key-file) +- YAML key: `email.emailTLS.certKeyFile` + +#### Server name + +Server name to verify against the target certificate. + +- Environment variable: `CODER_EMAIL_TLS_SERVERNAME` +- CLI flag: [`--email-tls-server-name`](../../reference/cli/server.md#--email-tls-server-name) +- YAML key: `email.emailTLS.serverName` + +#### Skip certificate verification (insecure) + +Skip verification of the target server's certificate (insecure). + +- Environment variable: `CODER_EMAIL_TLS_SKIPVERIFY` +- CLI flag: [`--email-tls-skip-verify`](../../reference/cli/server.md#--email-tls-skip-verify) +- YAML key: `email.emailTLS.insecureSkipVerify` + +#### StartTLS + +Enable STARTTLS to upgrade insecure SMTP connections using TLS. + +- Environment variable: `CODER_EMAIL_TLS_STARTTLS` +- CLI flag: [`--email-tls-starttls`](../../reference/cli/server.md#--email-tls-starttls) +- YAML key: `email.emailTLS.startTLS` + +## Introspection + +Configure logging, tracing, stat collection, and metrics exporting. + +### Health check + +#### Refresh + +Refresh interval for healthchecks. + +- Environment variable: `CODER_HEALTH_CHECK_REFRESH` +- CLI flag: [`--health-check-refresh`](../../reference/cli/server.md#--health-check-refresh) +- YAML key: `introspection.healthcheck.refresh` +- Default value: `10m0s` + +#### Threshold: database + +The threshold for the database health check. If the median latency of the database exceeds this threshold over 5 attempts, the database is considered unhealthy. The default value is 15ms. + +- Environment variable: `CODER_HEALTH_CHECK_THRESHOLD_DATABASE` +- CLI flag: [`--health-check-threshold-database`](../../reference/cli/server.md#--health-check-threshold-database) +- YAML key: `introspection.healthcheck.thresholdDatabase` +- Default value: `15ms` + +### Logging + +#### Enable Terraform debug mode + +Allow administrators to enable Terraform debug output. + +- Environment variable: `CODER_ENABLE_TERRAFORM_DEBUG_MODE` +- CLI flag: [`--enable-terraform-debug-mode`](../../reference/cli/server.md#--enable-terraform-debug-mode) +- YAML key: `introspection.logging.enableTerraformDebugMode` +- Default value: `false` + +#### Human log location + +Output human-readable logs to a given file. + +- Environment variable: `CODER_LOGGING_HUMAN` +- CLI flag: [`--log-human`](../../reference/cli/server.md#--log-human) +- YAML key: `introspection.logging.humanPath` +- Default value: `/dev/stderr` + +#### JSON log location + +Output JSON logs to a given file. + +- Environment variable: `CODER_LOGGING_JSON` +- CLI flag: [`--log-json`](../../reference/cli/server.md#--log-json) +- YAML key: `introspection.logging.jsonPath` + +#### Log filter + +Filter debug logs by matching against a given regex. Use .* to match all debug logs. + +- Environment variable: `CODER_LOG_FILTER` +- CLI flag: [`--log-filter`](../../reference/cli/server.md#-l---log-filter) +- YAML key: `introspection.logging.filter` + +#### Stackdriver log location + +Output Stackdriver compatible logs to a given file. + +- Environment variable: `CODER_LOGGING_STACKDRIVER` +- CLI flag: [`--log-stackdriver`](../../reference/cli/server.md#--log-stackdriver) +- YAML key: `introspection.logging.stackdriverPath` + +### Prometheus + +#### Address + +The bind address to serve prometheus metrics. + +- Environment variable: `CODER_PROMETHEUS_ADDRESS` +- CLI flag: [`--prometheus-address`](../../reference/cli/server.md#--prometheus-address) +- YAML key: `introspection.prometheus.address` +- Default value: `127.0.0.1:2112` + +#### Aggregate agent stats by + +When collecting agent stats, aggregate metrics by a given set of comma-separated labels to reduce cardinality. Accepted values are agent_name, template_name, username, workspace_name. + +- Environment variable: `CODER_PROMETHEUS_AGGREGATE_AGENT_STATS_BY` +- CLI flag: [`--prometheus-aggregate-agent-stats-by`](../../reference/cli/server.md#--prometheus-aggregate-agent-stats-by) +- YAML key: `introspection.prometheus.aggregate_agent_stats_by` +- Default value: `agent_name,template_name,username,workspace_name` + +#### Collect agent stats + +Collect agent stats (may increase charges for metrics storage). + +- Environment variable: `CODER_PROMETHEUS_COLLECT_AGENT_STATS` +- CLI flag: [`--prometheus-collect-agent-stats`](../../reference/cli/server.md#--prometheus-collect-agent-stats) +- YAML key: `introspection.prometheus.collect_agent_stats` + +#### Collect database metrics + +Collect database query metrics (may increase charges for metrics storage). If set to false, a reduced set of database metrics are still collected. + +- Environment variable: `CODER_PROMETHEUS_COLLECT_DB_METRICS` +- CLI flag: [`--prometheus-collect-db-metrics`](../../reference/cli/server.md#--prometheus-collect-db-metrics) +- YAML key: `introspection.prometheus.collect_db_metrics` +- Default value: `false` + +#### Enable + +Serve prometheus metrics on the address defined by prometheus address. + +- Environment variable: `CODER_PROMETHEUS_ENABLE` +- CLI flag: [`--prometheus-enable`](../../reference/cli/server.md#--prometheus-enable) +- YAML key: `introspection.prometheus.enable` + +### Stats collection + +#### Usage stats + +##### Enable + +Enable the collection of application and workspace usage along with the associated API endpoints and the template insights page. Disabling this will also disable traffic and connection insights in the deployment stats shown to admins in the bottom bar of the Coder UI, and will prevent Prometheus collection of these values. + +- Environment variable: `CODER_STATS_COLLECTION_USAGE_STATS_ENABLE` +- CLI flag: [`--stats-collection-usage-stats-enable`](../../reference/cli/server.md#--stats-collection-usage-stats-enable) +- YAML key: `introspection.statsCollection.usageStats.enable` +- Default value: `true` + +### Tracing + +#### Capture logs in traces + +Enables capturing of logs as events in traces. This is useful for debugging, but may result in a very large amount of events being sent to the tracing backend which may incur significant costs. + +- Environment variable: `CODER_TRACE_LOGS` +- CLI flag: [`--trace-logs`](../../reference/cli/server.md#--trace-logs) +- YAML key: `introspection.tracing.captureLogs` + +#### Trace enable + +Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md. + +- Environment variable: `CODER_TRACE_ENABLE` +- CLI flag: [`--trace`](../../reference/cli/server.md#--trace) +- YAML key: `introspection.tracing.enable` + +#### Trace Honeycomb API key + +Enables trace exporting to Honeycomb.io using the provided API Key. + +- Environment variable: `CODER_TRACE_HONEYCOMB_API_KEY` +- CLI flag: [`--trace-honeycomb-api-key`](../../reference/cli/server.md#--trace-honeycomb-api-key) + +### pprof + +#### Address + +The bind address to serve pprof. + +- Environment variable: `CODER_PPROF_ADDRESS` +- CLI flag: [`--pprof-address`](../../reference/cli/server.md#--pprof-address) +- YAML key: `introspection.pprof.address` +- Default value: `127.0.0.1:6060` + +#### Enable + +Serve pprof metrics on the address defined by pprof address. + +- Environment variable: `CODER_PPROF_ENABLE` +- CLI flag: [`--pprof-enable`](../../reference/cli/server.md#--pprof-enable) +- YAML key: `introspection.pprof.enable` + +## Networking + +### Access URL + +The URL that users will use to access the Coder deployment. + +- Environment variable: `CODER_ACCESS_URL` +- CLI flag: [`--access-url`](../../reference/cli/server.md#--access-url) +- YAML key: `networking.accessURL` + +### Browser only + +Whether Coder only allows connections to workspaces via the browser. + +- Environment variable: `CODER_BROWSER_ONLY` +- CLI flag: [`--browser-only`](../../reference/cli/server.md#--browser-only) +- YAML key: `networking.browserOnly` + +### Docs URL + +Specifies the custom docs URL. + +- Environment variable: `CODER_DOCS_URL` +- CLI flag: [`--docs-url`](../../reference/cli/server.md#--docs-url) +- YAML key: `networking.docsURL` +- Default value: `https://coder.com/docs` + +### Proxy trusted headers + +Headers to trust for forwarding IP addresses. e.g. Cf-Connecting-Ip, True-Client-Ip, X-Forwarded-For. + +- Environment variable: `CODER_PROXY_TRUSTED_HEADERS` +- CLI flag: [`--proxy-trusted-headers`](../../reference/cli/server.md#--proxy-trusted-headers) +- YAML key: `networking.proxyTrustedHeaders` + +### Proxy trusted origins + +Origin addresses to respect "proxy-trusted-headers" and X-Forwarded-Host for subdomain app routing. e.g. 192.168.1.0/24. + +- Environment variable: `CODER_PROXY_TRUSTED_ORIGINS` +- CLI flag: [`--proxy-trusted-origins`](../../reference/cli/server.md#--proxy-trusted-origins) +- YAML key: `networking.proxyTrustedOrigins` + +### Redirect to access URL + +Specifies whether to redirect requests that do not match the access URL host. + +- Environment variable: `CODER_REDIRECT_TO_ACCESS_URL` +- CLI flag: [`--redirect-to-access-url`](../../reference/cli/server.md#--redirect-to-access-url) +- YAML key: `networking.redirectToAccessURL` + +### SameSite auth cookie + +Controls the 'SameSite' property is set on browser session cookies. + +- Environment variable: `CODER_SAMESITE_AUTH_COOKIE` +- CLI flag: [`--samesite-auth-cookie`](../../reference/cli/server.md#--samesite-auth-cookie) +- YAML key: `networking.sameSiteAuthCookie` +- Default value: `lax` + +### Secure auth cookie + +Controls if the 'Secure' property is set on browser session cookies. + +- Environment variable: `CODER_SECURE_AUTH_COOKIE` +- CLI flag: [`--secure-auth-cookie`](../../reference/cli/server.md#--secure-auth-cookie) +- YAML key: `networking.secureAuthCookie` +- Default value: `(computed at runtime)` + +### Wildcard access URL + +Specifies the wildcard hostname to use for workspace applications in the form "*.example.com". + +- Environment variable: `CODER_WILDCARD_ACCESS_URL` +- CLI flag: [`--wildcard-access-url`](../../reference/cli/server.md#--wildcard-access-url) +- YAML key: `networking.wildcardAccessURL` + +### __Host prefix cookies + +Recommended to be enabled. Enables `__Host-` prefix for cookies to guarantee they are only set by the right domain. This change is disruptive to any workspaces built before release 2.31, requiring a workspace restart. + +- Environment variable: `CODER_HOST_PREFIX_COOKIE` +- CLI flag: [`--host-prefix-cookie`](../../reference/cli/server.md#--host-prefix-cookie) +- YAML key: `networking.hostPrefixCookie` +- Default value: `false` + +### Cluster + +Configure network clustering. Coder Servers in the primary region form a cluster by communicating directly. + +#### Host + +Hostname or (more commonly) IP to reach this replica for clustering. + +- Environment variable: `CODER_CLUSTER_HOST` +- CLI flag: [`--cluster-host`](../../reference/cli/server.md#--cluster-host) +- YAML key: `networking.cluster.clusterHost` + +### DERP + +Most Coder deployments never have to think about DERP because all connections between workspaces and users are peer-to-peer. However, when Coder cannot establish a peer to peer connection, Coder uses a distributed relay network backed by Tailscale and WireGuard. + +#### Block direct connections + +Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspaces may still reach out to STUN servers to get their address until they are restarted after this change has been made, but new connections will still be proxied regardless. + +- Environment variable: `CODER_BLOCK_DIRECT` +- CLI flag: [`--block-direct-connections`](../../reference/cli/server.md#--block-direct-connections) +- YAML key: `networking.derp.blockDirect` + +#### Config path + +Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/. + +- Environment variable: `CODER_DERP_CONFIG_PATH` +- CLI flag: [`--derp-config-path`](../../reference/cli/server.md#--derp-config-path) +- YAML key: `networking.derp.configPath` + +#### Config URL + +URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/. + +- Environment variable: `CODER_DERP_CONFIG_URL` +- CLI flag: [`--derp-config-url`](../../reference/cli/server.md#--derp-config-url) +- YAML key: `networking.derp.url` + +#### Force WebSockets + +Force clients and agents to always use WebSocket to connect to DERP relay servers. By default, DERP uses `Upgrade: derp`, which may cause issues with some reverse proxies. Clients may automatically fallback to WebSocket if they detect an issue with `Upgrade: derp`, but this does not work in all situations. + +- Environment variable: `CODER_DERP_FORCE_WEBSOCKETS` +- CLI flag: [`--derp-force-websockets`](../../reference/cli/server.md#--derp-force-websockets) +- YAML key: `networking.derp.forceWebSockets` + +#### Server enable + +Whether to enable or disable the embedded DERP relay server. + +- Environment variable: `CODER_DERP_SERVER_ENABLE` +- CLI flag: [`--derp-server-enable`](../../reference/cli/server.md#--derp-server-enable) +- YAML key: `networking.derp.enable` +- Default value: `true` + +#### Server region name + +Region name that for the embedded DERP server. + +- Environment variable: `CODER_DERP_SERVER_REGION_NAME` +- CLI flag: [`--derp-server-region-name`](../../reference/cli/server.md#--derp-server-region-name) +- YAML key: `networking.derp.regionName` +- Default value: `Coder Embedded Relay` + +#### Server relay URL + +An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability. + +- Environment variable: `CODER_DERP_SERVER_RELAY_URL` +- CLI flag: [`--derp-server-relay-url`](../../reference/cli/server.md#--derp-server-relay-url) +- YAML key: `networking.derp.relayURL` + +#### Server STUN addresses + +Addresses for STUN servers to establish P2P connections. It's recommended to have at least two STUN servers to give users the best chance of connecting P2P to workspaces. Each STUN server will get it's own DERP region, with region IDs starting at `--derp-server-region-id + 1`. Use special value 'disable' to turn off STUN completely. + +- Environment variable: `CODER_DERP_SERVER_STUN_ADDRESSES` +- CLI flag: [`--derp-server-stun-addresses`](../../reference/cli/server.md#--derp-server-stun-addresses) +- YAML key: `networking.derp.stunAddresses` +- Default value: `stun.l.google.com:19302,stun1.l.google.com:19302,stun2.l.google.com:19302,stun3.l.google.com:19302,stun4.l.google.com:19302` + +### HTTP + +#### Additional CSP policy + +Coder configures a Content Security Policy (CSP) to protect against XSS attacks. This setting allows you to add additional CSP directives, which can open the attack surface of the deployment. Format matches the CSP directive format, e.g. --additional-csp-policy="script-src https://example.com". + +- Environment variable: `CODER_ADDITIONAL_CSP_POLICY` +- CLI flag: [`--additional-csp-policy`](../../reference/cli/server.md#--additional-csp-policy) +- YAML key: `networking.http.additionalCSPPolicy` + +#### Disable password authentication + +Disable password authentication. This is recommended for security purposes in production deployments that rely on an identity provider. Any user with the owner role will be able to sign in with their password regardless of this setting to avoid potential lock out. If you are locked out of your account, you can use the `coder server create-admin` command to create a new admin user directly in the database. + +- Environment variable: `CODER_DISABLE_PASSWORD_AUTH` +- CLI flag: [`--disable-password-auth`](../../reference/cli/server.md#--disable-password-auth) +- YAML key: `networking.http.disablePasswordAuth` + +#### Disable session expiry refresh + +Disable automatic session expiry bumping due to activity. This forces all sessions to become invalid after the session expiry duration has been reached. + +- Environment variable: `CODER_DISABLE_SESSION_EXPIRY_REFRESH` +- CLI flag: [`--disable-session-expiry-refresh`](../../reference/cli/server.md#--disable-session-expiry-refresh) +- YAML key: `networking.http.disableSessionExpiryRefresh` + +#### Address + +HTTP bind address of the server. Unset to disable the HTTP endpoint. + +- Environment variable: `CODER_HTTP_ADDRESS` +- CLI flag: [`--http-address`](../../reference/cli/server.md#--http-address) +- YAML key: `networking.http.httpAddress` +- Default value: `127.0.0.1:3000` + +#### Max token lifetime + +The maximum lifetime duration users can specify when creating an API token. + +- Environment variable: `CODER_MAX_TOKEN_LIFETIME` +- CLI flag: [`--max-token-lifetime`](../../reference/cli/server.md#--max-token-lifetime) +- YAML key: `networking.http.maxTokenLifetime` +- Default value: `876600h0m0s` + +#### Maximum admin token lifetime + +The maximum lifetime duration administrators can specify when creating an API token. + +- Environment variable: `CODER_MAX_ADMIN_TOKEN_LIFETIME` +- CLI flag: [`--max-admin-token-lifetime`](../../reference/cli/server.md#--max-admin-token-lifetime) +- YAML key: `networking.http.maxAdminTokenLifetime` +- Default value: `168h0m0s` + +#### Proxy health check interval + +The interval in which coderd should be checking the status of workspace proxies. + +- Environment variable: `CODER_PROXY_HEALTH_INTERVAL` +- CLI flag: [`--proxy-health-interval`](../../reference/cli/server.md#--proxy-health-interval) +- YAML key: `networking.http.proxyHealthInterval` +- Default value: `1m0s` + +#### Session duration + +The token expiry duration for browser sessions. Sessions may last longer if they are actively making requests, but this functionality can be disabled via --disable-session-expiry-refresh. + +- Environment variable: `CODER_SESSION_DURATION` +- CLI flag: [`--session-duration`](../../reference/cli/server.md#--session-duration) +- YAML key: `networking.http.sessionDuration` +- Default value: `24h0m0s` + +### TLS + +Configure TLS / HTTPS for your Coder deployment. If you're running Coder behind a TLS-terminating reverse proxy or are accessing Coder over a secure link, you can safely ignore these settings. + +#### Strict-Transport-Security + +Controls if the 'Strict-Transport-Security' header is set on all static file responses. This header should only be set if the server is accessed via HTTPS. This value is the MaxAge in seconds of the header. + +- Environment variable: `CODER_STRICT_TRANSPORT_SECURITY` +- CLI flag: [`--strict-transport-security`](../../reference/cli/server.md#--strict-transport-security) +- YAML key: `networking.tls.strictTransportSecurity` +- Default value: `0` + +#### Strict-Transport-Security options + +Two optional fields can be set in the Strict-Transport-Security header; 'includeSubDomains' and 'preload'. The 'strict-transport-security' flag must be set to a non-zero value for these options to be used. + +- Environment variable: `CODER_STRICT_TRANSPORT_SECURITY_OPTIONS` +- CLI flag: [`--strict-transport-security-options`](../../reference/cli/server.md#--strict-transport-security-options) +- YAML key: `networking.tls.strictTransportSecurityOptions` + +#### Address + +HTTPS bind address of the server. + +- Environment variable: `CODER_TLS_ADDRESS` +- CLI flag: [`--tls-address`](../../reference/cli/server.md#--tls-address) +- YAML key: `networking.tls.address` +- Default value: `127.0.0.1:3443` + +#### Allow insecure ciphers + +By default, only ciphers marked as 'secure' are allowed to be used. See https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L82-L95. + +- Environment variable: `CODER_TLS_ALLOW_INSECURE_CIPHERS` +- CLI flag: [`--tls-allow-insecure-ciphers`](../../reference/cli/server.md#--tls-allow-insecure-ciphers) +- YAML key: `networking.tls.tlsAllowInsecureCiphers` +- Default value: `false` + +#### Certificate files + +Path to each certificate for TLS. It requires a PEM-encoded file. To configure the listener to use a CA certificate, concatenate the primary certificate and the CA certificate together. The primary certificate should appear first in the combined file. + +- Environment variable: `CODER_TLS_CERT_FILE` +- CLI flag: [`--tls-cert-file`](../../reference/cli/server.md#--tls-cert-file) +- YAML key: `networking.tls.certFiles` + +#### Ciphers + +Specify specific TLS ciphers that allowed to be used. See https://github.com/golang/go/blob/master/src/crypto/tls/cipher_suites.go#L53-L75. + +- Environment variable: `CODER_TLS_CIPHERS` +- CLI flag: [`--tls-ciphers`](../../reference/cli/server.md#--tls-ciphers) +- YAML key: `networking.tls.tlsCiphers` + +#### Client auth + +Policy the server will follow for TLS Client Authentication. Accepted values are "none", "request", "require-any", "verify-if-given", or "require-and-verify". + +- Environment variable: `CODER_TLS_CLIENT_AUTH` +- CLI flag: [`--tls-client-auth`](../../reference/cli/server.md#--tls-client-auth) +- YAML key: `networking.tls.clientAuth` +- Default value: `none` + +#### Client CA files + +PEM-encoded Certificate Authority file used for checking the authenticity of client. + +- Environment variable: `CODER_TLS_CLIENT_CA_FILE` +- CLI flag: [`--tls-client-ca-file`](../../reference/cli/server.md#--tls-client-ca-file) +- YAML key: `networking.tls.clientCAFile` + +#### Client cert file + +Path to certificate for client TLS authentication. It requires a PEM-encoded file. + +- Environment variable: `CODER_TLS_CLIENT_CERT_FILE` +- CLI flag: [`--tls-client-cert-file`](../../reference/cli/server.md#--tls-client-cert-file) +- YAML key: `networking.tls.clientCertFile` + +#### Client key file + +Path to key for client TLS authentication. It requires a PEM-encoded file. + +- Environment variable: `CODER_TLS_CLIENT_KEY_FILE` +- CLI flag: [`--tls-client-key-file`](../../reference/cli/server.md#--tls-client-key-file) +- YAML key: `networking.tls.clientKeyFile` + +#### Enable + +Whether TLS will be enabled. + +- Environment variable: `CODER_TLS_ENABLE` +- CLI flag: [`--tls-enable`](../../reference/cli/server.md#--tls-enable) +- YAML key: `networking.tls.enable` + +#### Key files + +Paths to the private keys for each of the certificates. It requires a PEM-encoded file. + +- Environment variable: `CODER_TLS_KEY_FILE` +- CLI flag: [`--tls-key-file`](../../reference/cli/server.md#--tls-key-file) +- YAML key: `networking.tls.keyFiles` + +#### Minimum version + +Minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13". + +- Environment variable: `CODER_TLS_MIN_VERSION` +- CLI flag: [`--tls-min-version`](../../reference/cli/server.md#--tls-min-version) +- YAML key: `networking.tls.minVersion` +- Default value: `tls12` + +## Notifications + +Configure how notifications are processed and delivered. + +### Dispatch timeout + +How long to wait while a notification is being sent before giving up. + +- Environment variable: `CODER_NOTIFICATIONS_DISPATCH_TIMEOUT` +- CLI flag: [`--notifications-dispatch-timeout`](../../reference/cli/server.md#--notifications-dispatch-timeout) +- YAML key: `notifications.dispatchTimeout` +- Default value: `1m0s` + +### Max send attempts + +The upper limit of attempts to send a notification. + +- Environment variable: `CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS` +- CLI flag: [`--notifications-max-send-attempts`](../../reference/cli/server.md#--notifications-max-send-attempts) +- YAML key: `notifications.maxSendAttempts` +- Default value: `5` + +### Method + +Which delivery method to use (available options: 'smtp', 'webhook'). + +- Environment variable: `CODER_NOTIFICATIONS_METHOD` +- CLI flag: [`--notifications-method`](../../reference/cli/server.md#--notifications-method) +- YAML key: `notifications.method` +- Default value: `smtp` + +### Email + +Configure how email notifications are sent. + +#### Force TLS + +**Deprecated.** Force a TLS connection to the configured SMTP smarthost. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_FORCE_TLS` +- CLI flag: [`--notifications-email-force-tls`](../../reference/cli/server.md#--notifications-email-force-tls) +- YAML key: `notifications.email.forceTLS` + +#### From address + +**Deprecated.** The sender's address to use. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_FROM` +- CLI flag: [`--notifications-email-from`](../../reference/cli/server.md#--notifications-email-from) +- YAML key: `notifications.email.from` + +#### Hello + +**Deprecated.** The hostname identifying the SMTP server. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_HELLO` +- CLI flag: [`--notifications-email-hello`](../../reference/cli/server.md#--notifications-email-hello) +- YAML key: `notifications.email.hello` + +#### Smarthost + +**Deprecated.** The intermediary SMTP host through which emails are sent. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_SMARTHOST` +- CLI flag: [`--notifications-email-smarthost`](../../reference/cli/server.md#--notifications-email-smarthost) +- YAML key: `notifications.email.smarthost` + +#### Email authentication + +Configure SMTP authentication options. + +##### Identity + +**Deprecated.** Identity to use with PLAIN authentication. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_AUTH_IDENTITY` +- CLI flag: [`--notifications-email-auth-identity`](../../reference/cli/server.md#--notifications-email-auth-identity) +- YAML key: `notifications.email.emailAuth.identity` + +##### Password + +**Deprecated.** Password to use with PLAIN/LOGIN authentication. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD` +- CLI flag: [`--notifications-email-auth-password`](../../reference/cli/server.md#--notifications-email-auth-password) + +##### Password file + +**Deprecated.** File from which to load password for use with PLAIN/LOGIN authentication. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD_FILE` +- CLI flag: [`--notifications-email-auth-password-file`](../../reference/cli/server.md#--notifications-email-auth-password-file) +- YAML key: `notifications.email.emailAuth.passwordFile` + +##### Username + +**Deprecated.** Username to use with PLAIN/LOGIN authentication. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_AUTH_USERNAME` +- CLI flag: [`--notifications-email-auth-username`](../../reference/cli/server.md#--notifications-email-auth-username) +- YAML key: `notifications.email.emailAuth.username` + +#### Email TLS + +Configure TLS for your SMTP server target. + +##### Certificate authority file + +**Deprecated.** CA certificate file to use. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_TLS_CACERTFILE` +- CLI flag: [`--notifications-email-tls-ca-cert-file`](../../reference/cli/server.md#--notifications-email-tls-ca-cert-file) +- YAML key: `notifications.email.emailTLS.caCertFile` + +##### Certificate file + +**Deprecated.** Certificate file to use. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_TLS_CERTFILE` +- CLI flag: [`--notifications-email-tls-cert-file`](../../reference/cli/server.md#--notifications-email-tls-cert-file) +- YAML key: `notifications.email.emailTLS.certFile` + +##### Certificate key file + +**Deprecated.** Certificate key file to use. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_TLS_CERTKEYFILE` +- CLI flag: [`--notifications-email-tls-cert-key-file`](../../reference/cli/server.md#--notifications-email-tls-cert-key-file) +- YAML key: `notifications.email.emailTLS.certKeyFile` + +##### Server name + +**Deprecated.** Server name to verify against the target certificate. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_TLS_SERVERNAME` +- CLI flag: [`--notifications-email-tls-server-name`](../../reference/cli/server.md#--notifications-email-tls-server-name) +- YAML key: `notifications.email.emailTLS.serverName` + +##### Skip certificate verification (insecure) + +**Deprecated.** Skip verification of the target server's certificate (insecure). + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_TLS_SKIPVERIFY` +- CLI flag: [`--notifications-email-tls-skip-verify`](../../reference/cli/server.md#--notifications-email-tls-skip-verify) +- YAML key: `notifications.email.emailTLS.insecureSkipVerify` + +##### StartTLS + +**Deprecated.** Enable STARTTLS to upgrade insecure SMTP connections using TLS. + +- Environment variable: `CODER_NOTIFICATIONS_EMAIL_TLS_STARTTLS` +- CLI flag: [`--notifications-email-tls-starttls`](../../reference/cli/server.md#--notifications-email-tls-starttls) +- YAML key: `notifications.email.emailTLS.startTLS` + +### Inbox + +#### Enabled + +Enable Coder Inbox. + +- Environment variable: `CODER_NOTIFICATIONS_INBOX_ENABLED` +- CLI flag: [`--notifications-inbox-enabled`](../../reference/cli/server.md#--notifications-inbox-enabled) +- YAML key: `notifications.inbox.enabled` +- Default value: `true` + +### Webhook + +#### Endpoint + +The endpoint to which to send webhooks. + +- Environment variable: `CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT` +- CLI flag: [`--notifications-webhook-endpoint`](../../reference/cli/server.md#--notifications-webhook-endpoint) +- YAML key: `notifications.webhook.endpoint` + +## OAuth2 + +Configure login and user-provisioning with GitHub via oAuth2. + +### GitHub + +#### Allow everyone + +Allow all logins, setting this option means allowed orgs and teams must be empty. + +- Environment variable: `CODER_OAUTH2_GITHUB_ALLOW_EVERYONE` +- CLI flag: [`--oauth2-github-allow-everyone`](../../reference/cli/server.md#--oauth2-github-allow-everyone) +- YAML key: `oauth2.github.allowEveryone` + +#### Allow signups + +Whether new users can sign up with GitHub. + +- Environment variable: `CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS` +- CLI flag: [`--oauth2-github-allow-signups`](../../reference/cli/server.md#--oauth2-github-allow-signups) +- YAML key: `oauth2.github.allowSignups` + +#### Allowed orgs + +Organizations the user must be a member of to Login with GitHub. + +- Environment variable: `CODER_OAUTH2_GITHUB_ALLOWED_ORGS` +- CLI flag: [`--oauth2-github-allowed-orgs`](../../reference/cli/server.md#--oauth2-github-allowed-orgs) +- YAML key: `oauth2.github.allowedOrgs` + +#### Allowed teams + +Teams inside organizations the user must be a member of to Login with GitHub. Structured as: `<organization-name>/<team-slug>`. + +- Environment variable: `CODER_OAUTH2_GITHUB_ALLOWED_TEAMS` +- CLI flag: [`--oauth2-github-allowed-teams`](../../reference/cli/server.md#--oauth2-github-allowed-teams) +- YAML key: `oauth2.github.allowedTeams` + +#### Client ID + +Client ID for Login with GitHub. + +- Environment variable: `CODER_OAUTH2_GITHUB_CLIENT_ID` +- CLI flag: [`--oauth2-github-client-id`](../../reference/cli/server.md#--oauth2-github-client-id) +- YAML key: `oauth2.github.clientID` + +#### Client secret + +Client secret for Login with GitHub. + +- Environment variable: `CODER_OAUTH2_GITHUB_CLIENT_SECRET` +- CLI flag: [`--oauth2-github-client-secret`](../../reference/cli/server.md#--oauth2-github-client-secret) + +#### Default provider enable + +Enable the default GitHub OAuth2 provider managed by Coder. + +- Environment variable: `CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE` +- CLI flag: [`--oauth2-github-default-provider-enable`](../../reference/cli/server.md#--oauth2-github-default-provider-enable) +- YAML key: `oauth2.github.defaultProviderEnable` +- Default value: `true` + +#### Device flow + +Enable device flow for Login with GitHub. + +- Environment variable: `CODER_OAUTH2_GITHUB_DEVICE_FLOW` +- CLI flag: [`--oauth2-github-device-flow`](../../reference/cli/server.md#--oauth2-github-device-flow) +- YAML key: `oauth2.github.deviceFlow` +- Default value: `false` + +#### Enterprise base URL + +Base URL of a GitHub Enterprise deployment to use for Login with GitHub. + +- Environment variable: `CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL` +- CLI flag: [`--oauth2-github-enterprise-base-url`](../../reference/cli/server.md#--oauth2-github-enterprise-base-url) +- YAML key: `oauth2.github.enterpriseBaseURL` + +## OIDC + +### Enable OIDC group auto create + +Automatically creates missing groups from a user's groups claim. + +- Environment variable: `CODER_OIDC_GROUP_AUTO_CREATE` +- CLI flag: [`--oidc-group-auto-create`](../../reference/cli/server.md#--oidc-group-auto-create) +- YAML key: `oidc.enableGroupAutoCreate` +- Default value: `false` + +### Allow signups + +Whether new users can sign up with OIDC. + +- Environment variable: `CODER_OIDC_ALLOW_SIGNUPS` +- CLI flag: [`--oidc-allow-signups`](../../reference/cli/server.md#--oidc-allow-signups) +- YAML key: `oidc.allowSignups` +- Default value: `true` + +### Allowed groups + +If provided any group name not in the list will not be allowed to authenticate. This allows for restricting access to a specific set of groups. This filter is applied after the group mapping and before the regex filter. + +- Environment variable: `CODER_OIDC_ALLOWED_GROUPS` +- CLI flag: [`--oidc-allowed-groups`](../../reference/cli/server.md#--oidc-allowed-groups) +- YAML key: `oidc.groupAllowed` + +### Auth URL parameters + +OIDC auth URL parameters to pass to the upstream provider. + +- Environment variable: `CODER_OIDC_AUTH_URL_PARAMS` +- CLI flag: [`--oidc-auth-url-params`](../../reference/cli/server.md#--oidc-auth-url-params) +- YAML key: `oidc.authURLParams` +- Default value: `{"access_type": "offline"}` + +### Client cert file + +Pem encoded certificate file to use for oauth2 PKI/JWT authorization. The public certificate that accompanies oidc-client-key-file. A standard x509 certificate is expected. + +- Environment variable: `CODER_OIDC_CLIENT_CERT_FILE` +- CLI flag: [`--oidc-client-cert-file`](../../reference/cli/server.md#--oidc-client-cert-file) +- YAML key: `oidc.oidcClientCertFile` + +### Client ID + +Client ID to use for Login with OIDC. + +- Environment variable: `CODER_OIDC_CLIENT_ID` +- CLI flag: [`--oidc-client-id`](../../reference/cli/server.md#--oidc-client-id) +- YAML key: `oidc.clientID` + +### Client key file + +Pem encoded RSA private key to use for oauth2 PKI/JWT authorization. This can be used instead of oidc-client-secret if your IDP supports it. + +- Environment variable: `CODER_OIDC_CLIENT_KEY_FILE` +- CLI flag: [`--oidc-client-key-file`](../../reference/cli/server.md#--oidc-client-key-file) +- YAML key: `oidc.oidcClientKeyFile` + +### Client secret + +Client secret to use for Login with OIDC. + +- Environment variable: `CODER_OIDC_CLIENT_SECRET` +- CLI flag: [`--oidc-client-secret`](../../reference/cli/server.md#--oidc-client-secret) + +### Email domain + +Email domains that clients logging in with OIDC must match. + +- Environment variable: `CODER_OIDC_EMAIL_DOMAIN` +- CLI flag: [`--oidc-email-domain`](../../reference/cli/server.md#--oidc-email-domain) +- YAML key: `oidc.emailDomain` + +### Email field + +OIDC claim field to use as the email. + +- Environment variable: `CODER_OIDC_EMAIL_FIELD` +- CLI flag: [`--oidc-email-field`](../../reference/cli/server.md#--oidc-email-field) +- YAML key: `oidc.emailField` +- Default value: `email` + +### Group field + +This field must be set if using the group sync feature and the scope name is not 'groups'. Set to the claim to be used for groups. + +- Environment variable: `CODER_OIDC_GROUP_FIELD` +- CLI flag: [`--oidc-group-field`](../../reference/cli/server.md#--oidc-group-field) +- YAML key: `oidc.groupField` + +### Group mapping + +A map of OIDC group IDs and the group in Coder it should map to. This is useful for when OIDC providers only return group IDs. + +- Environment variable: `CODER_OIDC_GROUP_MAPPING` +- CLI flag: [`--oidc-group-mapping`](../../reference/cli/server.md#--oidc-group-mapping) +- YAML key: `oidc.groupMapping` +- Default value: `{}` + +### Ignore email verified + +Ignore the email_verified claim from the upstream provider. + +- Environment variable: `CODER_OIDC_IGNORE_EMAIL_VERIFIED` +- CLI flag: [`--oidc-ignore-email-verified`](../../reference/cli/server.md#--oidc-ignore-email-verified) +- YAML key: `oidc.ignoreEmailVerified` + +### Ignore UserInfo + +Ignore the userinfo endpoint and only use the ID token for user information. + +- Environment variable: `CODER_OIDC_IGNORE_USERINFO` +- CLI flag: [`--oidc-ignore-userinfo`](../../reference/cli/server.md#--oidc-ignore-userinfo) +- YAML key: `oidc.ignoreUserInfo` +- Default value: `false` + +### Issuer URL + +Issuer URL to use for Login with OIDC. + +- Environment variable: `CODER_OIDC_ISSUER_URL` +- CLI flag: [`--oidc-issuer-url`](../../reference/cli/server.md#--oidc-issuer-url) +- YAML key: `oidc.issuerURL` + +### Name field + +OIDC claim field to use as the name. + +- Environment variable: `CODER_OIDC_NAME_FIELD` +- CLI flag: [`--oidc-name-field`](../../reference/cli/server.md#--oidc-name-field) +- YAML key: `oidc.nameField` +- Default value: `name` + +### Regex group filter + +If provided any group name not matching the regex is ignored. This allows for filtering out groups that are not needed. This filter is applied after the group mapping. + +- Environment variable: `CODER_OIDC_GROUP_REGEX_FILTER` +- CLI flag: [`--oidc-group-regex-filter`](../../reference/cli/server.md#--oidc-group-regex-filter) +- YAML key: `oidc.groupRegexFilter` +- Default value: `.*` + +### Scopes + +Scopes to grant when authenticating with OIDC. + +- Environment variable: `CODER_OIDC_SCOPES` +- CLI flag: [`--oidc-scopes`](../../reference/cli/server.md#--oidc-scopes) +- YAML key: `oidc.scopes` +- Default value: `openid,profile,email` + +### User role default + +If user role sync is enabled, these roles are always included for all authenticated users. The 'member' role is always assigned. + +- Environment variable: `CODER_OIDC_USER_ROLE_DEFAULT` +- CLI flag: [`--oidc-user-role-default`](../../reference/cli/server.md#--oidc-user-role-default) +- YAML key: `oidc.userRoleDefault` + +### User role field + +This field must be set if using the user roles sync feature. Set this to the name of the claim used to store the user's role. The roles should be sent as an array of strings. + +- Environment variable: `CODER_OIDC_USER_ROLE_FIELD` +- CLI flag: [`--oidc-user-role-field`](../../reference/cli/server.md#--oidc-user-role-field) +- YAML key: `oidc.userRoleField` + +### User role mapping + +A map of the OIDC passed in user roles and the groups in Coder it should map to. This is useful if the group names do not match. If mapped to the empty string, the role will ignored. + +- Environment variable: `CODER_OIDC_USER_ROLE_MAPPING` +- CLI flag: [`--oidc-user-role-mapping`](../../reference/cli/server.md#--oidc-user-role-mapping) +- YAML key: `oidc.userRoleMapping` +- Default value: `{}` + +### Username field + +OIDC claim field to use as the username. + +- Environment variable: `CODER_OIDC_USERNAME_FIELD` +- CLI flag: [`--oidc-username-field`](../../reference/cli/server.md#--oidc-username-field) +- YAML key: `oidc.usernameField` +- Default value: `preferred_username` + +### OpenID Connect sign in text + +The text to show on the OpenID Connect sign in button. + +- Environment variable: `CODER_OIDC_SIGN_IN_TEXT` +- CLI flag: [`--oidc-sign-in-text`](../../reference/cli/server.md#--oidc-sign-in-text) +- YAML key: `oidc.signInText` +- Default value: `OpenID Connect` + +### OpenID Connect icon URL + +URL pointing to the icon to use on the OpenID Connect login button. + +- Environment variable: `CODER_OIDC_ICON_URL` +- CLI flag: [`--oidc-icon-url`](../../reference/cli/server.md#--oidc-icon-url) +- YAML key: `oidc.iconURL` + +### Signups disabled text + +The custom text to show on the error page informing about disabled OIDC signups. Markdown format is supported. + +- Environment variable: `CODER_OIDC_SIGNUPS_DISABLED_TEXT` +- CLI flag: [`--oidc-signups-disabled-text`](../../reference/cli/server.md#--oidc-signups-disabled-text) +- YAML key: `oidc.signupsDisabledText` + +### Skip OIDC issuer checks (not recommended) + +OIDC issuer urls must match in the request, the id_token 'iss' claim, and in the well-known configuration. This flag disables that requirement, and can lead to an insecure OIDC configuration. It is not recommended to use this flag. + +- Environment variable: `CODER_DANGEROUS_OIDC_SKIP_ISSUER_CHECKS` +- CLI flag: [`--dangerous-oidc-skip-issuer-checks`](../../reference/cli/server.md#--dangerous-oidc-skip-issuer-checks) +- YAML key: `oidc.dangerousSkipIssuerChecks` + +## Provisioning + +Tune the behavior of the provisioner, which is responsible for creating, updating, and deleting workspace resources. + +### Force cancel interval + +Time to force cancel provisioning tasks that are stuck. + +- Environment variable: `CODER_PROVISIONER_FORCE_CANCEL_INTERVAL` +- CLI flag: [`--provisioner-force-cancel-interval`](../../reference/cli/server.md#--provisioner-force-cancel-interval) +- YAML key: `provisioning.forceCancelInterval` +- Default value: `10m0s` + +### Provisioner daemon pre-shared key (PSK) + +Pre-shared key to authenticate external provisioner daemons to Coder server. + +- Environment variable: `CODER_PROVISIONER_DAEMON_PSK` +- CLI flag: [`--provisioner-daemon-psk`](../../reference/cli/server.md#--provisioner-daemon-psk) + +### Provisioner daemons + +Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this. + +- Environment variable: `CODER_PROVISIONER_DAEMONS` +- CLI flag: [`--provisioner-daemons`](../../reference/cli/server.md#--provisioner-daemons) +- YAML key: `provisioning.daemons` +- Default value: `3` + +### Poll interval + +**Deprecated** and ignored. + +- Environment variable: `CODER_PROVISIONER_DAEMON_POLL_INTERVAL` +- CLI flag: [`--provisioner-daemon-poll-interval`](../../reference/cli/server.md#--provisioner-daemon-poll-interval) +- YAML key: `provisioning.daemonPollInterval` +- Default value: `1s` + +### Poll jitter + +**Deprecated** and ignored. + +- Environment variable: `CODER_PROVISIONER_DAEMON_POLL_JITTER` +- CLI flag: [`--provisioner-daemon-poll-jitter`](../../reference/cli/server.md#--provisioner-daemon-poll-jitter) +- YAML key: `provisioning.daemonPollJitter` +- Default value: `100ms` + +## Retention + +Configure data retention policies for various database tables. Retention policies automatically purge old data to reduce database size and improve performance. Setting a retention duration to 0 disables automatic purging for that data type. + +### API keys retention + +How long expired API keys are retained before being deleted. Keeping expired keys allows the backend to return a more helpful error when a user tries to use an expired key. Set to 0 to disable automatic deletion of expired keys. + +- Environment variable: `CODER_API_KEYS_RETENTION` +- CLI flag: [`--api-keys-retention`](../../reference/cli/server.md#--api-keys-retention) +- YAML key: `retention.api_keys` +- Default value: `7d` + +### Audit logs retention + +How long audit log entries are retained. Set to 0 to disable (keep indefinitely). We advise keeping audit logs for at least a year, and in accordance with your compliance requirements. + +- Environment variable: `CODER_AUDIT_LOGS_RETENTION` +- CLI flag: [`--audit-logs-retention`](../../reference/cli/server.md#--audit-logs-retention) +- YAML key: `retention.audit_logs` +- Default value: `0` + +### Boundary log retention + +How long boundary audit log entries are retained. Boundary logs record HTTP requests processed by a Boundary confinement proxy. Set to 0 to disable automatic deletion (keep indefinitely). Adjust to match your organization's regulatory requirements. + +- Environment variable: `CODER_BOUNDARY_LOG_RETENTION` +- CLI flag: [`--boundary-log-retention`](../../reference/cli/server.md#--boundary-log-retention) +- YAML key: `retention.boundary_logs` +- Default value: `0` + +### Connection logs retention + +How long connection log entries are retained. Set to 0 to disable (keep indefinitely). + +- Environment variable: `CODER_CONNECTION_LOGS_RETENTION` +- CLI flag: [`--connection-logs-retention`](../../reference/cli/server.md#--connection-logs-retention) +- YAML key: `retention.connection_logs` +- Default value: `0` + +### Workspace agent logs retention + +How long workspace agent logs are retained. Logs from non-latest builds are deleted if the agent hasn't connected within this period. Logs from the latest build are always retained. Set to 0 to disable automatic deletion. + +- Environment variable: `CODER_WORKSPACE_AGENT_LOGS_RETENTION` +- CLI flag: [`--workspace-agent-logs-retention`](../../reference/cli/server.md#--workspace-agent-logs-retention) +- YAML key: `retention.workspace_agent_logs` +- Default value: `7d` + +## Telemetry + +Telemetry is critical to our ability to improve Coder. We strip all personal information before sending data to our servers. Please only disable telemetry when required by your organization's security policy. + +### Enable + +Whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product. + +- Environment variable: `CODER_TELEMETRY_ENABLE` +- CLI flag: [`--telemetry`](../../reference/cli/server.md#--telemetry) +- YAML key: `telemetry.enable` +- Default value: `true` + +## Template Builder + +### Disable Template Builder + +Disable the template builder feature for guided template creation. When disabled, all /api/v2/templatebuilder/* endpoints return 404. + +- Environment variable: `CODER_DISABLE_TEMPLATE_BUILDER` +- CLI flag: [`--disable-template-builder`](../../reference/cli/server.md#--disable-template-builder) +- YAML key: `templateBuilder.disabled` + +### Registry URL + +The base URL of the module registry used by the template builder for module source paths. + +- Environment variable: `CODER_TEMPLATE_BUILDER_REGISTRY_URL` +- CLI flag: [`--template-builder-registry-url`](../../reference/cli/server.md#--template-builder-registry-url) +- YAML key: `templateBuilder.registryURL` +- Default value: `registry.coder.com` + +## User quiet hours schedule + +Allow users to set quiet hours schedules each day for workspaces to avoid workspaces stopping during the day due to template scheduling. + +### Allow custom quiet hours + +Allow users to set their own quiet hours schedule for workspaces to stop in (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used. + +- Environment variable: `CODER_ALLOW_CUSTOM_QUIET_HOURS` +- CLI flag: [`--allow-custom-quiet-hours`](../../reference/cli/server.md#--allow-custom-quiet-hours) +- YAML key: `userQuietHoursSchedule.allowCustomQuietHours` +- Default value: `true` + +### Default quiet hours schedule + +The default daily cron schedule applied to users that haven't set a custom quiet hours schedule themselves. The quiet hours schedule determines when workspaces will be force stopped due to the template's autostop requirement, and will round the max deadline up to be within the user's quiet hours window (or default). The format is the same as the standard cron format, but the day-of-month, month and day-of-week must be *. Only one hour and minute can be specified (ranges or comma separated values are not supported). + +- Environment variable: `CODER_QUIET_HOURS_DEFAULT_SCHEDULE` +- CLI flag: [`--default-quiet-hours-schedule`](../../reference/cli/server.md#--default-quiet-hours-schedule) +- YAML key: `userQuietHoursSchedule.defaultQuietHoursSchedule` +- Default value: `CRON_TZ=UTC 0 0 * * *` + +## Workspace prebuilds + +Configure how workspace prebuilds behave. + +### Reconciliation interval + +How often to reconcile workspace prebuilds state. + +- Environment variable: `CODER_WORKSPACE_PREBUILDS_RECONCILIATION_INTERVAL` +- CLI flag: [`--workspace-prebuilds-reconciliation-interval`](../../reference/cli/server.md#--workspace-prebuilds-reconciliation-interval) +- YAML key: `workspace_prebuilds.reconciliation_interval` +- Default value: `1m0s` + +## ⚠️ Dangerous + +### Allow path app sharing + +Allow workspace apps that are not served from subdomains to be shared. Path-based app sharing is DISABLED by default for security purposes. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. Path-based apps can be disabled entirely with --disable-path-apps for further security. + +- Environment variable: `CODER_DANGEROUS_ALLOW_PATH_APP_SHARING` +- CLI flag: [`--dangerous-allow-path-app-sharing`](../../reference/cli/server.md#--dangerous-allow-path-app-sharing) + +### Allow site owners to access path apps + +Allow site-owners to access workspace apps from workspaces they do not own. Owners cannot access path-based apps they do not own by default. Path-based apps can make requests to the Coder API and pose a security risk when the workspace serves malicious JavaScript. Path-based apps can be disabled entirely with --disable-path-apps for further security. + +- Environment variable: `CODER_DANGEROUS_ALLOW_PATH_APP_SITE_OWNER_ACCESS` +- CLI flag: [`--dangerous-allow-path-app-site-owner-access`](../../reference/cli/server.md#--dangerous-allow-path-app-site-owner-access) diff --git a/docs/admin/setup/index.md b/docs/admin/setup/index.md index 55419a16614eb..a2c4fe776d39c 100644 --- a/docs/admin/setup/index.md +++ b/docs/admin/setup/index.md @@ -4,6 +4,11 @@ Coder server's primary configuration is done via environment variables. For a full list of the options, run `coder server --help` or see our [CLI documentation](../../reference/cli/server.md). +> [!TIP] +> Need to look up an exact environment variable, CLI flag, or YAML key for a +> setting? See the [configuration reference](./configuration-reference.md) for +> a searchable list of every option. + ## Access URL `CODER_ACCESS_URL` is required if you are not using the tunnel. Set this to the diff --git a/docs/admin/templates/extending-templates/variables.md b/docs/admin/templates/extending-templates/variables.md index 4fa89d40aebbf..13a9793279ed3 100644 --- a/docs/admin/templates/extending-templates/variables.md +++ b/docs/admin/templates/extending-templates/variables.md @@ -51,7 +51,7 @@ and predictability. If you encounter a situation where you need to override template settings for variables, you can employ a straightforward solution: -1. Create a `terraform.tfvars` file in in the template directory: +1. Create a `terraform.tfvars` file in the template directory: ```tf coder_image = newimage:tag diff --git a/docs/admin/users/github-auth.md b/docs/admin/users/github-auth.md index 4d07abb1e2e18..8735f1100807c 100644 --- a/docs/admin/users/github-auth.md +++ b/docs/admin/users/github-auth.md @@ -85,37 +85,13 @@ CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE=false ## Step 2: Configure Coder with the OAuth credentials -Go to your Coder host and run the following command to start up the Coder server: +Coder server reads these settings from environment variables. Set them +wherever your deployment manages environment variables. For example, use +Helm `values.yaml` for Kubernetes or `/etc/coder.d/coder.env` for a system +service. -```sh -coder server --oauth2-github-allow-signups=true --oauth2-github-allowed-orgs="your-org" --oauth2-github-client-id="8d1...e05" --oauth2-github-client-secret="57ebc9...02c24c" -``` - -> [!NOTE] -> For GitHub Enterprise support, specify the `--oauth2-github-enterprise-base-url` flag. - -Alternatively, if you are running Coder as a system service, you can achieve the -same result as the command above by adding the following environment variables -to the `/etc/coder.d/coder.env` file: - -```sh -CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true -CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org" -CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05" -CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c" -``` - -> [!TIP] -> To allow everyone to sign up using GitHub, set: -> -> ```shell -> CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true -> ``` - -Once complete, run `sudo service coder restart` to reboot Coder. - -If deploying Coder via Helm, you can set the above environment variables in the -`values.yaml` file as such: +**Kubernetes (Helm):** set the variables under `coder.env` in your +`values.yaml`: ```yaml coder: @@ -129,17 +105,48 @@ coder: # If setting allowed orgs, comment out CODER_OAUTH2_GITHUB_ALLOW_EVERYONE and its value - name: CODER_OAUTH2_GITHUB_ALLOWED_ORGS value: "your-org" - # If allowing everyone, comment out CODER_OAUTH2_GITHUB_ALLOWED_ORGS and it's value + # If allowing everyone, comment out CODER_OAUTH2_GITHUB_ALLOWED_ORGS and its value #- name: CODER_OAUTH2_GITHUB_ALLOW_EVERYONE # value: "true" ``` -To upgrade Coder, run: +Then apply the change with `helm upgrade`: ```sh helm upgrade <release-name> coder-v2/coder -n <namespace> -f values.yaml ``` +**System service:** add the variables to `/etc/coder.d/coder.env`: + +```sh +CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true +CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org" +CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05" +CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c" +``` + +Then restart Coder with `sudo service coder restart`. + +> [!TIP] +> To allow everyone to sign up using GitHub, set: +> +> ```shell +> CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true +> ``` + +For GitHub Enterprise support, also set +`CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL`. + +> [!NOTE] +> Every option above also has an equivalent CLI flag (for example, +> `CODER_OAUTH2_GITHUB_CLIENT_ID` becomes `--oauth2-github-client-id`). +> CLI flags are convenient for ad-hoc invocations of `coder server` during +> local development. For production deployments, prefer environment +> variables so the configuration lives with the container, Helm chart, or +> service unit that manages Coder. See the +> [configuration reference](../setup/configuration-reference.md) for the +> full mapping between environment variables and flags. + We recommend requiring and auditing MFA usage for all users in your GitHub organizations. This can be enforced from the organization settings page in the **Authentication security** sidebar tab. diff --git a/docs/admin/users/idp-sync.md b/docs/admin/users/idp-sync.md index a5fe3da316983..5f653ad95f60b 100644 --- a/docs/admin/users/idp-sync.md +++ b/docs/admin/users/idp-sync.md @@ -241,7 +241,7 @@ role sync at the organization level. 1. Confirm you have the [Coder CLI](../../install/index.md) installed and are logged in with a user who is an Owner or has an Organization Admin role. -1. To fetch the current group sync settings for an organization, run the +1. To fetch the current role sync settings for an organization, run the following: ```sh @@ -388,7 +388,7 @@ settings, a user's memberships will update when they log out and log back in. "cbdcf774-4123-4118-8cd9-b3f502c84dfb" ], "sales": [ - "d79144d9-b30a-555a-9af8-7dac83b2q4ec", + "d79144d9-b30a-555a-9af8-7dac83b2q4ec" ] }, "organization_assign_default": true diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index 0a38fa8710abc..a5bd35a0586a6 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -230,10 +230,11 @@ You can use the Coder CLI or API to retrieve your list of users. ### CLI -Use `users list` to export the list of users to a CSV file: +Use `users list` with `jq` to export the list of users to a CSV file: ```sh -coder users list > users.csv +coder users list --output json | \ + jq -r '["username","email","created_at","status"], (.[] | [.username, .email, .created_at, .status]) | @csv' > users.csv ``` Visit the [users list](../../reference/cli/users_list.md) documentation for more options. diff --git a/docs/admin/users/sessions-tokens.md b/docs/admin/users/sessions-tokens.md index 8d31426694880..f07e4e44746f5 100644 --- a/docs/admin/users/sessions-tokens.md +++ b/docs/admin/users/sessions-tokens.md @@ -119,6 +119,8 @@ To hard-delete a token, use the `--delete` flag: coder tokens remove --delete <name|id> ``` +Deleting the user that owns a token revokes every token that user holds at the same time. + ## API Key Scopes API key scopes allow you to limit the permissions of a token to specific operations. By default, tokens are created with the `all` scope, granting full access to all actions the user can perform. For improved security, you can create tokens with limited scopes that restrict access to only the operations needed. diff --git a/docs/ai-coder/agents/platform-controls/index.md b/docs/ai-coder/agents/platform-controls/index.md index d8b7469c47b43..2465624963e81 100644 --- a/docs/ai-coder/agents/platform-controls/index.md +++ b/docs/ai-coder/agents/platform-controls/index.md @@ -116,12 +116,14 @@ none, if the template does not define any). ### Spend management -Administrators can set spend limits to cap LLM usage per user within a rolling -time period, with per-user and per-group overrides. The cost tracking dashboard -provides visibility into per-user spending, token consumption, and per-model -breakdowns. - -See [Spend Management](./usage-insights.md) for details. +The Spend page provides usage-only reporting for Coder Agents chats. As of +release 2.36, native chat usage limits are no longer configurable or enforced. +Values configured before upgrading remain stored, have no effect, and are not +migrated to AI Gateway budgets. AI Gateway budgets are the only way to limit +spend. + +Configure new AI Gateway budgets from group settings or through the generated +group budget API. See [Spend Management](./usage-insights.md) for details. ### Git providers diff --git a/docs/ai-coder/agents/platform-controls/usage-insights.md b/docs/ai-coder/agents/platform-controls/usage-insights.md index b6b2d1e5db1d0..a8549e144c696 100644 --- a/docs/ai-coder/agents/platform-controls/usage-insights.md +++ b/docs/ai-coder/agents/platform-controls/usage-insights.md @@ -1,90 +1,52 @@ # Spend Management -Coder provides admin-only controls for monitoring and controlling agent -spend: usage limits and cost tracking. +Coder provides usage reporting for Coder Agents and enforces spend through AI +Gateway budgets on release 2.36. -## Usage limits +## Native chat usage limits -Navigate to **Agents** > **Settings** > **Manage Agents** > **Spend**. +As of release 2.36, native chat usage limits are no longer configurable or +enforced. Coder does not check previously configured values when processing +chat messages. Values configured before upgrading remain stored, have no +effect, and are not migrated to AI Gateway budgets. -Usage limits cap how much each user can spend on LLM usage within a rolling -time period. When enabled, the system checks the user's current spend before -processing each chat message. +To limit spend, configure AI Gateway budgets. They are the only enforcement +mechanism. -### Configuration +## AI Gateway budgets -- **Enable/disable toggle** — master on/off for the entire limit system. -- **Period** — `day`, `week`, or `month`. Periods are UTC-aligned: midnight - UTC for daily, Monday start for weekly, first of the month for monthly. -- **Default limit** — deployment-wide default in dollars. Applies to all - users who do not have a more specific override. Leave unset for no limit. -- **Per-user overrides** — set a custom dollar limit for an individual user. - Takes highest priority. -- **Per-group overrides** — set a limit for a group. When a user belongs to - multiple groups, the lowest group limit applies. +AI Gateway budgets cap each user's AI Gateway spend, including Coder Agents +chats, over a monthly period. -### Priority hierarchy +- Set a group budget from the group's settings page. +- When a user belongs to several budgeted groups, the deployment budget policy + selects the applicable group. The default policy selects the highest budget. +- A per-user override takes priority over group budgets. -The system resolves a user's effective limit in this order: +Budget controls require a license that includes AI Gateway. Existing native +usage-limit values and recorded spend are not migrated when you configure a +budget. -1. Individual user override (highest priority) -1. Minimum group limit across all of the user's groups -1. Global default limit -1. No limit (if limits are disabled or no value is configured) +The API reference documents how to +[get](../../../reference/api/enterprise.md#get-group-ai-budget), +[upsert](../../../reference/api/enterprise.md#upsert-group-ai-budget), and +[delete](../../../reference/api/enterprise.md#delete-group-ai-budget) a group +budget. -### Enforcement +The Agents page shows the signed-in user's current AI Gateway spend and budget +when AI Gateway is available. -- Checked before each chat message is processed. -- When current spend meets or exceeds the limit, the chat returns a - **409 Conflict** response and the message is blocked. -- Fail-open: if the limit query itself fails, the message is allowed - through. -- Brief overage is possible when concurrent messages are in flight, because - cost is determined only after the LLM returns. +## Spend visibility -### User-facing status +Navigate to **Agents** > **Settings** > **Manage Agents** > **Spend** to view +usage-only reporting for deployment-wide Coder Agents chat costs. -Users can view their own spend status, including whether a limit is active, -their effective limit, current spend, and when the current period resets. +The top-level table includes total cost, message and chat counts, and token usage +for each user. It supports date range filtering, search, and pagination. -> [!NOTE] -> The admin configuration page shows the count of models without pricing -> data. Models missing pricing cannot be tracked accurately against limits. - -## Cost tracking - -Navigate to **Agents** > **Settings** > **Manage Agents** > **Spend**. - -This view shows deployment-wide LLM chat costs with per-user drill-down. - -### Top-level view - -A per-user rollup table with the following columns: - -| Column | Description | -|--------------------|-------------------------------------| -| Total cost | Aggregate dollar spend for the user | -| Messages | Number of chat messages sent | -| Chats | Number of distinct chat sessions | -| Input tokens | Total input tokens consumed | -| Output tokens | Total output tokens consumed | -| Cache read tokens | Tokens served from cache | -| Cache write tokens | Tokens written to cache | - -The table supports date range filtering (default: last 30 days), search by -name or username, and pagination. - -### Per-user detail view - -Select a user to see: - -- **Summary cards** — total cost, token breakdowns, and message counts. -- **Usage limit progress** — if a limit is active, a color-coded progress - bar shows current spend relative to the limit. -- **Per-model breakdown** — table of costs and token usage by model. -- **Per-chat breakdown** — table of costs and token usage by chat session. +Select a user to view summary cards and per-model and per-chat breakdowns. The +Spend page does not configure native usage limits or AI Gateway budgets. > [!NOTE] -> Automatic title generation uses lightweight models, such as Claude Haiku or GPT-4o -> Mini. Its token usage is not counted towards usage limits or shown in usage -> summaries. +> Automatic title generation uses lightweight models. Its token usage is not +> included in usage reporting. 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 d0f735b722743..560bd2c0e9370 100644 --- a/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md +++ b/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md @@ -54,7 +54,8 @@ All other traffic is tunneled through without decryption. 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 `<coderd-access-url>/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: +AI Gateway Proxy remains part of the `coder server` process when you [deploy AI Gateway as a standalone service](../standalone.md). +To forward intercepted requests to the standalone Gateway, set: ```sh CODER_AI_GATEWAY_PROXY_TARGET=https://ai-gateway.example.com/ diff --git a/docs/ai-coder/ai-gateway/audit.md b/docs/ai-coder/ai-gateway/audit.md index 9be227b659776..63c1161c73207 100644 --- a/docs/ai-coder/ai-gateway/audit.md +++ b/docs/ai-coder/ai-gateway/audit.md @@ -49,12 +49,12 @@ not just what was called. The sessions page (`http://<deployment-url>/ai-gateway/sessions`) lists all sessions in reverse-chronological order. Each row shows the last prompt, initiator, provider, -client, token usage, network calls, thread count, and timestamp. +client, token usage, network requests, thread count, and timestamp. -The network calls column reports the total and blocked -[Agent Firewall](../agent-firewall/index.md) calls for the session. It shows -`No activity` when the session made no calls, and `Disabled` when the session -did not pass through Agent Firewall, so no network calls were monitored. +The **Network Requests** column reports the total and blocked +[Agent Firewall](../agent-firewall/index.md) requests for the session. It shows +`No activity` when the session made no requests, and `Disabled` when the session +did not pass through Agent Firewall, so no network requests were monitored. Select one to view its full details. @@ -67,6 +67,20 @@ Click into a session to see a chronological causal chain of events. Within a thread, each step shows token usage, tool call details (including arguments and MCP server URLs), duration, and any errors or warnings. +The **Session summary** card beside the timeline reports the session's +[Agent Firewall](../agent-firewall/index.md) activity: + +- **Network requests** is the total number of requests the session made. It + shows `Disabled` when the session did not pass through Agent Firewall, so + monitoring was not active, and `No activity` when the session made no + requests. +- **Blocked network requests** is the subset of those requests that the + allow-list denied. This row appears only when the session made at least one + request, and is highlighted when any were blocked. +- **Top domains** names the destination host the session contacted most, and + appears whenever at least one domain was recorded. When the session contacted + more than one distinct domain, a `+N more` count follows. + ![Session detail](../../images/aibridge/session_detail.png) ## Conducting a forensic audit diff --git a/docs/ai-coder/ai-gateway/auth.md b/docs/ai-coder/ai-gateway/auth.md index 0b2f907a5bf5c..3f2b9e076aa4c 100644 --- a/docs/ai-coder/ai-gateway/auth.md +++ b/docs/ai-coder/ai-gateway/auth.md @@ -3,25 +3,33 @@ > [!NOTE] > AI Gateway requires the [AI Governance Add-On](../ai-governance.md). +AI Gateway uses different credentials for different kinds of connections: + +- AI clients use a Coder API token to authenticate with AI Gateway as a user. +- Standalone Gateway replicas use AI Gateway keys to connect to the Coder control plane. +- AI Gateway uses provider credentials configured by an administrator to authenticate to upstream AI providers. +- In Bring Your Own Key (BYOK) mode, a user also supplies a personal provider credential or subscription token. + +These credentials are not interchangeable. +A Gateway key does not authenticate an AI client, and a Coder API token does not authenticate a standalone replica. + +## Authenticate AI clients + AI Gateway authenticates clients with the same Coder API token -that a user already uses against the rest of the Coder API. -No separate AI Gateway login or credential is required. +that a user uses for the rest of the Coder API. +No separate AI Gateway login is required for client traffic. +For token creation, expiration, and revocation, refer to [Sessions and API tokens](../../admin/users/sessions-tokens.md). -Authenticating with a Coder token avoids distributing provider-specific API keys -(such as OpenAI or Anthropic keys) to individual users. +Authenticating with a Coder token avoids distributing centralized provider API keys, +such as OpenAI or Anthropic keys, to individual users. AI Gateway handles upstream credentials centrally and forwards each request to the configured provider on the user's behalf. -> [!NOTE] -> Only Coder-issued tokens can authenticate users to AI Gateway. -> AI Gateway will use provider-specific API keys to -> [authenticate against upstream AI services](./setup.md#configure-providers). - -The exact environment variable or setting naming may differ from tool to tool. -Refer to the list of [supported clients](./clients/index.md), -and consult your tool's documentation for details. +The exact environment variable or setting name differs between tools. +Refer to the list of [supported clients](./clients/index.md) and +your tool's documentation for details. -## Create a Coder API token +### Create a Coder API token You can generate a token from the Coder dashboard or the CLI. @@ -43,22 +51,17 @@ coder tokens create --lifetime 30d -n my-ai-token Use short lifetimes for automation and CI to limit the blast radius if a token leaks. -## Retrieve your session token +### Retrieve your session token -If you're logged in with the Coder CLI, you can retrieve your current session token -by using [`coder login token`](../../reference/cli/login_token.md): +If you're logged in with the Coder CLI, retrieve your current session token +with [`coder login token`](../../reference/cli/login_token.md): ```sh export ANTHROPIC_API_KEY=$(coder login token) export ANTHROPIC_BASE_URL="https://coder.example.com/api/v2/ai-gateway/anthropic" ``` -Alternatively, you can [generate a long-lived API token](../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself) -from the Coder dashboard. - -For headless or service-account use, refer to [Headless authentication](../../admin/users/headless-auth.md). - -## AI Gateway Proxy authentication +### AI Gateway Proxy authentication For tools that don't support a configurable base URL, [AI Gateway Proxy](./ai-gateway-proxy/index.md) intercepts traffic and forwards it to AI Gateway. @@ -71,25 +74,98 @@ export HTTPS_PROXY="https://coder:$(coder login token)@<proxy-host>:8888" The client machine also needs to trust the proxy's CA certificate. For full setup, refer to [AI Gateway Proxy setup](./ai-gateway-proxy/setup.md). +## Authenticate standalone Gateway replicas + +AI Gateway keys are scoped to the Coder deployment. +A [standalone AI Gateway](./standalone.md) uses one of these keys to connect to `coderd`. +Only the built-in Owner role can create, list, and delete these keys. +Coder custom roles are organization-scoped and cannot grant the site-level `ai_gateway_key` permissions. + +Create a key with a descriptive name: + +```sh +coder ai-gateway keys create standalone-production +``` + +The command displays the plaintext key once. +Save it immediately in your secret manager because Coder cannot retrieve it later. +Coder stores only a short prefix of the key for display and a SHA-256 hash for authentication, never the full secret. + +Names must be unique, 64 characters or fewer, and use only lowercase letters, numbers, and hyphens. +A name cannot start or end with a hyphen or contain consecutive hyphens. +Gateway keys do not expire and cannot be scoped or restricted. + +Configure the standalone process with either of the following options, but not both: + +- `CODER_AI_GATEWAY_KEY` or `--key` supplies the key directly. +- `CODER_AI_GATEWAY_KEY_FILE` or `--key-file` reads the key from a file. + +A user login and `CODER_SESSION_TOKEN` are not used by `coder ai-gateway start`. +The same Gateway key can authenticate multiple replicas. +Separate keys make it easier to rotate or revoke each deployment independently. + +List keys and the most recent heartbeat for each: + +```sh +coder ai-gateway keys list +``` + +A replica records a heartbeat when its control connection is established, then refreshes it every 60 seconds while that connection is active. +The heartbeat reports control-connection liveness rather than client request volume. +Coder stores one timestamp per key, so replicas that share a key cannot be distinguished. + +For usage and flags, refer to the generated CLI reference for [creating](../../reference/cli/ai-gateway_keys_create.md), [listing](../../reference/cli/ai-gateway_keys_list.md), and [deleting](../../reference/cli/ai-gateway_keys_delete.md) Gateway keys. + +### Rotate a Gateway key + +Rotate a key with a rolling restart. +Run more than 1 replica behind a load balancer so client traffic continues during the rollout: + +1. Create a new Gateway key. +1. Update the Kubernetes Secret, environment variable, or key file used by every replica. +1. Restart or roll out the standalone deployment so every replica uses the new key. +1. Verify readiness and confirm that the new key has a recent heartbeat. +1. Delete the old key. + +Delete a key by name or ID: + +```sh +coder ai-gateway keys delete standalone-production +``` + +Add `--yes` to skip the confirmation prompt in automation. + +Deleting a key rejects new connections immediately. +An established session closes when its next heartbeat detects the deletion, within 60 seconds. +The replica then tries to reconnect, receives HTTP 401, and treats that as fatal: the process exits non-zero rather than retrying. +Stop or update every replica before deleting its key for an orderly rotation. + +## Authenticate to upstream providers + +Administrators configure provider credentials in the Coder dashboard or with the [AI Providers API](../../reference/api/aiproviders.md). +Standalone replicas fetch this provider configuration from `coderd`, so do not copy centralized provider credentials into each standalone deployment. + +For provider setup and credential failover, refer to [Provider configuration](./providers.md). + ## Bring Your Own Key (BYOK) -In addition to centralized key management, AI Gateway supports **Bring Your Own Key** (BYOK) mode. -Users can provide their own LLM API keys or use provider subscriptions -(such as Claude Pro/Max or ChatGPT Plus/Pro), +In addition to centralized key management, AI Gateway supports Bring Your Own Key (BYOK) mode. +Users can provide their own LLM API keys or provider subscriptions, +such as Claude Pro or Max and ChatGPT Plus or Pro, while AI Gateway continues to provide observability and governance. ![BYOK authentication flow](../../images/aibridge/clients/byok_auth_flow.png) In BYOK mode, users need two credentials: -- A **Coder API token** to authenticate with AI Gateway. -- Their **own LLM credential** (personal API key or subscription token) +- A Coder API token to authenticate with AI Gateway. +- Their own LLM credential, such as a personal API key or subscription token, which AI Gateway forwards to the upstream provider. BYOK and centralized modes can be used together. When a user provides their own credential, AI Gateway forwards it directly. -When no user credential is present, AI Gateway uses the admin-configured provider key. -This approach offers centralized keys as a default, +When no user credential is present, AI Gateway uses the administrator-configured provider key. +This approach offers centralized keys as a default while allowing individual users to bring their own key. > [!NOTE] @@ -97,11 +173,11 @@ while allowing individual users to bring their own key. > is skipped. Coder Agents requests routed through AI Gateway are in-process control plane -requests, not external client requests that send their own AI Gateway bearer -token. Coder Agents use this same global BYOK setting. When BYOK is enabled, -users can save personal API keys for any enabled AI provider from the Agents -settings page. See -[Agents credential selection](../agents/models.md#credential-selection) +requests, not external client requests that send their own AI Gateway bearer token. +Coder Agents use the same global BYOK setting. +When BYOK is enabled, users can save personal API keys for any enabled AI provider +from the Agents settings page. +Refer to [Agents credential selection](../agents/models.md#credential-selection) for the Agents-specific behavior. Visit individual [client pages](./clients/index.md) for configuration details. @@ -109,21 +185,18 @@ Visit individual [client pages](./clients/index.md) for configuration details. ### Enable or disable BYOK BYOK is enabled by default. -Administrators can disable it using `--ai-gateway-allow-byok=false` or `CODER_AI_GATEWAY_ALLOW_BYOK=false`: +Administrators can disable it for the embedded Gateway with `--ai-gateway-allow-byok=false` or `CODER_AI_GATEWAY_ALLOW_BYOK=false`: ```sh coder server --ai-gateway-allow-byok=false ``` -When disabled, BYOK requests are rejected with a `403 Forbidden` response and only centralized key authentication is permitted. - -## Rotate or revoke a token - -To rotate a token without downtime: +For a standalone Gateway, set the option on each replica: -1. Create a new token with `coder tokens create`. -2. Update the client's configuration to use the new token. -3. Delete the old token from the dashboard or with `coder tokens rm <name>`. +```sh +CODER_AI_GATEWAY_ALLOW_BYOK=false coder ai-gateway start +``` -Deleting a token immediately revokes access. -Deleting the user that owns a token revokes every token that user holds at the same time. +Each replica enforces its own local value, which governs only the traffic that replica handles. +The standalone process does not receive the `coderd` value, so set the option on every replica that should reject BYOK requests. +When disabled, BYOK requests are rejected with a `403 Forbidden` response, and only centralized provider credentials are permitted. diff --git a/docs/ai-coder/ai-gateway/clients/index.md b/docs/ai-coder/ai-gateway/clients/index.md index e3e5513721ce2..ab1b66d739798 100644 --- a/docs/ai-coder/ai-gateway/clients/index.md +++ b/docs/ai-coder/ai-gateway/clients/index.md @@ -27,6 +27,11 @@ The exact configuration method varies by client, some use environment variables, Replace `coder.example.com` with your actual Coder deployment URL. +If you run a [standalone AI Gateway](../standalone.md), point clients at the +Gateway endpoint and drop the `/api/v2/ai-gateway` prefix, for example +`https://ai-gateway.example.com/openai/v1` or +`https://ai-gateway.example.com/anthropic`. + ## Authentication For information about authenticating with AI Gateway, visit [AI Gateway Authentication](../auth.md). diff --git a/docs/ai-coder/ai-gateway/cost-controls.md b/docs/ai-coder/ai-gateway/cost-controls.md index 2af87b1edfbfd..0b563cc3b26b8 100644 --- a/docs/ai-coder/ai-gateway/cost-controls.md +++ b/docs/ai-coder/ai-gateway/cost-controls.md @@ -1,8 +1,307 @@ -# Cost Controls +# AI Governance Cost Control > [!NOTE] -> AI Gateway requires the [AI Governance Add-On](../ai-governance.md). -> As of Coder v2.32, deployments without the add-on will not be able to -> access AI Gateway. +> AI Gateway is part of [AI Governance](../ai-governance.md), which is +> included with a Premium license. -<!-- TODO(AIGOV-476): Document AI Gateway cost controls. --> +AI Governance Cost Control governs AI spend in two complementary ways: + +- **Enforcement** stops a user's requests routed via AI Gateway once their + spend reaches their budget. +- **Reporting** shows what each user and group has approximately spent in the + current budget period. + +AI Governance Cost Control requires: + +- Coder v2.36 or later. +- AI Gateway [enabled and configured](./setup.md) with at least one provider. + +> [!NOTE] +> AI Governance Cost Control reports estimated spend rather than billed cost. +> Estimates will not match your provider invoices exactly. For details, see +> [How spend is estimated](#how-spend-is-estimated). + +These terms appear throughout this page and in the Coder dashboard: + +| Term | What it means | Where it is set | +|---------------------|---------------------------------------------------------------------------------------|----------------------------------------| +| **Budget period** | The window spend accumulates in before it resets. Defaults to the UTC calendar month. | Deployment settings | +| **Budget policy** | The rule that selects a user's effective group. Defaults to the highest budget. | Deployment settings | +| **Group budget** | A spend limit granted to each member of a group. | **Groups** > {group} > **Settings** | +| **User override** | A spend limit for one user that supersedes their group budget. | **Groups** > {group} > **Members** tab | +| **Effective group** | The group that supplies a user's budget and has their spend associated with it. | Resolved automatically | +| **Estimated spend** | A user's approximate spend in the current budget period. | Estimated from usage | + +## Deployment settings + +Two deployment-wide settings govern budget resolution and the reset window. Each +accepts a single value today. + +| Setting | Flag | Environment variable | Supported values | Default | +|---------------|----------------------|--------------------------|------------------|-----------| +| Budget policy | `--ai-budget-policy` | `CODER_AI_BUDGET_POLICY` | `highest` | `highest` | +| Budget period | `--ai-budget-period` | `CODER_AI_BUDGET_PERIOD` | `month` | `month` | + +- **Budget policy** determines which budget wins when a user belongs to more + than one budgeted group. `highest` selects the largest. +- **Budget period** sets the reset window. `month` is the UTC calendar month, so + spend resets at 00:00 UTC on the first day of each month. + +These settings are deployment-wide. + +## Budget + +**Every user's spend is recorded against a group.** A user only has a budget +when one is set on a group they belong to, or when they are given a user +override. + +> [!NOTE] +> When AI Governance Cost Control is first deployed, users have **unlimited +> spend** until an administrator sets budgets. Their spend is recorded against +> the `Everyone` group. + +### Group budget + +Setting a group budget requires the Owner, User Admin, Organization Admin, or +Organization User Admin [role](../../admin/users/groups-roles.md). + +A group budget applies to each member individually rather than to the group as a +whole. For example, if a group has ten members and a budget of $200 USD, each +member can spend $200 USD and the group has a total spend limit of $2,000 USD. + +1. Go to **Groups** and select a group. +1. Select **Settings**. +1. Under **AI budget**, set **Monthly limit per member**. +1. Select **Save**. + +![Group AI budget settings](../../images/aibridge/group-ai-budget.png) + +Budget values behave as follows: + +- An empty field means no limit. The field displays `unlimited`. +- `$0 USD` blocks every request routed via AI Gateway from members whose + effective group is this group. +- The maximum is `$1,000,000 USD` per member per budget period. + +Note: Members who belong to other groups with budgets are still governed by +whichever group the budget policy selects. See +[Effective group resolution](#effective-group-resolution). + +### User override + +Adding or removing an override requires the Owner or User Admin +[role](../../admin/users/groups-roles.md). Other administrator roles can view +overrides but cannot change them. + +Override a group budget when one user needs a different limit from the rest of +their group. + +1. Go to **Groups**, select a group, then open the **Members** tab. +1. Find the member to apply the override to, open their action menu, and select + **Manage AI budget**. +1. Enable **Override group budget**. +1. Set **Custom monthly budget**, then choose the group in + **Budget assigned to**. +1. Select **Update**. + +![User AI budget override](../../images/aibridge/user-override-ai-budget.png) + +Overrides behave as follows: + +- A user can have only one override at a time. +- An override supersedes every group budget the user belongs to. +- Spend is always attributed to a group, so an override must name the assigned + group. This group can be different from the group selected in the **Members** + tab. The user must belong to the assigned group. +- The `$0 USD` to `$1,000,000 USD` range applies to overrides as well. +- Disabling **Override group budget** removes the override and returns the user + to the budget of their [effective group](#effective-group-resolution). + +### Effective group resolution + +A user can belong to several groups that have budgets, and can also hold an +override. AI Gateway resolves a single effective group for each request, in this +order: + +1. The user's [override](#user-override) takes precedence over every group + budget. +1. Otherwise, the [budget policy](#deployment-settings) selects one of the + groups the user belongs to. The default policy, `highest`, selects the group + with the largest budget. +1. If none of those groups has a budget, the user has *effectively* unlimited + spend and their spend is recorded against the `Everyone` group. + +> [!NOTE] +> Groups with identical budgets are ranked by the organization membership the +> user joined first, then by group ID. To see the effective group currently +> assigned to a user, see [Monitor spend](#monitor-spend). The effective group is +> deployment-wide, so it can be a group in a different organization. + +Recorded spend is immutable. Changing which budget applies to a user affects +future requests only: spend that Coder has already recorded stays with the group +it was attributed to, so a user's history can span several groups. The effective +group is resolved at request time, so changing a group budget can change which +group applies to a user on future requests. + +## How enforcement works + +AI Gateway checks each request before forwarding it upstream. The check compares +the user's spend in the current budget period with the budget that applies to the +request: + +- Spend below the budget: the request proceeds. +- Spend at or above the budget: AI Gateway returns `403 Forbidden` with a + message that describes the issue. + +Users without a budget have **unlimited spend**, so their requests proceed +without enforcement. A blocked user's access resumes when the budget period +resets, when an administrator raises the budget, or when an override is added. + +> [!NOTE] +> Enforcement is approximate. A request's cost is known only after the provider +> response reaches AI Gateway, so concurrent in-flight requests can carry a user +> slightly past their budget. Subsequent requests are blocked after the recorded +> spend reaches the budget. + +### Notifications + +The first time a user's spend crosses a threshold within a budget period, Coder +notifies the user and deployment-wide Owners and User Admins, excluding the +affected user: + +| Threshold | User notification | Admin notification | +|-----------|--------------------------------|------------------------------------------| +| 85% | You're approaching your budget | `<username>` is approaching their budget | +| 100% | You've reached your budget | `<username>` has reached their budget | + +- A single expensive request can cross both thresholds at once. +- Budgets of `$0 USD` and unpriced usage cross no thresholds. +- Notifications are informational. Enforcement does not depend on them. + +For delivery methods, see +[Notifications](../../admin/monitoring/notifications/index.md). + +## How spend is estimated + +Coder multiplies the token usage of each request by the published price of the +model that served it. Prices come from a curated [models.dev](https://models.dev) +snapshot that ships with every Coder release, so no configuration is required. + +Spend accumulates only from the moment v2.36 is deployed. Upgrading mid-month +therefore produces a partial first period. + +To see which models are priced in the release version you run, consult the +price book for your Coder version: + +```text +https://github.com/coder/coder/blob/release/<VERSION>/coderd/aibridge/prices/data/prices.json +``` + +Replace `<VERSION>` with your Coder minor version, for example `2.36`. + +> [!IMPORTANT] +> Estimated spend can differ from provider-reported amounts, and some usage might +> not count toward spend: +> +> - Estimates exclude negotiated discounts, committed-use pricing, and +> provider-specific billing rules. +> - Requests to models that are missing from the price table record token usage +> but add nothing to a user's spend. A user who only calls unpriced models is +> effectively unlimited. + +Monitor `coder_ai_gateway_cost_control_unpriced_token_usage_records_total`, +labeled by `provider`, `provider_type`, and `model`, to detect unpriced usage. +The `(provider_type, model)` tuple identifies the missing price. Any non-zero +value means spend is under-counted. Because the price book ships with the +release, a newly launched model can remain unpriced until you upgrade Coder. + +## Monitor spend + +Spend reporting is available in the Coder dashboard and as a CSV export. +Prometheus metrics report enforcement and pricing gaps. + +### Dashboard + +Visibility follows the viewer's role: + +| Who | Sees | +|------------------------------------------------------|----------------------------------------------------------------------| +| Every user | Their own spend and budget, or unlimited state, in their avatar menu | +| Members of a group | The group's spend and budget, and their own member row | +| Owners, User Admins, and organization administrators | Spend and budgets for every group and every member | + +- The **Groups** page compares each group's spend with the combined limits of + the members it covers. +- The **Members** tab of a group reports each member's spend, their budget, and + its source, labeled `Custom limit` for an override or `Group limit` for a + group budget. If the effective group is another group in the same organization, + the row shows `Budget managed by another group`. If the effective group is in a + different organization, the row shows a dash and explains that the group is not + visible there. +- The avatar menu reports the signed-in user's own spend for the budget period + as `$<spend> / $<budget> USD`, or `$<spend> / Unlimited USD` when no budget + applies. + +Administrators can also use the +[Get user AI spend](../../reference/api/enterprise.md#get-user-ai-spend) API +endpoint to see a user's current effective group. + +### CSV Export + +Users who can read group-member data for the organization can export estimated +spend for reporting and internal cost allocation. The export is available through +the API only. + +```sh +curl -H "Coder-Session-Token: $CODER_SESSION_TOKEN" \ + "https://coder.example.com/api/v2/organizations/<organization>/ai/spend/export" +``` + +- Without parameters, the export covers the current budget period. +- To select a range, pass `period_start` and `period_end` together as RFC 3339 + timestamps. A range can span at most 31 days. +- Each row breaks spend down by user, group, model, and provider, with the + underlying token counts. + +### Prometheus Metrics + +Prometheus metrics report blocked requests, users over budget, unpriced usage, +and enforcement latency. For the full metric list, including types and labels, +see [Prometheus metrics](../../admin/integrations/prometheus.md). + +## Migrate from Coder Agents Cost Control + +In release 2.36, native Coder Agents usage limits are no longer configurable +or enforced. Existing native values remain stored, have no effect, and are not +migrated to AI Gateway budgets. + +> [!WARNING] +> Spend limits previously configured under **Admin settings** > **AI** > +> **Spend** are no longer enforced by Coder Agents. To enforce spend, set an +> AI Gateway budget. + +Configure new AI Gateway budgets from the group settings page or use the API to +[get](../../reference/api/enterprise.md#get-group-ai-budget), +[upsert](../../reference/api/enterprise.md#upsert-group-ai-budget), or +[delete](../../reference/api/enterprise.md#delete-group-ai-budget) a group +budget. + +Expect the following differences: + +- AI Gateway has no deployment-wide default budget. Each group that needs a + limit requires its own budget. +- The UTC calendar month is the only budget period. Daily and weekly periods are + not currently supported. +- Users in several budgeted groups receive the highest budget by default. Native + Coder Agents usage limits applied the lowest group limit. +- Budgets cover priced AI Gateway traffic. Chat, IDE extensions, and CLI agents + draw on the same budget when their provider and model are priced. See + [How spend is estimated](#how-spend-is-estimated). +- Recorded spend and native limit values do not carry over to AI Gateway + budgets. + +## Next steps + +- [Monitoring](./monitoring.md) +- [Auditing AI sessions](./audit.md) +- [AI Gateway API reference](../../reference/api/enterprise.md) diff --git a/docs/ai-coder/ai-gateway/index.md b/docs/ai-coder/ai-gateway/index.md index 3fcbaf15f2c3a..c9755102d6964 100644 --- a/docs/ai-coder/ai-gateway/index.md +++ b/docs/ai-coder/ai-gateway/index.md @@ -6,11 +6,12 @@ AI Gateway is a smart gateway for AI. It acts as an intermediary between your us and providers like OpenAI and Anthropic. By intercepting all the AI traffic between these clients and the upstream APIs, AI Gateway can record user prompts, token usage, and tool invocations. AI Gateway supports clients running inside or outside Coder workspaces. +You can run the Gateway inside the Coder control plane (`coderd`) or as a [standalone service](./standalone.md). AI Gateway solves 3 key problems: 1. **Centralized authn/z management**: no more issuing & managing API tokens for OpenAI/Anthropic usage. - Users use their Coder session or API tokens to authenticate with `coderd` (Coder control plane), and + Users use their Coder session or API tokens to authenticate with `coderd`, and `coderd` securely communicates with the upstream APIs on their behalf. 1. **Auditing and attribution**: all interactions with AI services, whether autonomous or human-initiated, will be audited and attributed back to a user. @@ -41,6 +42,7 @@ AI Gateway is best suited for organizations facing these centralized management ## Next steps - [Set up AI Gateway](./setup.md) on your Coder deployment +- Optionally, [deploy AI Gateway as a standalone service](./standalone.md) - [Configure AI clients](./clients/index.md) to use AI Gateway - [Configure MCP servers](./mcp.md) for tool access - [Audit AI sessions](./audit.md) diff --git a/docs/ai-coder/ai-gateway/mcp.md b/docs/ai-coder/ai-gateway/mcp.md index d78bcb21a74b0..2e446aa48112b 100644 --- a/docs/ai-coder/ai-gateway/mcp.md +++ b/docs/ai-coder/ai-gateway/mcp.md @@ -34,7 +34,7 @@ CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=... CODER_EXTERNAL_AUTH_0_MCP_URL=https://api.githubcopilot.com/mcp/ ``` -See the diagram in [Implementation Details](./reference.md#implementation-details) for more information. +Refer to the diagram in [Embedded Gateway](./reference.md#embedded-gateway) for more information. You can also control which tools are injected by using an allow and/or a deny regular expression on the tool names: @@ -64,7 +64,7 @@ AI Gateway marks automatically injected tools with a prefix `bmcp_` ("bridged MC ## Tool Injection -If a model decides to invoke a tool and it has a `bmcp_` prefix and AI Gateway has a connection with the related MCP server, it will invoke the tool. The tool result will be passed back to the upstream AI provider, and this will loop until the model has all of its required data. These inner loops are not relayed back to the client; all it sees is the result of this loop. See [Implementation Details](./reference.md#implementation-details). +If a model decides to invoke a tool and it has a `bmcp_` prefix and AI Gateway has a connection with the related MCP server, it will invoke the tool. The tool result will be passed back to the upstream AI provider, and this will loop until the model has all of its required data. These inner loops are not relayed back to the client. The client only sees the result of this loop. Refer to [Deployment topologies](./reference.md#deployment-topologies). In contrast, tools which are defined by the client (i.e. the [`Bash` tool](https://docs.claude.com/en/docs/claude-code/settings#tools-available-to-claude) defined by _Claude Code_) cannot be invoked by AI Gateway, and the tool call from the model will be relayed to the client, after which it will invoke the tool. diff --git a/docs/ai-coder/ai-gateway/monitoring.md b/docs/ai-coder/ai-gateway/monitoring.md index a9b2b72f45208..b56a6d8fbec40 100644 --- a/docs/ai-coder/ai-gateway/monitoring.md +++ b/docs/ai-coder/ai-gateway/monitoring.md @@ -11,44 +11,83 @@ AI Gateway records the last `user` prompt, token usage, model reasoning, and eve ![User Leaderboard](../../images/aibridge/grafana_user_leaderboard.png) -We provide an example Grafana dashboard that you can import as a starting point for your metrics. See [the Grafana dashboard README](../../../examples/monitoring/dashboards/grafana/aibridge/README.md). +Coder provides an example Grafana dashboard that you can import as a starting point for your metrics. +Refer to the [Grafana dashboard README](../../../examples/monitoring/dashboards/grafana/aibridge/README.md). These logs and metrics can be used to determine usage patterns, track costs, and evaluate tooling adoption. -## Provider metrics - -AI Gateway (the in-process daemon) and AI Gateway Proxy (the external -proxy) each export Prometheus metrics describing the configured -provider pool and its reload loop. See -[Provider Configuration](./providers.md) for the lifecycle these -metrics describe. - -| Metric | Type | Labels | Purpose | -|--------------------------------------------------------------------------|---------|--------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| -| `coder_ai_gateway_provider_info` | gauge | `provider_name`, `provider_type`, `status` | One series per configured provider. Value is always `1`; the `status` label (`enabled`, `disabled`, `error`) carries the alertable signal. | -| `coder_ai_gateway_providers_last_reload_timestamp_seconds` | gauge | | Unix timestamp of the last reload attempt, success or failure. | -| `coder_ai_gateway_providers_last_reload_success_timestamp_seconds` | gauge | | Unix timestamp of the last reload that successfully refreshed the pool. | -| `coder_ai_gateway_proxy_provider_info` | gauge | `provider_name`, `provider_type`, `status` | Same shape as `coder_ai_gateway_provider_info` but reported by the external proxy. | -| `coder_ai_gateway_proxy_providers_last_reload_timestamp_seconds` | gauge | | Last reload attempt timestamp in the external proxy. | -| `coder_ai_gateway_proxy_providers_last_reload_success_timestamp_seconds` | gauge | | Last successful reload timestamp in the external proxy. | -| `coder_ai_gateway_proxy_connect_sessions_total` | counter | `type` (`mitm`, `tunneled`) | CONNECT sessions established by the proxy. | -| `coder_ai_gateway_proxy_mitm_requests_total` | counter | `provider` | MITM requests handled. | -| `coder_ai_gateway_proxy_inflight_mitm_requests` | gauge | `provider` | In-flight MITM requests. | -| `coder_ai_gateway_proxy_mitm_responses_total` | counter | `code`, `provider` | MITM responses by HTTP status code. | +## Prometheus metrics + +The embedded Gateway and [standalone Gateway](./standalone.md) export the same AI Gateway request metrics. +Each process exports metrics for the traffic that it handles: + +- The Coder control plane (`coderd`) Prometheus listener exports metrics for the embedded Gateway. +- Each standalone Gateway replica exports metrics from its own Prometheus listener. + +Refer to [provider configuration](./providers.md) for the provider reload lifecycle these metrics describe. + +| Metric | Type | Labels | Purpose | +|--------------------------------------------------------------------|-----------|----------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `coder_ai_gateway_interceptions_total` | counter | `client`, `initiator_id`, `method`, `model`, `provider`, `route`, `status` | Intercepted requests. | +| `coder_ai_gateway_interceptions_inflight` | gauge | `model`, `provider`, `route` | Intercepted requests currently being processed. | +| `coder_ai_gateway_interceptions_duration_seconds` | histogram | `model`, `provider` | Total intercepted request duration, including upstream processing. | +| `coder_ai_gateway_passthrough_total` | counter | `method`, `provider`, `route` | Requests passed through to an upstream provider without interception. | +| `coder_ai_gateway_prompts_total` | counter | `client`, `initiator_id`, `model`, `provider` | Prompts issued by users. | +| `coder_ai_gateway_tokens_total` | counter | `client`, `initiator_id`, `model`, `provider`, `type` | Tokens used by intercepted requests. | +| `coder_ai_gateway_injected_tool_invocations_total` | counter | `model`, `name`, `provider`, `server` | Invocations of MCP tools injected by AI Gateway. | +| `coder_ai_gateway_non_injected_tool_selections_total` | counter | `model`, `name`, `provider` | Tools selected by a model for the client to invoke. | +| `coder_ai_gateway_circuit_breaker_state` | gauge | `endpoint`, `model`, `provider` | Current circuit-breaker state: `0` for closed, `0.5` for half-open, and `1` for open. | +| `coder_ai_gateway_circuit_breaker_trips_total` | counter | `endpoint`, `model`, `provider` | Times a circuit breaker transitioned to the open state. | +| `coder_ai_gateway_circuit_breaker_rejects_total` | counter | `endpoint`, `model`, `provider` | Requests rejected because a circuit breaker was open. | +| `coder_ai_gateway_key_pool_state` | gauge | `provider`, `state` | Provider keys in each state: `valid`, `temporary`, or `permanent`. | +| `coder_ai_gateway_key_pool_state_transitions_total` | counter | `provider`, `reason` | Provider key state transitions during failover. | +| `coder_ai_gateway_key_pool_exhaustions_total` | counter | `outcome`, `provider` | Times a provider key pool had no usable key. | +| `coder_ai_gateway_key_pool_failover_attempts` | histogram | `provider` | Keys attempted before a request succeeded or exhausted the provider key pool. | +| `coder_ai_gateway_provider_info` | gauge | `provider_name`, `provider_type`, `status` | Build status of each configured provider, including disabled and errored ones. Value is always `1`; `status` is `enabled`, `disabled`, or `error`. | +| `coder_ai_gateway_providers_last_reload_timestamp_seconds` | gauge | | Unix timestamp of the last attempt to rebuild the Gateway provider pool. | +| `coder_ai_gateway_providers_last_reload_success_timestamp_seconds` | gauge | | Unix timestamp of the last successful rebuild of the Gateway provider pool. | + +Histograms also emit the standard `_bucket`, `_sum`, and `_count` series. + +### Cost control metrics + +Budget enforcement runs in `coderd`. +Cost control metrics are exported only from the `coderd` Prometheus listener. +Standalone replicas do not export them. + +| Metric | Type | Labels | Purpose | +|--------------------------------------------------------------------|-----------|--------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `coder_ai_gateway_cost_control_blocked_requests_total` | counter | `group_id` | AI requests blocked because the initiator's budget was exceeded. | +| `coder_ai_gateway_cost_control_blocked_users` | gauge | `group_id` | Users currently over their AI budget. | +| `coder_ai_gateway_cost_control_enforcement_duration_seconds` | histogram | `outcome` | Duration of AI budget enforcement checks. `outcome` is `allowed`, `blocked`, or `error`. | +| `coder_ai_gateway_cost_control_unpriced_token_usage_records_total` | counter | `model`, `provider`, `provider_type` | Recorded token-usage records for which no model price was found. `provider` is the provider instance name, and `provider_type` is the configured type the price is keyed on, or `unknown` when the provider could not be resolved. | + +### AI Gateway Proxy metrics + +AI Gateway Proxy exports metrics from the `coderd` Prometheus listener. + +| Metric | Type | Labels | Purpose | +|--------------------------------------------------------------------------|---------|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| `coder_ai_gateway_proxy_connect_sessions_total` | counter | `type` | CONNECT sessions established, classified as `mitm` or `tunneled`. | +| `coder_ai_gateway_proxy_mitm_requests_total` | counter | `provider` | MITM requests handled by AI Gateway Proxy. | +| `coder_ai_gateway_proxy_inflight_mitm_requests` | gauge | `provider` | MITM requests currently being processed. | +| `coder_ai_gateway_proxy_mitm_responses_total` | counter | `code`, `provider` | MITM responses by HTTP status code. | +| `coder_ai_gateway_proxy_provider_info` | gauge | `provider_name`, `provider_type`, `status` | Routing status of each configured provider. Value is always `1`; `status` is `enabled`, `disabled`, or `error`. | +| `coder_ai_gateway_proxy_providers_last_reload_timestamp_seconds` | gauge | | Unix timestamp of the last attempt to rebuild the proxy routing snapshot. | +| `coder_ai_gateway_proxy_providers_last_reload_success_timestamp_seconds` | gauge | | Unix timestamp of the last successful rebuild of the proxy routing snapshot. | + +Refer to the [Prometheus reference](../../admin/integrations/prometheus.md) for these metrics alongside the other metrics that Coder components export. + +### Metric name migration > [!IMPORTANT] -> The AI Gateway metric prefixes were renamed: `coder_aibridged_*` became -> `coder_ai_gateway_*` and `coder_aibridgeproxyd_*` became -> `coder_ai_gateway_proxy_*`. This rename covers every AI Gateway metric, -> including the interception, token, prompt, tool, and circuit-breaker counters -> listed in the [Prometheus reference](../../admin/integrations/prometheus.md). -> The legacy `coder_aibridged_*` and `coder_aibridgeproxyd_*` names are still -> emitted with identical values during the v2.35 and v2.36 deprecation window. -> They are planned for removal in v2.37. Migrate dashboards and alerts to the new -> names now. Do not relabel new names back to old names while legacy names are -> still emitted, because that creates duplicate legacy series in the same scrape. -> After legacy names are removed, use `metric_relabel_configs` only if you need a -> temporary compatibility bridge for dashboards that still use the old names: +> The embedded Gateway metric prefix changed from `coder_aibridged_*` to `coder_ai_gateway_*`, and the proxy prefix changed from `coder_aibridgeproxyd_*` to `coder_ai_gateway_proxy_*`. +> The embedded Gateway and AI Gateway Proxy emit the legacy names with identical values during the v2.35 and v2.36 deprecation window, and the legacy names are planned for removal in v2.37. +> The cost control metrics were added after the rename and have no legacy alias. +> The standalone Gateway emits only the current `coder_ai_gateway_*` names. +> Migrate dashboards and alerts to the new names. +> Do not relabel new names back to old names while both are emitted because this creates duplicate legacy series in the same scrape. +> After the legacy names are removed, use `metric_relabel_configs` only if you need a temporary compatibility bridge: > > ```yaml > metric_relabel_configs: @@ -68,28 +107,80 @@ metrics describe. Alert on any provider entering a non-`enabled` status: ```promql -sum by (provider_name, status) (coder_ai_gateway_provider_info{status!="enabled"}) > 0 +sum by (instance, provider_name, status) ( + coder_ai_gateway_provider_info{status!="enabled"} +) > 0 ``` -Alert when the reload loop is firing but failing to refresh the pool -for longer than a few minutes: +Alert when the provider reload loop is firing but failing to refresh the pool for longer than a few minutes: ```promql (coder_ai_gateway_providers_last_reload_timestamp_seconds - coder_ai_gateway_providers_last_reload_success_timestamp_seconds) > 300 ``` -Repeat the same query against `coder_ai_gateway_proxy_*` if you run the -external proxy. +Use the `coder_ai_gateway_proxy_*` metrics when you alert on AI Gateway Proxy. -## Structured Logging +## Standalone Gateway monitoring -AI Gateway can emit structured logs for every interception event to your -existing log pipeline. This is useful for exporting data to external SIEM or -observability platforms. See [Structured Logging](./setup.md#structured-logging) -in the setup guide for configuration and a full list of record types. +### Metrics listener -## Exporting Data +Enable the standalone metrics listener with `CODER_PROMETHEUS_ENABLE=true` or `--prometheus-enable`, and set its bind address with `CODER_PROMETHEUS_ADDRESS` or `--prometheus-address`. +The command default is `127.0.0.1:2112`. +The listener is unauthenticated, so expose it only to your monitoring network. + +In addition to the common `coder_ai_gateway_*` metrics, the standalone listener exports standard unprefixed `go_*`, `process_*`, and `promhttp_*` metrics for the standalone process and its metrics handler. + +### Kubernetes discovery + +The [AI Gateway Helm chart](../../../helm/ai-gateway/README.md#metrics) enables metrics and binds the listener to `0.0.0.0:2112` by default. +The chart exposes a named `metrics` container port, but it does not include this port in the data-plane Service or create monitoring discovery resources. +For Prometheus pod-based discovery, add scrape annotations to each Gateway pod: + +```yaml +coder: + podAnnotations: + prometheus.io/scrape: "true" + prometheus.io/port: "2112" +``` + +You can also configure a `PodMonitor` to select the chart's `app.kubernetes.io/name` and `app.kubernetes.io/instance` pod labels, or add dedicated selector labels with `coder.podLabels`. +Manage the `PodMonitor` separately or include it in the Helm release with `extraTemplates`. +To use a `ServiceMonitor`, create a separate Service that exposes the `metrics` container port because the chart's data-plane Service exposes only the HTTP traffic port. +Refer to the [Helm chart metrics configuration](../../../helm/ai-gateway/README.md#metrics) for the available chart settings. + +### Health and readiness + +A standalone AI Gateway exposes health endpoints on its data-plane listener: + +| Endpoint | Success condition | +|------------|-----------------------------------------------------------------------------------------------| +| `/healthz` | The HTTP listener is serving. | +| `/readyz` | The control connection to `coderd` is active and provider configuration has been initialized. | + +`/readyz` returns HTTP 503 until provider configuration is initialized and whenever the control connection to `coderd` is unavailable. +A `200 OK` response from `/healthz` only means the HTTP listener is accepting connections. It returns `200 OK` even when the control connection is down. +Both endpoints are unauthenticated, bypass the concurrency, rate limiting, and BYOK middleware, and do not create trace spans. + +The standalone Helm chart enables a `/healthz` liveness probe and a `/readyz` readiness probe by default. +The startup probe is disabled by default. + +## Logs + +### Standalone operational logs + +Standalone replicas use the standard Coder logging options. +Configure them on every replica or through `coder.env` in the AI Gateway Helm chart. +Refer to the [`coder ai-gateway start` logging options](../../reference/cli/ai-gateway_start.md#-l---log-filter) for configuration details. + +### Structured interception logs + +AI Gateway can emit a structured log for every interception record to an external SIEM or observability platform. +The `CODER_AI_GATEWAY_STRUCTURED_LOGGING` setting belongs to `coderd`, standalone Gateway does not consume it. +Standalone replicas send interception records to `coderd`, which writes the structured logs to the Coder server log output. +Refer to [structured logging](./setup.md#structured-logging) for configuration and record types. + +## Export data AI Gateway interception data can be exported for external analysis, compliance reporting, or integration with log aggregation systems. @@ -131,42 +222,48 @@ Available query filters: - `started_after` - Filter sessions after a timestamp - `started_before` - Filter sessions before a timestamp -See the [API documentation](../../reference/api/aigateway.md) for full details. +Refer to the [API documentation](../../reference/api/aigateway.md) for full details. -## Data Retention +## Data retention AI Gateway data is retained for **60 days by default**. Configure the retention period to balance storage costs with your organization's compliance and analysis needs. -For configuration options and details, see [Data Retention](./setup.md#data-retention) +For configuration options and details, refer to [Data Retention](./setup.md#data-retention) in the AI Gateway setup guide. ## Tracing -AI Gateway supports tracing via [OpenTelemetry](https://opentelemetry.io/), -providing visibility into request processing, upstream API calls, and MCP server -interactions. +AI Gateway supports tracing through [OpenTelemetry](https://opentelemetry.io/) for request processing, upstream API calls, and MCP server interactions. +Embedded Gateway spans are emitted by the `coder server` process. +Standalone spans are emitted independently by every replica with the service name `coder-ai-gateway`. + +### Enable tracing -### Enabling Tracing +AI Gateway exports spans over OTLP/gRPC when you set `CODER_TRACE_ENABLE`, honoring `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`. +The exporter always dials without TLS, so an `https://` endpoint is still contacted over plaintext gRPC. +`CODER_TRACE_HONEYCOMB_API_KEY` adds a Honeycomb exporter and works with or without `CODER_TRACE_ENABLE`. +Set only the Honeycomb key to export to Honeycomb alone, or set both to export to Honeycomb and an OTLP collector. -AI Gateway tracing is enabled when tracing is enabled for the Coder server. -To enable tracing set `CODER_TRACE_ENABLE` environment variable or -[--trace](https://coder.com/docs/reference/cli/server#--trace) CLI flag: +The embedded and standalone Gateways share the same tracing options. +Refer to the [`coder server` tracing options](../../reference/cli/server.md#--trace) for the embedded Gateway and the [`coder ai-gateway start` tracing options](../../reference/cli/ai-gateway_start.md#--trace) for standalone replicas. +Configure tracing on every standalone process or through `coder.env` in the AI Gateway Helm chart. + +The following minimal configuration enables tracing and exports spans over OTLP/gRPC: ```sh export CODER_TRACE_ENABLE=true +export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317 ``` -```sh -coder server --trace -``` +In both deployment modes, each request to the Gateway's LLM API endpoint creates an HTTP request span, including requests that are passed through or rejected instead of intercepted. -### What is Traced +### Traced operations AI Gateway creates spans for the following operations: -| Span Name | Description | +| Span name | Description | |---------------------------------------------|------------------------------------------------------| | `CachedBridgePool.Acquire` | Acquiring a request bridge instance from the pool | | `Intercept` | Top-level span for processing an intercepted request | @@ -174,33 +271,33 @@ AI Gateway creates spans for the following operations: | `Intercept.ProcessRequest` | Processing the request through the bridge | | `Intercept.ProcessRequest.Upstream` | Forwarding the request to the upstream AI provider | | `Intercept.ProcessRequest.ToolCall` | Executing a tool call requested by the AI model | -| `Intercept.RecordInterception` | Recording creating interception record | -| `Intercept.RecordPromptUsage` | Recording prompt/message data | +| `Intercept.RecordInterception` | Creating the interception record | +| `Intercept.RecordPromptUsage` | Recording prompt and message data | | `Intercept.RecordTokenUsage` | Recording token consumption | -| `Intercept.RecordToolUsage` | Recording tool/function calls | +| `Intercept.RecordToolUsage` | Recording tool and function calls | +| `Intercept.RecordModelThought` | Recording model reasoning | | `Intercept.RecordInterceptionEnded` | Recording the interception as completed | +| `Passthrough` | Forwarding a non-intercepted provider request | | `ServerProxyManager.Init` | Initializing MCP server proxy connections | | `StreamableHTTPServerProxy.Init` | Setting up HTTP-based MCP server proxies | | `StreamableHTTPServerProxy.Init.fetchTools` | Fetching available tools from MCP servers | -Example trace of an interception using Jaeger backend: +Example trace of an interception using a Jaeger backend: ![Trace of interception](../../images/aibridge/jaeger_interception_trace.png) -### Capturing Logs in Traces +### Capture logs in traces > [!NOTE] > Enabling log capture may generate a large volume of trace events. -To include log messages as trace events, enable trace log capture -by setting `CODER_TRACE_LOGS` environment variable or using -[--trace-logs](https://coder.com/docs/reference/cli/server#--trace-logs) flag: +Set `CODER_TRACE_LOGS=true` to include log messages as trace events: ```sh export CODER_TRACE_ENABLE=true export CODER_TRACE_LOGS=true ``` -```sh -coder server --trace --trace-logs -``` +Log capture only applies to recording spans, so it requires tracing to be enabled through `CODER_TRACE_ENABLE` or a backend-specific exporter such as `CODER_TRACE_HONEYCOMB_API_KEY`. +Leave `CODER_TRACE_LOGS` unset to trace without capturing logs. +For standalone replicas, set both options on every process that should capture logs. diff --git a/docs/ai-coder/ai-gateway/providers.md b/docs/ai-coder/ai-gateway/providers.md index 889689fd3c5c5..3e4abe7ad806b 100644 --- a/docs/ai-coder/ai-gateway/providers.md +++ b/docs/ai-coder/ai-gateway/providers.md @@ -37,6 +37,10 @@ After seeding, manage providers through the dashboard or API. A provider that has been edited or removed there is not recreated or overwritten from the environment on the next restart. +Seeding is a `coderd` operation. A [standalone Gateway](./standalone.md) +ignores the deprecated provider variables and fetches provider +configuration from `coderd`. + ## Provider types AI Gateway speaks two upstream API formats: the **OpenAI** format @@ -276,7 +280,7 @@ an API key. ## Provider lifecycle Every provider carries an explicit status, surfaced through the -[`provider_info`](./monitoring.md#provider-metrics) metric and the API: +[`provider_info`](./monitoring.md#prometheus-metrics) metric and the API: | Status | Meaning | Effect on requests | |------------|-------------------------------------------------------------------------------|--------------------------------------------------| @@ -298,11 +302,13 @@ attempt and each successful reload, exposed as Prometheus metrics: If you run the [external proxy](./ai-gateway-proxy/index.md), it exposes the same pair under the `coder_ai_gateway_proxy_` prefix. +Each [standalone Gateway](./standalone.md) replica reloads providers +independently. A growing gap between the attempt and success timestamps means reloads are firing but failing to apply. Alert on that gap rather than on a single failure, which may resolve on the next change. See -[Monitoring](./monitoring.md#provider-metrics) for the full metric list +[Monitoring](./monitoring.md#prometheus-metrics) for the full metric list and sample alert queries. ## Key failover diff --git a/docs/ai-coder/ai-gateway/reference.md b/docs/ai-coder/ai-gateway/reference.md index 7714f4ec2ef05..88dc639298485 100644 --- a/docs/ai-coder/ai-gateway/reference.md +++ b/docs/ai-coder/ai-gateway/reference.md @@ -5,23 +5,75 @@ > As of Coder v2.32, deployments without the add-on will not be able to > access AI Gateway. -## Implementation Details +## Deployment topologies -`coderd` runs an in-memory instance of `aibridged`, whose logic is mostly contained in ../../../aibridge. In future releases we will support running external instances for higher throughput and complete memory isolation from `coderd`. +AI Gateway can run inside `coderd` or as a standalone data-plane service. +Both topologies run the same Gateway request handling and keep `coderd` as the source of truth for Coder API key validation, provider configuration, and AI session records. +They differ in how requests are routed to the Gateway. + +### Embedded Gateway + +By default, `coder server` runs an in-memory Gateway instance in the `coderd` process. +AI clients send requests to `<Coder access URL>/api/v2/ai-gateway/<provider-name>/`. +The embedded Gateway uses the same control RPC as a standalone deployment, over an in-process transport rather than a network connection. +It does not use a Gateway key and does not negotiate an API version. + +The following diagram shows the embedded topology: ![AI Gateway implementation details](../../images/aibridge/aibridge-implementation-details.png) +### Standalone Gateway + +A [standalone deployment](./standalone.md) runs the AI traffic data plane outside the `coderd` process. +Each replica accepts client traffic, sends AI requests directly to upstream providers, and maintains a control connection to `coderd` using a [Gateway key](./standalone.md#create-a-gateway-key). + +The control connection carries: + +- Coder API key validation, which resolves each request to an active Coder user. +- AI budget checks, which reject requests from users over their spend limit. +- Provider configuration, plus a change signal when the provider set changes. +- AI session records. +- **Deprecated**: the configuration and access tokens used by [injected MCP](./mcp.md). + +Standalone replicas do not own authoritative database state. +They keep ephemeral provider snapshots, request caches, provider key pools, and metrics in memory, and emit their own logs and traces. +Each replica writes its own [API dumps](./setup.md#api-dumps) to its own local disk when dumps are enabled. + +`coderd` remains required for standalone operation. +A replica becomes unready when its control connection is unavailable, even if its HTTP listener remains healthy. +AI Gateway Proxy remains part of `coder server` and can forward its intercepted traffic to either the embedded Gateway or a standalone endpoint. + +## Version compatibility + +The control connection between a standalone replica and `coderd` is versioned. +The current version is defined in [`coderd/aibridged/proto/version.go`](https://github.com/coder/coder/blob/main/coderd/aibridged/proto/version.go). + +`coderd` validates the version that a standalone replica advertises before it accepts the control connection. +Compatibility follows these rules: + +- The Gateway and `coderd` major versions must match. +- The Gateway minor version must be less than or equal to the `coderd` minor version. +- `coderd` rejects a standalone Gateway that advertises a newer minor version. + +A rejected replica receives an HTTP 400 response that reports the `client_api_version` and `server_api_version` values. +Coder build versions are not the compatibility criterion. + +For upgrade and rollback ordering, refer to [Version compatibility](./standalone.md#version-compatibility) in the standalone deployment guide. + ## Supported APIs -API support is broken down into two categories: +API support is divided into two categories: -- **Intercepted**: requests are intercepted, audited, and augmented - full AI Gateway functionality -- **Passthrough**: requests are proxied directly to the upstream, no auditing or augmentation takes place +- **Intercepted**: Requests are intercepted, audited, and augmented. +- **Passthrough**: Requests are proxied directly to the upstream provider without auditing or augmentation. Where relevant, both streaming and non-streaming requests are supported. +Paths are relative to the provider's base URL, such as `https://ai-gateway.example.com/openai/v1` or `https://ai-gateway.example.com/anthropic`. ### OpenAI +The OpenAI provider also serves the Azure OpenAI, Google, OpenRouter, Vercel, and OpenAI-compatible provider types. + #### Intercepted - [`/v1/chat/completions`](https://platform.openai.com/docs/api-reference/chat/create) @@ -29,18 +81,44 @@ Where relevant, both streaming and non-streaming requests are supported. #### Passthrough +- [`/v1/conversations(/*)`](https://platform.openai.com/docs/api-reference/conversations) - [`/v1/models(/*)`](https://platform.openai.com/docs/api-reference/models/list) +- [`/v1/responses/*`](https://platform.openai.com/docs/api-reference/responses/get) + +The legacy [`/v1/completions`](https://platform.openai.com/docs/api-reference/completions) API is deprecated and is not passed through. ### Anthropic +The Anthropic provider also serves the AWS Bedrock provider type. + #### Intercepted - [`/v1/messages`](https://docs.claude.com/en/api/messages) #### Passthrough +- [`/v1/messages/count_tokens`](https://docs.claude.com/en/api/messages-count-tokens) - [`/v1/models(/*)`](https://docs.claude.com/en/api/models-list) +- `/api/event_logging/*` + +### GitHub Copilot + +#### Intercepted + +- `/chat/completions` +- `/responses` +- `/v1/messages` + +#### Passthrough + +- `/models(/*)` +- `/agents/*` +- `/mcp/*` +- `/.well-known/*` + +Any route that is not listed above returns `404`. ## Troubleshooting -To report a bug, file a feature request, or view a list of known issues, please visit our [GitHub repository](https://github.com/coder/coder/issues). If you encounter issues with AI Gateway, please reach out to us via [Discord](https://discord.gg/coder). +To report a bug, file a feature request, or review known issues, visit the [Coder GitHub repository](https://github.com/coder/coder/issues). +For help with AI Gateway, visit the [Coder Discord](https://discord.gg/coder). diff --git a/docs/ai-coder/ai-gateway/setup.md b/docs/ai-coder/ai-gateway/setup.md index ebcb6f6cd7798..91f48c120edec 100644 --- a/docs/ai-coder/ai-gateway/setup.md +++ b/docs/ai-coder/ai-gateway/setup.md @@ -1,6 +1,9 @@ # Setup -AI Gateway runs inside the Coder control plane (`coderd`), requiring no separate compute to deploy or scale. Once enabled, `coderd` runs the `aibridged` in-memory and brokers traffic to your configured AI providers on behalf of authenticated users. +By default, AI Gateway runs inside the Coder control plane (`coderd`) and requires no separate compute. +In embedded mode, `coderd` runs the Gateway in memory and brokers traffic to your configured AI providers on behalf of authenticated users. + +If AI traffic needs dedicated compute, independent scaling, or a separate network endpoint, you can [deploy AI Gateway as a standalone service](./standalone.md). > [!NOTE] > Since v2.34, provider environment variables and flags are deprecated. @@ -11,8 +14,10 @@ AI Gateway runs inside the Coder control plane (`coderd`), requiring no separate ## Activation -AI Gateway must be enabled in deployment config before users can authenticate -to it. +The AI Gateway feature must be enabled in the Coder deployment configuration before +embedded or standalone Gateway instances can serve authenticated traffic. + +_AI Gateway is enabled by default as of v2.34._ ```sh export CODER_AI_GATEWAY_ENABLED=true @@ -21,7 +26,9 @@ coder server coder server --ai-gateway-enabled=true ``` -_AI Gateway is enabled by default as of v2.34._ +A standalone process does not read `CODER_AI_GATEWAY_ENABLED` from its own environment. +However, this setting must remain enabled on `coderd`. +It is required for Gateway key management endpoints to work and for standalone replicas to connect to the control plane. ## Configure Providers @@ -84,6 +91,9 @@ with `/var/lib/coder/ai-gateway-dumps` configured writes to Sensitive headers are redacted before dumps are written. Leave the value empty to disable dumping. +Each [standalone Gateway](./standalone.md) replica accepts the same API dump +settings and writes dumps to its own local disk. + > [!WARNING] > API dumps are intended for short diagnostic sessions only. Dump files contain > raw request and response data, which may include proprietary or sensitive @@ -138,6 +148,9 @@ stderr) or [`--log-json`](../../reference/cli/server.md#--log-json). For machine ingestion, set `--log-json` to a file path or `/dev/stderr` so that records are emitted as JSON. +This setting belongs to `coderd`. +A [standalone Gateway](./standalone.md) does not consume it. + Filter for AI Gateway records in your logging pipeline by matching on the `"interception log"` message. Each log line includes a `record_type` field that indicates the kind of event captured: diff --git a/docs/ai-coder/ai-gateway/standalone.md b/docs/ai-coder/ai-gateway/standalone.md new file mode 100644 index 0000000000000..08fe70c4e60bc --- /dev/null +++ b/docs/ai-coder/ai-gateway/standalone.md @@ -0,0 +1,319 @@ +# Deploy AI Gateway as a standalone service + +> [!NOTE] +> AI Gateway requires the [AI Governance Add-On](../ai-governance.md). +> As of Coder v2.32, deployments without the add-on will not be able to +> access AI Gateway. + +When AI traffic needs dedicated compute, independent scaling, or a separate network endpoint, you can deploy AI Gateway separately from the Coder control plane (`coderd`). + +A standalone AI Gateway serves client traffic on its own listener and maintains a control connection to `coderd`. +`coderd` continues to manage authentication, authorization, provider configuration, and AI session records. + +## Before you begin + +Standalone AI Gateway requires: + +- Coder v2.36.0 or later. +- A Coder license with the [AI Governance Add-On](../ai-governance.md). +- AI Gateway enabled on the Coder control plane with `CODER_AI_GATEWAY_ENABLED=true` or `--ai-gateway-enabled=true`. +- The full Coder image or a Coder binary that includes the `coder ai-gateway start` command. + +`coder ai-gateway start` does not read `CODER_AI_GATEWAY_ENABLED` from the standalone process. +However, this setting must remain enabled on `coderd` for Gateway key management and standalone control connections. + +## Create a Gateway key + +Each standalone replica uses a Gateway key to authenticate and establish its control connection to `coderd`. +Gateway key management requires site-level `ai_gateway_key` permissions, which only the built-in Owner role includes. +Coder custom roles are organization-scoped and cannot grant site-level permissions. + +Log in to the Coder CLI as an Owner or another user with these permissions, then create a dedicated key for the standalone deployment: + +```sh +coder login https://coder.example.com +coder ai-gateway keys create standalone-production +``` + +Save the key from the command output immediately because Coder does not display the plaintext value again. + +A single key can authenticate multiple replicas in the same deployment. +For independent rotation and revocation, use a separate key for each standalone deployment or environment. + +## Start a standalone process + +Set the Coder URL, Gateway key, and listener address, then start the Gateway: + +```sh +export CODER_URL=https://coder.example.com +export CODER_AI_GATEWAY_KEY='<AI Gateway key>' +export CODER_AI_GATEWAY_HTTP_ADDRESS=0.0.0.0:4001 +coder ai-gateway start +``` + +Use `CODER_AI_GATEWAY_KEY_FILE` instead of `CODER_AI_GATEWAY_KEY` to read the key from a file. +The standalone process does not require a user login or `CODER_SESSION_TOKEN` after you provide the Gateway key. + +The listener defaults to `127.0.0.1:4001`, which accepts connections only from the local host. +Set `CODER_AI_GATEWAY_HTTP_ADDRESS` to a routable address, as shown above, before other hosts or pods can reach the Gateway. + +The standalone Gateway fetches provider configuration from `coderd`. +Configure at least one [AI provider](./providers.md) in Coder before sending provider traffic through the Gateway. +The standalone Gateway does not use the deprecated [provider seed variables](./providers.md#database-management-of-providers). + +The listener uses HTTP by default. +Set both `CODER_AI_GATEWAY_TLS_CERT_FILE` and `CODER_AI_GATEWAY_TLS_KEY_FILE` to terminate TLS in the process. +For all command options, refer to [`coder ai-gateway start`](../../reference/cli/ai-gateway_start.md). + +## Run AI Gateway in Kubernetes + +To run the standalone Gateway as a Kubernetes workload, provide the same environment variables and network access to the Coder URL. +You can manage the workload with your own Kubernetes manifests or use the provided Helm chart. +The chart configures the Deployment, probes, and Service, plus an optional Ingress or `HTTPRoute`. + +To use the Kubernetes examples, install `kubectl` and configure access to the cluster where AI Gateway will run. +Install Helm if you use the chart. + +Create a namespace and store the Gateway key in a Kubernetes Secret: + +```sh +kubectl create namespace coder-ai-gateway +kubectl create secret generic coder-ai-gateway-key \ + --namespace coder-ai-gateway \ + --from-literal=key='<AI Gateway key>' +``` + +### Configure the Helm chart + +Create `ai-gateway-values.yaml` with at least the Coder URL and key Secret: + +```yaml +coder: + env: + - name: CODER_URL + value: https://coder.example.com + +aigateway: + keySecret: + name: coder-ai-gateway-key +``` + +The Gateway must be able to reach the Coder URL from every replica. +For an HTTPS URL signed by a private CA, set `aigateway.coderTLS.caSecret.name` to the Secret holding the CA bundle. +If Coder requires client mTLS, also set `aigateway.coderTLS.clientSecret.name`. + +The chart defaults to one replica, a `ClusterIP` Service, and a data-plane listener on port 4001. +It also enables a separate Prometheus listener on port 2112. +Ingress and `HTTPRoute` are disabled by default. + +For the complete chart configuration, including private CAs, mTLS, Ingress, Kubernetes Gateway API, and additional manifests, refer to the [AI Gateway Helm chart README](../../../helm/ai-gateway/README.md). + +### Install the Helm chart + +Install the chart version that matches the Coder control plane from GitHub Container Registry: + +```sh +helm install ai-gateway \ + oci://ghcr.io/coder/chart/coder-ai-gateway \ + --namespace coder-ai-gateway \ + --values ai-gateway-values.yaml \ + --version '<chart version>' +``` + +Chart versions omit the leading `v`, so use `2.36.0` rather than `v2.36.0`. + +When you install the chart directly from a Git checkout, set `coder.image.tag` explicitly and install `./helm/ai-gateway`. +Released chart packages select the matching Coder image by default. +If you need to run different `coderd` and Gateway versions, refer to [Version compatibility](#version-compatibility). + +### Validate the Helm deployment + +Wait for the Deployment to become available: + +```sh +kubectl rollout status deployment/coder-ai-gateway \ + --namespace coder-ai-gateway \ + --timeout=5m +``` + +The chart configures the following probes by default: + +- The liveness probe requests `/healthz` and verifies that the HTTP listener is serving. +- The readiness probe requests `/readyz` and verifies that the Gateway is connected to `coderd` and has completed its initial provider load. +- The startup probe is disabled by default. + +`/healthz` can return HTTP 200 while `/readyz` returns HTTP 503. +This occurs while the initial connection to `coderd` is being established, before the initial provider load, and whenever the control connection drops. +A replica can only become ready after its initial provider load succeeds, and it returns to ready whenever the control connection recovers. +Kubernetes removes an unready replica from the Service, so clients normally stop reaching it. +A request that still arrives waits for the control connection instead of failing. + +Port-forward the Service to inspect both endpoints: + +```sh +kubectl port-forward \ + --namespace coder-ai-gateway \ + service/coder-ai-gateway \ + 4001:80 +``` + +In another terminal, run: + +```sh +curl --fail http://127.0.0.1:4001/healthz +curl --fail http://127.0.0.1:4001/readyz +``` + +Both endpoints return HTTP 200 with an empty body when the replica is serving and ready. + +List Gateway keys and verify that the key has a recent heartbeat: + +```sh +coder ai-gateway keys list +``` + +The `LAST HEARTBEAT AT` column holds the timestamp. +The first heartbeat is recorded when the replica connects. +An active control connection updates the timestamp every 60 seconds. +Coder stores one timestamp per key, so a recent heartbeat on a shared key does not confirm that every replica is connected. +Check `/readyz` on each replica to verify individual health. + +## Route traffic to the standalone Gateway + +The standalone deployment does not move traffic automatically. +Configure AI Gateway Proxy and direct AI clients to send requests to the standalone endpoint. + +### AI Gateway Proxy + +[AI Gateway Proxy](./ai-gateway-proxy/index.md) remains part of the `coder server` process. +Configure its target to use the standalone Service, Ingress, `HTTPRoute`, or load balancer. + +If you installed the Helm chart, get the exact in-cluster Service URL from the chart notes: + +```sh +helm get notes ai-gateway --namespace coder-ai-gateway +``` + +If you use your own Kubernetes manifests, use the URL of the Service you created. +Set the Service URL on the Coder control plane, for example: + +```sh +CODER_AI_GATEWAY_PROXY_TARGET=http://coder-ai-gateway.coder-ai-gateway.svc.cluster.local:80 +``` + +Restart or upgrade the Coder deployment after changing this setting. +The proxy appends the provider name and request path to the target, so do not include query parameters. + +### Direct AI clients + +Clients that use `<Coder access URL>/api/v2/ai-gateway/<provider-name>/` continue to reach the embedded Gateway. +To route these clients to the standalone Gateway, replace the Coder access URL and embedded route prefix with the standalone endpoint while keeping the provider path. + +For example, if the Gateway is reachable at `https://ai-gateway.example.com` and the providers are configured, configure Claude Code with: + +```sh +ANTHROPIC_BASE_URL=https://ai-gateway.example.com/anthropic +``` + +Configure an OpenAI-compatible client with: + +```sh +OPENAI_BASE_URL=https://ai-gateway.example.com/openai/v1 +``` + +The standalone listener also accepts the equivalent `/api/v2/ai-gateway/<provider-name>/` paths for compatibility. +For full per-client configuration examples, refer to [Client Configuration](./clients/index.md). + +### Expose the standalone AI Gateway + +If clients cannot access the in-cluster Service, expose the Gateway through an Ingress, an `HTTPRoute`, or a load balancer Service. +In the provided Helm chart, set: + +```yaml +service: + type: LoadBalancer +``` + +Use TLS to protect credentials and AI traffic whenever they cross an untrusted network. +Prefer TLS termination at the Ingress or Kubernetes Gateway when your platform already manages certificates there. +To terminate TLS in the AI Gateway process, set `aigateway.listenerTLS.name` to an existing TLS Secret. + +If `CODER_AI_GATEWAY_PROXY_TARGET` uses HTTPS with a private CA, add that CA to the trust store of the `coderd` pods before changing the proxy target. +For the Coder Helm chart, you can mount a CA bundle with `coder.certs.secrets`. +This trust configuration is separate from `aigateway.coderTLS.caSecret`, which configures the connection from the standalone Gateway to `coderd`. + +## Scale replicas + +Run multiple replicas behind a Service or load balancer. +In the provided Helm chart, set: + +```yaml +coder: + replicaCount: 3 +``` + +The Service balances requests across ready replicas. +Each replica maintains its own control connection, local caches, concurrency limits, request metrics, logs, and traces. +All replicas fetch provider configuration from the same Coder deployment and write AI session records through `coderd`. +Cost control metrics are registered only on `coderd`, so `coder_ai_gateway_cost_control_*` series never appear on a replica's Prometheus listener. +Budget enforcement also runs in `coderd` over the control connection rather than per replica. +Sticky load balancing can improve cache efficiency, but it is not required for correctness. + +When you enable [API dumps](./setup.md#api-dumps), each replica writes dumps to its own local disk. +Use persistent storage if you need those files to survive pod replacement. + +The chart requests 1 CPU and 1 GiB of memory per replica and does not set resource limits. +Measure production traffic before changing requests, limits, or `CODER_AI_GATEWAY_MAX_CONCURRENCY`. +The chart does not create a Horizontal Pod Autoscaler or PodDisruptionBudget. + +## Migrate from the embedded Gateway + +Use a gradual cutover so that you can validate the standalone data plane before moving production traffic: + +1. Upgrade `coderd` to the target Coder version. +1. Keep `CODER_AI_GATEWAY_ENABLED=true` on `coderd`. +1. Create a dedicated Gateway key. +1. Deploy one standalone replica without changing client or proxy routing. +1. Verify `/readyz`, the key heartbeat, metrics, logs, and traces. +1. Send a test request directly to the standalone Gateway and confirm that the AI session appears in Coder. +1. Set `CODER_AI_GATEWAY_PROXY_TARGET` to the standalone endpoint. +1. Update direct client base URLs that should use the standalone endpoint. +1. Scale the standalone deployment after the canary path is stable. + +Keep `CODER_AI_GATEWAY_ENABLED=true` on `coderd` after the cutover. +The setting is required for standalone control connections and Coder features that use the in-process Gateway. + +## Roll back to the embedded Gateway + +To return traffic to the embedded Gateway: + +1. Remove `CODER_AI_GATEWAY_PROXY_TARGET`, or set it to `<Coder access URL>/api/v2/ai-gateway`. +1. Restart or upgrade `coderd` so the proxy target change takes effect. +1. Restore direct client base URLs to `<Coder access URL>/api/v2/ai-gateway/<provider-name>/`. +1. Verify requests and AI session records through the embedded route. +1. Scale down or uninstall the standalone deployment. +1. Delete the standalone Gateway key after all replicas have stopped. + +Delete the key with: + +```sh +coder ai-gateway keys delete standalone-production +``` + +Deleting a key prevents new connections immediately. +An established connection closes when its next heartbeat detects the deletion, within 60 seconds. +The replica then tries to reconnect, receives HTTP 401, and treats that as fatal: the process exits non-zero rather than retrying. +Stop the deployment before deleting its key. + +## Version compatibility + +The standalone Gateway image does not need to match the `coderd` image version exactly. +The components can connect when their AI Gateway API versions are compatible, and `coderd` checks compatibility whenever a Gateway replica connects. + +A replica may run at the same API version as `coderd` or an earlier minor version of the same major version, but never a newer one. +Sequence changes that move both components: + +- To upgrade, upgrade `coderd` first, then roll out the standalone Gateway release. +- To roll back, roll back the standalone Gateway first, then roll back `coderd`. + +Running the same Coder release on `coderd` and every standalone replica is the simplest way to stay compatible, but it is not required. diff --git a/docs/ai-coder/github-to-tasks.md b/docs/ai-coder/github-to-tasks.md index f75dbe850305a..a3462596b3f39 100644 --- a/docs/ai-coder/github-to-tasks.md +++ b/docs/ai-coder/github-to-tasks.md @@ -99,9 +99,6 @@ You must also set `coder-template-name` as part of this. The GHA example has thi ```sh # List all templates in your organization coder templates list - -# List templates in a specific organization -coder templates list --org your-org-name ``` You can also choose to modify the other [input parameters](https://github.com/coder/create-task-action?tab=readme-ov-file#inputs) to better fit your desired workflow. @@ -229,7 +226,7 @@ Generate a new token with these permissions at `https://<your-coder-url>/deploym **Solution:** -1. Verify the template name using: `coder templates list --org your-org-name` +1. Verify the template name using: `coder templates list` 1. Update the `coder-template-name` input in your workflow file to match exactly, or input secret or variable saved in GitHub 1. Ensure the template exists in the organization specified by `coder-organization` diff --git a/docs/ai-coder/tasks-migration.md b/docs/ai-coder/tasks-migration.md index 7fe2a01e266da..e279317fe255d 100644 --- a/docs/ai-coder/tasks-migration.md +++ b/docs/ai-coder/tasks-migration.md @@ -76,7 +76,7 @@ Below is a minimal illustrative example of a Coder Tasks template pre-2.28.0. terraform { required_providers { coder = { - source = "coder/coder + source = "coder/coder" } } } @@ -132,8 +132,8 @@ Example (**not** a full template): terraform { required_providers { coder = { - source = "coder/coder - version = ">= 2.13.0 + source = "coder/coder" + version = ">= 2.13.0" } } } diff --git a/docs/images/aibridge/group-ai-budget.png b/docs/images/aibridge/group-ai-budget.png new file mode 100644 index 0000000000000..c3461c569057c Binary files /dev/null and b/docs/images/aibridge/group-ai-budget.png differ diff --git a/docs/images/aibridge/user-override-ai-budget.png b/docs/images/aibridge/user-override-ai-budget.png new file mode 100644 index 0000000000000..f9d5f01087569 Binary files /dev/null and b/docs/images/aibridge/user-override-ai-budget.png differ diff --git a/docs/install/airgap.md b/docs/install/airgap.md index 06e99571c3706..4f3c32e4896a9 100644 --- a/docs/install/airgap.md +++ b/docs/install/airgap.md @@ -55,7 +55,7 @@ RUN mkdir -p /opt/terraform # The below step is optional if you wish to keep the existing version. # See ../../provisioner/terraform/install.go#L23-L24 # for supported Terraform versions. -ARG TERRAFORM_VERSION=1.11.0 +ARG TERRAFORM_VERSION=1.16.6 RUN apk update && \ curl -LOs https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ && unzip -o terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ diff --git a/docs/install/cli.md b/docs/install/cli.md index a8e5429143c82..66f24a99c9a1b 100644 --- a/docs/install/cli.md +++ b/docs/install/cli.md @@ -20,6 +20,8 @@ curl -L https://coder.com/install.sh | sh Refer to [GitHub releases](https://github.com/coder/coder/releases) for alternate installation methods (e.g. standalone binaries, system packages). +To run the Coder control plane on a Red Hat Enterprise Linux host or another RHEL-family distribution, refer to [Install Coder on RHEL-family Linux](./rpm.md). + ## Windows If you plan to use the built-in PostgreSQL database, ensure that the diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md index 8954a2b131a90..80a6fa6cd910d 100644 --- a/docs/install/kubernetes.md +++ b/docs/install/kubernetes.md @@ -197,6 +197,14 @@ helm upgrade coder coder-v2/coder \ -f values.yaml ``` +## Standalone AI Gateway Chart + +Coder also publishes a chart, `oci://ghcr.io/coder/chart/coder-ai-gateway`, +that runs [AI Gateway](../ai-coder/ai-gateway/index.md) as its own Deployment +alongside the control plane. Use it when you want to scale AI traffic +independently of `coderd`. For installation and configuration, visit +[Standalone AI Gateway](../ai-coder/ai-gateway/standalone.md). + ## Coder Observability Chart Use the [Observability Helm chart](https://github.com/coder/observability) for a diff --git a/docs/install/releases/index.md b/docs/install/releases/index.md index d89a7d4f190d8..e3d41a67f63e1 100644 --- a/docs/install/releases/index.md +++ b/docs/install/releases/index.md @@ -80,13 +80,13 @@ pages. | Release name | Release Date | Status | Latest Release | |--------------------------------------------------|-------------------|--------------------------|------------------------------------------------------------------| | [2.29](https://coder.com/changelog/coder-2-29) | December 02, 2025 | Extended Support Release | [v2.29.19](https://github.com/coder/coder/releases/tag/v2.29.19) | -| [2.30](https://coder.com/changelog/coder-2-30) | February 03, 2026 | Not Supported | [v2.30.9](https://github.com/coder/coder/releases/tag/v2.30.9) | | [2.31](https://coder.com/changelog/coder-2-31) | February 23, 2026 | Not Supported | [v2.31.14](https://github.com/coder/coder/releases/tag/v2.31.14) | | [2.32](https://coder.com/changelog/coder-2-32) | April 14, 2026 | Not Supported | [v2.32.10](https://github.com/coder/coder/releases/tag/v2.32.10) | -| [2.33](https://coder.com/changelog/coder-2-33) | May 05, 2026 | Security Support | [v2.33.11](https://github.com/coder/coder/releases/tag/v2.33.11) | -| [2.34](https://coder.com/changelog/coder-2-34) | June 02, 2026 | Stable (ESR) | [v2.34.6](https://github.com/coder/coder/releases/tag/v2.34.6) | -| [2.35](https://coder.com/changelog/coder-2-35-1) | July 07, 2026 | Mainline | [v2.35.2](https://github.com/coder/coder/releases/tag/v2.35.2) | -| 2.36 | | Not Released | N/A | +| [2.33](https://coder.com/changelog/coder-2-33) | May 05, 2026 | Not Supported | [v2.33.11](https://github.com/coder/coder/releases/tag/v2.33.11) | +| [2.34](https://coder.com/changelog/coder-2-34) | June 02, 2026 | Security Support | [v2.34.8](https://github.com/coder/coder/releases/tag/v2.34.8) | +| [2.35](https://coder.com/changelog/coder-2-35-1) | July 07, 2026 | Stable | [v2.35.4](https://github.com/coder/coder/releases/tag/v2.35.4) | +| [2.36](https://coder.com/changelog/coder-2-36) | August 04, 2026 | Mainline | [v2.36.0](https://github.com/coder/coder/releases/tag/v2.36.0) | +| 2.37 | | Not Released | N/A | <!-- RELEASE_CALENDAR_END --> > [!TIP] diff --git a/docs/install/rpm.md b/docs/install/rpm.md new file mode 100644 index 0000000000000..947fb02c983c1 --- /dev/null +++ b/docs/install/rpm.md @@ -0,0 +1,139 @@ +--- +title: Install Coder on RHEL-family Linux +--- + +This guide is for a Coder deployment administrator who runs the Coder control plane on a single Red Hat Enterprise Linux host or another RHEL-family distribution. +Coder publishes an RPM with every release, and `systemd` manages the server like any other service. +For a multi-replica deployment, refer to [Kubernetes](./kubernetes.md) or [OpenShift](./openshift.md) instead. +To install the CLI on a client machine that connects to an existing deployment, refer to [Install the Coder CLI](./cli.md). + +## Supported distributions + +Coder builds one RPM per release and architecture. +The install script reads `/etc/os-release` and selects the RPM when `ID` or `ID_LIKE` names a Fedora-family or openSUSE-family distribution. + +| Distribution | Versions | `ID` | +|--------------------------|------------------|----------------------------------------| +| Red Hat Enterprise Linux | 8, 9, 10 | `rhel` | +| Rocky Linux | 8, 9, 10 | `rocky` | +| AlmaLinux | 8, 9, 10 | `almalinux` | +| CentOS Stream | 9, 10 | `centos` | +| Amazon Linux | 2, 2023 | `amzn` | +| Fedora | Current releases | `fedora` | +| openSUSE | Leap, Tumbleweed | `opensuse-leap`, `opensuse-tumbleweed` | + +Every RHEL-family distribution in the table lists `fedora` in `ID_LIKE`, and both openSUSE variants list `opensuse`. +A derivative that isn't listed here takes the same path when its `ID_LIKE` includes `fedora`. +Each release publishes RPMs for `amd64`, `arm64`, and `armv7`. + +## Requirements + +- A host running one of the distributions above. +- 2 CPU cores and 4 GB of memory to evaluate Coder. + For production sizing, refer to the [Coder Validated Architecture](../admin/infrastructure/validated-architectures/index.md). +- `root` or `sudo` access. +- An external PostgreSQL database for anything beyond a proof of concept. + Refer to [Using an external database](../tutorials/external-database.md). + +## Install the package + +The install script detects the distribution and installs the matching RPM. +Install the package directly with `dnf` or `rpm` when the host can't run a piped shell script, or when you stage packages yourself. + +<div class="tabs"> + +### Install script + +```sh +curl -fsSL https://coder.com/install.sh | sh +``` + +The script installs the latest mainline release. +Pass `--stable` for the latest stable release, or `--version X.Y.Z` for a specific version. +Pass `--dry-run` to print the commands without running them. + +### dnf + +1. Download the RPM for your version and architecture from [GitHub releases](https://github.com/coder/coder/releases). +2. Install the package. + + ```sh + sudo dnf install ./coder_<version>_linux_amd64.rpm + ``` + +On hosts that use `yum`, run `sudo yum install` instead. + +### rpm + +Install or upgrade the package in place. +This is the command the install script runs. + +```sh +sudo rpm -U coder_<version>_linux_amd64.rpm +``` + +</div> + +The package installs the `coder` binary to `/usr/bin/coder`, the configuration file to `/etc/coder.d/coder.env`, and two `systemd` units: `coder.service` for the control plane and `coder-workspace-proxy.service` for an optional [workspace proxy](../admin/networking/workspace-proxies.md). +It also creates the unprivileged `coder` user that the service runs as. + +To stage the RPM on a host with no internet access, refer to [Air-gapped deployments](./airgap.md). + +## Configure the server + +`coder.service` doesn't start while `/etc/coder.d/coder.env` is empty. +Set your configuration before you start the service. + +1. Open the configuration file. + + ```sh + sudo vi /etc/coder.d/coder.env + ``` + +2. Set `CODER_ACCESS_URL` to the external URL that users and workspaces connect to. +3. Set `CODER_PG_CONNECTION_URL` to your PostgreSQL connection string. +4. Save the file. + +The file ships with the TLS and HTTP address variables commented in place. +For every server option, refer to the [`coder server`](../reference/cli/server.md) command reference. + +## Start the service + +1. Start Coder now and on every boot. + + ```sh + sudo systemctl enable --now coder + ``` + +2. Confirm that the service is running. + + ```console + $ systemctl is-active coder + active + ``` + +3. Read the startup logs. + + ```sh + journalctl -u coder.service -b + ``` + +To test a configuration change without the service manager, run `coder server` in the foreground. + +## SELinux and container runtimes in workspaces + +SELinux affects workspaces that run their own container runtime. +If your templates run Docker or Podman inside a workspace on a RHEL-family node, refer to [Docker in workspaces](../admin/templates/extending-templates/docker-in-workspaces.md) for the runtime requirements. + +## Hardened base images + +Coder publishes a container image built on Red Hat's UBI9-minimal base through [Iron Bank](https://ironbank.dso.mil/), the Department of Defense hardened container registry. +Choose that image when your compliance program requires an accredited base image. +The Iron Bank image is a container image, so deploy it with [Kubernetes](./kubernetes.md) or [OpenShift](./openshift.md) rather than through the RPM. + +## Next steps + +- [Set up your control plane](../admin/setup/index.md) +- [Create your first template](../tutorials/template-from-scratch.md) +- [Upgrade Coder](./upgrade.md) +- [Uninstall Coder](./uninstall.md) diff --git a/docs/manifest.json b/docs/manifest.json index cb71529fb93a9..7c0e564c001bc 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -155,6 +155,12 @@ "path": "./install/openshift.md", "icon_path": "./images/icons/openshift.svg" }, + { + "title": "RPM (RHEL family)", + "description": "Install the Coder control plane from an RPM package on Red Hat Enterprise Linux and other RHEL-family distributions.", + "path": "./install/rpm.md", + "icon_path": "./images/icons/system.svg" + }, { "title": "Cloud Providers", "description": "Install guides and example templates for deploying Coder on AWS, Google Cloud, and Azure.", @@ -423,6 +429,11 @@ "path": "./admin/setup/appearance.md", "state": ["premium"] }, + { + "title": "Configuration Reference", + "description": "Searchable list of every Coder server setting with its environment variable, CLI flag, and YAML key", + "path": "./admin/setup/configuration-reference.md" + }, { "title": "Telemetry", "description": "Learn what usage telemetry Coder collects, why it is collected, and how to opt out.", @@ -1074,7 +1085,7 @@ }, { "title": "Spend Management", - "description": "Set spend limits and track AI costs for Coder Agents with admin-only controls.", + "description": "Track Coder Agents usage and configure new spend controls with AI Gateway budgets.", "path": "./ai-coder/agents/platform-controls/usage-insights.md", "state": ["beta"] }, @@ -1189,7 +1200,7 @@ }, { "title": "AI Gateway", - "description": "Govern and observe AI coding traffic with AI Gateway, an LLM proxy built into the Coder control plane.", + "description": "Govern and observe AI coding traffic with AI Gateway.", "path": "./ai-coder/ai-gateway/index.md", "icon_path": "./images/icons/api.svg", "state": ["ai governance add-on"], @@ -1200,6 +1211,12 @@ "path": "./ai-coder/ai-gateway/setup.md", "state": ["ai governance add-on"] }, + { + "title": "Standalone Deployment", + "description": "Deploy and operate AI Gateway as a standalone service", + "path": "./ai-coder/ai-gateway/standalone.md", + "state": ["ai governance add-on"] + }, { "title": "Authentication", "description": "Authenticate AI coding tools against AI Gateway using your Coder API token.", @@ -1318,14 +1335,14 @@ "state": ["ai governance add-on"] }, { - "title": "Cost Controls", - "description": "Manage AI Gateway spend with budgets, spend limits, and budget notifications.", + "title": "AI Governance Cost Control", + "description": "Configure AI Gateway budgets, enforcement, notifications, and spend reporting.", "path": "./ai-coder/ai-gateway/cost-controls.md", - "state": ["ai governance add-on"] + "state": ["premium"] }, { "title": "Reference", - "description": "Technical reference for AI Gateway, including the aibridged component that runs inside coderd.", + "description": "Technical reference for AI Gateway, including deployment topologies, version compatibility, and supported APIs.", "path": "./ai-coder/ai-gateway/reference.md", "state": ["ai governance add-on"] }, @@ -2153,6 +2170,11 @@ "description": "Update a secret", "path": "reference/cli/secret_update.md" }, + { + "title": "secret import", + "description": "Import secrets from a file", + "path": "reference/cli/secret_import.md" + }, { "title": "secret list", "description": "List secrets, or show one by name", diff --git a/docs/reference/api/aigateway.md b/docs/reference/api/aigateway.md index 3d8ed690729b8..18d1f862e3877 100644 --- a/docs/reference/api/aigateway.md +++ b/docs/reference/api/aigateway.md @@ -195,6 +195,31 @@ Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward com "models": [ "string" ], + "network_call_logs": [ + { + "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" + } + ], + "network_calls": { + "blocked": 0, + "total": 0 + }, + "network_domain_count": 0, + "network_top_domains": [ + { + "count": 0, + "domain": "string" + } + ], "page_ended_at": "2019-08-24T14:15:22Z", "page_started_at": "2019-08-24T14:15:22Z", "providers": [ diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index cff040db9af38..1717b38a11e7f 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1035,6 +1035,50 @@ curl -X DELETE http://coder-server:8080/api/v2/groups/{group}/ai/budget \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get group AI spend + +### Code samples + +```sh +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/groups/{group}/ai/spend \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/groups/{group}/ai/spend` + +Returns the AI spend limit and aggregate spend for the group. + +### Parameters + +| Name | In | Type | Required | Description | +|---------|------|--------------|----------|-------------| +| `group` | path | string(uuid) | true | Group ID | + +### Example responses + +> 200 Response + +```json +{ + "current_spend_micros": 0, + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "period_end": "2019-08-24T14:15:22Z", + "period_start": "2019-08-24T14:15:22Z", + "spend_limit_micros": 0, + "total_spend_limit_micros": 0 +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GroupAISpend](schemas.md#codersdkgroupaispend) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get group members by group ID ### Code samples @@ -1960,7 +2004,8 @@ Unknown or unreadable group IDs are silently omitted. { "current_spend_micros": 0, "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", - "spend_limit_micros": 0 + "spend_limit_micros": 0, + "total_spend_limit_micros": 0 } ], "period_end": "2019-08-24T14:15:22Z", @@ -3856,12 +3901,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```sh # Example request using curl -curl -X GET http://coder-server:8080/api/v2/users/{user}/ai/budget \ +curl -X GET http://coder-server:8080/api/v2/users/{user}/ai/budget/override \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /api/v2/users/{user}/ai/budget` +`GET /api/v2/users/{user}/ai/budget/override` ### Parameters @@ -3897,13 +3942,13 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```sh # Example request using curl -curl -X PUT http://coder-server:8080/api/v2/users/{user}/ai/budget \ +curl -X PUT http://coder-server:8080/api/v2/users/{user}/ai/budget/override \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`PUT /api/v2/users/{user}/ai/budget` +`PUT /api/v2/users/{user}/ai/budget/override` > Body parameter @@ -3949,11 +3994,11 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```sh # Example request using curl -curl -X DELETE http://coder-server:8080/api/v2/users/{user}/ai/budget \ +curl -X DELETE http://coder-server:8080/api/v2/users/{user}/ai/budget/override \ -H 'Coder-Session-Token: API_KEY' ``` -`DELETE /api/v2/users/{user}/ai/budget` +`DELETE /api/v2/users/{user}/ai/budget/override` ### Parameters @@ -3995,11 +4040,13 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/ai/spend \ ```json { "current_spend_micros": 0, + "effective_budget": { + "limit_source": "user_override", + "spend_limit_micros": 0 + }, "effective_group_id": "85e2b926-ddfb-4c66-b68e-b66e5acec6c0", - "limit_source": "user_override", "period_end": "2019-08-24T14:15:22Z", "period_start": "2019-08-24T14:15:22Z", - "spend_limit_micros": 0, "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 4715ff82458cb..9ca81e264d15b 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -289,6 +289,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_agent_context_sync": true, "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 582c2051ee600..2d5da928e1df4 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -622,22 +622,22 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|-----------------------|------------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client` | string | false | | | -| `ended_at` | string | false | | | -| `id` | string | false | | | -| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | -| `last_active_at` | string | false | | | -| `last_prompt` | string | false | | | -| `metadata` | object | false | | | -| » `[any property]` | any | false | | | -| `models` | array of string | false | | | -| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network calls made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | -| `providers` | array of string | false | | | -| `started_at` | string | false | | | -| `threads` | integer | false | | | -| `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|------------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client` | string | false | | | +| `ended_at` | string | false | | | +| `id` | string | false | | | +| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `last_active_at` | string | false | | | +| `last_prompt` | string | false | | | +| `metadata` | object | false | | | +| » `[any property]` | any | false | | | +| `models` | array of string | false | | | +| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network requests made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | +| `providers` | array of string | false | | | +| `started_at` | string | false | | | +| `threads` | integer | false | | | +| `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | ## codersdk.AIBridgeSessionNetworkCallSummary @@ -655,6 +655,22 @@ | `blocked` | integer | false | | | | `total` | integer | false | | | +## codersdk.AIBridgeSessionNetworkDomain + +```json +{ + "count": 0, + "domain": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|---------|----------|--------------|-------------| +| `count` | integer | false | | | +| `domain` | string | false | | | + ## codersdk.AIBridgeSessionThreadsResponse ```json @@ -675,6 +691,31 @@ "models": [ "string" ], + "network_call_logs": [ + { + "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" + } + ], + "network_calls": { + "blocked": 0, + "total": 0 + }, + "network_domain_count": 0, + "network_top_domains": [ + { + "count": 0, + "domain": "string" + } + ], "page_ended_at": "2019-08-24T14:15:22Z", "page_started_at": "2019-08-24T14:15:22Z", "providers": [ @@ -758,21 +799,25 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|-----------------------|----------------------------------------------------------------------------------------|----------|--------------|-------------| -| `client` | string | false | | | -| `ended_at` | string | false | | | -| `id` | string | false | | | -| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | -| `metadata` | object | false | | | -| » `[any property]` | any | false | | | -| `models` | array of string | false | | | -| `page_ended_at` | string | false | | | -| `page_started_at` | string | false | | | -| `providers` | array of string | false | | | -| `started_at` | string | false | | | -| `threads` | array of [codersdk.AIBridgeThread](#codersdkaibridgethread) | false | | | -| `token_usage_summary` | [codersdk.AIBridgeSessionThreadsTokenUsage](#codersdkaibridgesessionthreadstokenusage) | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|------------------------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client` | string | false | | | +| `ended_at` | string | false | | | +| `id` | string | false | | | +| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `metadata` | object | false | | | +| » `[any property]` | any | false | | | +| `models` | array of string | false | | | +| `network_call_logs` | array of [codersdk.AgentFirewallLog](#codersdkagentfirewalllog) | false | | Network call logs is the chronological list of individual network calls made during the session, holding the earliest calls up to a server-side cap. NetworkCalls remains authoritative for whole-session totals, so a shorter list than NetworkCalls.Total means the list was truncated. Empty when the session did not pass through Agent Firewall. | +| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network calls made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | +| `network_domain_count` | integer | false | | | +| `network_top_domains` | array of [codersdk.AIBridgeSessionNetworkDomain](#codersdkaibridgesessionnetworkdomain) | false | | Network top domains lists the most contacted destination hosts, ordered by call count descending. NetworkDomainCount is the total number of distinct domains, used to render a "+N more" overflow beyond the listed domains. | +| `page_ended_at` | string | false | | | +| `page_started_at` | string | false | | | +| `providers` | array of string | false | | | +| `started_at` | string | false | | | +| `threads` | array of [codersdk.AIBridgeThread](#codersdkaibridgethread) | false | | | +| `token_usage_summary` | [codersdk.AIBridgeSessionThreadsTokenUsage](#codersdkaibridgesessionthreadstokenusage) | false | | | ## codersdk.AIBridgeSessionThreadsTokenUsage @@ -938,6 +983,22 @@ | `server_url` | string | false | | | | `tool` | string | false | | | +## codersdk.AIBudgetLimit + +```json +{ + "limit_source": "user_override", + "spend_limit_micros": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|--------------------------------------------------------------|----------|--------------|-------------| +| `limit_source` | [codersdk.AIBudgetLimitSource](#codersdkaibudgetlimitsource) | false | | | +| `spend_limit_micros` | integer | false | | | + ## codersdk.AIBudgetLimitSource ```json @@ -1055,22 +1116,6 @@ | `last_heartbeat_at` | string | false | | | | `name` | string | false | | | -## codersdk.AIGroupBudget - -```json -{ - "limit_source": "user_override", - "spend_limit_micros": 0 -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------------------|--------------------------------------------------------------|----------|--------------|-------------| -| `limit_source` | [codersdk.AIBudgetLimitSource](#codersdkaibudgetlimitsource) | false | | | -| `spend_limit_micros` | integer | false | | | - ## codersdk.AIProvider ```json @@ -5129,6 +5174,7 @@ This is required on creation to enable a user-flow of validating a template work ```json { "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "name": "string", @@ -5138,13 +5184,14 @@ This is required on creation to enable a user-flow of validating a template work ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|--------|----------|--------------|-------------| -| `description` | string | false | | | -| `env_name` | string | false | | | -| `file_path` | string | false | | | -| `name` | string | false | | | -| `value` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|---------|----------|--------------|-------------| +| `description` | string | false | | | +| `enabled` | boolean | false | | | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `name` | string | false | | | +| `value` | string | false | | | ## codersdk.CreateUserSkillRequest @@ -5810,6 +5857,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_agent_context_sync": true, "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, @@ -6420,6 +6468,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "disable_owner_workspace_exec": true, "disable_password_auth": true, "disable_path_apps": true, + "disable_workspace_agent_context_sync": true, "disable_workspace_sharing": true, "docs_url": { "forceQuery": true, @@ -6814,6 +6863,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `disable_owner_workspace_exec` | boolean | false | | | | `disable_password_auth` | boolean | false | | | | `disable_path_apps` | boolean | false | | | +| `disable_workspace_agent_context_sync` | boolean | false | | | | `disable_workspace_sharing` | boolean | false | | | | `docs_url` | [serpent.URL](#serpenturl) | false | | | | `enable_authz_recording` | boolean | false | | | @@ -7242,9 +7292,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai-gateway-cost-control`, `ai-gateway-seat-exclusion`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-capable-licensing`, `workspace-usage` | +| Value(s) | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai-gateway-seat-exclusion`, `auto-fill-parameters`, `chat-advisor`, `chat-virtual-desktop`, `example`, `mcp-server-http`, `minimum-implicit-member`, `nats_pubsub`, `notifications`, `oauth2`, `workspace-build-updates`, `workspace-capable-licensing`, `workspace-usage` | ## codersdk.ExternalAPIKeyScopes @@ -7750,6 +7800,30 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `spend_limit_micros` | integer | false | | | | `updated_at` | string | false | | | +## codersdk.GroupAISpend + +```json +{ + "current_spend_micros": 0, + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "period_end": "2019-08-24T14:15:22Z", + "period_start": "2019-08-24T14:15:22Z", + "spend_limit_micros": 0, + "total_spend_limit_micros": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|---------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `current_spend_micros` | integer | false | | Current spend micros is the group's spend over the current budget period. | +| `group_id` | string | false | | | +| `period_end` | string | false | | Period end is the exclusive upper bound of the current budget period. | +| `period_start` | string | false | | Period start is the inclusive lower bound of the current budget period. | +| `spend_limit_micros` | integer | false | | Spend limit micros is the group's configured AI spend budget per member. Null when the group has no configured budget. | +| `total_spend_limit_micros` | integer | false | | Total spend limit micros is the currently configured combined budget of the members attributed to this group, with each member's override replacing their share. Null when the group has no budget, and zero when no members are attributed to it. | + ## codersdk.GroupMemberAISpend ```json @@ -7769,7 +7843,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | Name | Type | Required | Restrictions | Description | |----------------------|--------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `effective_group_id` | string | false | | Effective group ID is the user's effective budget group within the queried group's organization, falling back to the Everyone group when no budget applies. Null when the effective group belongs to a different organization than the queried group. | -| `group_budget` | [codersdk.AIGroupBudget](#codersdkaigroupbudget) | false | | Group budget is the budget when the queried group is this user's effective budget source. Null when the user's budget resolves to another group or no budget applies to the user. | +| `group_budget` | [codersdk.AIBudgetLimit](#codersdkaibudgetlimit) | false | | Group budget is the budget when the queried group is this user's effective budget source. Null when the user's budget resolves to another group or no budget applies to the user. | | `group_spend_micros` | integer | false | | Group spend micros is the user's spend attributed to the queried group over the current budget period. | | `user_id` | string | false | | | @@ -9280,17 +9354,19 @@ Only certain features set these fields: - FeatureManagedAgentLimit| { "current_spend_micros": 0, "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", - "spend_limit_micros": 0 + "spend_limit_micros": 0, + "total_spend_limit_micros": 0 } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------| -| `current_spend_micros` | integer | false | | Current spend micros is the group's spend over the current budget period. | -| `group_id` | string | false | | | -| `spend_limit_micros` | integer | false | | Spend limit micros is the group's configured AI spend limit. Null when the group has no configured budget. | +| Name | Type | Required | Restrictions | Description | +|----------------------------|---------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `current_spend_micros` | integer | false | | Current spend micros is the group's spend over the current budget period. | +| `group_id` | string | false | | | +| `spend_limit_micros` | integer | false | | Spend limit micros is the group's configured AI spend budget per member. Null when the group has no configured budget. | +| `total_spend_limit_micros` | integer | false | | Total spend limit micros is the currently configured combined budget of the members attributed to this group, with each member's override replacing their share. Null when the group has no budget, and zero when no members are attributed to it. | ## codersdk.OrganizationGroupsAISpend @@ -9300,7 +9376,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit| { "current_spend_micros": 0, "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", - "spend_limit_micros": 0 + "spend_limit_micros": 0, + "total_spend_limit_micros": 0 } ], "period_end": "2019-08-24T14:15:22Z", @@ -13824,6 +13901,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "value": "string" @@ -13832,12 +13910,13 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|--------|----------|--------------|-------------| -| `description` | string | false | | | -| `env_name` | string | false | | | -| `file_path` | string | false | | | -| `value` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|---------|----------|--------------|-------------| +| `description` | string | false | | | +| `enabled` | boolean | false | | | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `value` | string | false | | | ## codersdk.UpdateUserSkillRequest @@ -14023,9 +14102,9 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|----------------------|---------|----------|--------------|-------------| -| `spend_limit_micros` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|---------|----------|--------------|-----------------------------------------------------------| +| `spend_limit_micros` | integer | false | | Spend limit micros must not exceed MaxAISpendLimitMicros. | ## codersdk.UpsertUserAIBudgetOverrideRequest @@ -14041,7 +14120,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | Name | Type | Required | Restrictions | Description | |----------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------| | `group_id` | string | true | | Group ID is the group the user's spend is attributed to. The user must be a member of this group. | -| `spend_limit_micros` | integer | false | | | +| `spend_limit_micros` | integer | false | | Spend limit micros must not exceed MaxAISpendLimitMicros. | ## codersdk.UpsertWorkspaceAgentPortShareRequest @@ -14199,26 +14278,27 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "current_spend_micros": 0, + "effective_budget": { + "limit_source": "user_override", + "spend_limit_micros": 0 + }, "effective_group_id": "85e2b926-ddfb-4c66-b68e-b66e5acec6c0", - "limit_source": "user_override", "period_end": "2019-08-24T14:15:22Z", "period_start": "2019-08-24T14:15:22Z", - "spend_limit_micros": 0, "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|--------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `current_spend_micros` | integer | false | | Current spend micros is the user's spend on their effective group over the current budget period. | -| `effective_group_id` | string | false | | Effective group ID is the group the spend is attributed to, falling back to the Everyone group when no budget applies. Null only when the user has no organization membership. | -| `limit_source` | [codersdk.AIBudgetLimitSource](#codersdkaibudgetlimitsource) | false | | Limit source identifies which tier produced the limit. Null when no budget applies. | -| `period_end` | string | false | | Period end is the exclusive upper bound of the current budget period. | -| `period_start` | string | false | | Period start is the inclusive lower bound of the current budget period. | -| `spend_limit_micros` | integer | false | | Spend limit micros is the effective spend limit in micro-units. Null when no budget applies to the user (unlimited). | -| `user_id` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|--------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `current_spend_micros` | integer | false | | Current spend micros is the user's spend on their effective group over the current budget period. | +| `effective_budget` | [codersdk.AIBudgetLimit](#codersdkaibudgetlimit) | false | | Effective budget is the spend limit that applies to the user, whether it came from a group budget or a user override. Null when no budget applies, leaving the user's spend unlimited. | +| `effective_group_id` | string | false | | Effective group ID is the group the spend is attributed to, falling back to the Everyone group when no budget applies. Null only when the user has no organization membership. | +| `period_end` | string | false | | Period end is the exclusive upper bound of the current budget period. | +| `period_start` | string | false | | Period start is the inclusive lower bound of the current budget period. | +| `user_id` | string | false | | | ## codersdk.UserActivity @@ -14523,6 +14603,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -14533,15 +14614,16 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|--------|----------|--------------|-------------| -| `created_at` | string | false | | | -| `description` | string | false | | | -| `env_name` | string | false | | | -| `file_path` | string | false | | | -| `id` | string | false | | | -| `name` | string | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|---------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `created_at` | string | false | | | +| `description` | string | false | | | +| `enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `updated_at` | string | false | | | ## codersdk.UserSkill diff --git a/docs/reference/api/secrets.md b/docs/reference/api/secrets.md index 74b44c0ec239a..d1eee9875d2fa 100644 --- a/docs/reference/api/secrets.md +++ b/docs/reference/api/secrets.md @@ -28,6 +28,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -47,16 +48,17 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | false | | | -| `» description` | string | false | | | -| `» env_name` | string | false | | | -| `» file_path` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» name` | string | false | | | -| `» updated_at` | string(date-time) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------|-------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» description` | string | false | | | +| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. | +| `» env_name` | string | false | | | +| `» file_path` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» name` | string | false | | | +| `» updated_at` | string(date-time) | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -79,6 +81,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \ ```json { "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "name": "string", @@ -101,6 +104,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -156,6 +160,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -178,16 +183,17 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \ Status Code **201** -| Name | Type | Required | Restrictions | Description | -|-----------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | false | | | -| `» description` | string | false | | | -| `» env_name` | string | false | | | -| `» file_path` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» name` | string | false | | | -| `» updated_at` | string(date-time) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------|-------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» description` | string | false | | | +| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. | +| `» env_name` | string | false | | | +| `» file_path` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» name` | string | false | | | +| `» updated_at` | string(date-time) | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -219,6 +225,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets/{name} \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -281,6 +288,7 @@ curl -X PATCH http://coder-server:8080/api/v2/users/{user}/secrets/{name} \ ```json { "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "value": "string" @@ -303,6 +311,7 @@ curl -X PATCH http://coder-server:8080/api/v2/users/{user}/secrets/{name} \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", diff --git a/docs/reference/cli/secret.md b/docs/reference/cli/secret.md index 7d022f3dfd693..1413650750e5b 100644 --- a/docs/reference/cli/secret.md +++ b/docs/reference/cli/secret.md @@ -24,6 +24,10 @@ coder secret $ echo -n "$NEW_SECRET_VALUE" | coder secret update api-key --description "Rotated API key" --env API_KEY --file "~/.api-key" + - Import secrets from a file: + + $ coder secret import ./secrets.env + - List your secrets: $ coder secret list @@ -39,9 +43,12 @@ coder secret ## Subcommands -| Name | Purpose | -|-------------------------------------------|-----------------------------------| -| [<code>create</code>](./secret_create.md) | Create a secret | -| [<code>update</code>](./secret_update.md) | Update a secret | -| [<code>list</code>](./secret_list.md) | List secrets, or show one by name | -| [<code>delete</code>](./secret_delete.md) | Delete a secret | +| Name | Purpose | +|---------------------------------------------|---------------------------------------------------| +| [<code>create</code>](./secret_create.md) | Create a secret | +| [<code>update</code>](./secret_update.md) | Update a secret | +| [<code>import</code>](./secret_import.md) | Import secrets from a file | +| [<code>enable</code>](./secret_enable.md) | Enable a secret so it is injected into workspaces | +| [<code>disable</code>](./secret_disable.md) | Disable a secret without removing it | +| [<code>list</code>](./secret_list.md) | List secrets, or show one by name | +| [<code>delete</code>](./secret_delete.md) | Delete a secret | diff --git a/docs/reference/cli/secret_create.md b/docs/reference/cli/secret_create.md index df9086f6930ff..84e09ec2940a1 100644 --- a/docs/reference/cli/secret_create.md +++ b/docs/reference/cli/secret_create.md @@ -48,3 +48,12 @@ Name of the workspace environment variable that this secret will set. | Type | <code>string</code> | Workspace file path where this secret will be written. Must start with ~/ or /. + +### --enabled + +| | | +|---------|-------------------| +| Type | <code>bool</code> | +| Default | <code>true</code> | + +Whether the secret is injected into workspaces. An enabled secret must set --env or --file; pass --enabled=false to store a secret without injecting it. diff --git a/docs/reference/cli/secret_disable.md b/docs/reference/cli/secret_disable.md new file mode 100644 index 0000000000000..1ee4c3de822d0 --- /dev/null +++ b/docs/reference/cli/secret_disable.md @@ -0,0 +1,10 @@ +<!-- DO NOT EDIT | GENERATED CONTENT --> +# secret disable + +Disable a secret without removing it + +## Usage + +```console +coder secret disable <name> +``` diff --git a/docs/reference/cli/secret_enable.md b/docs/reference/cli/secret_enable.md new file mode 100644 index 0000000000000..56d83a38673ca --- /dev/null +++ b/docs/reference/cli/secret_enable.md @@ -0,0 +1,10 @@ +<!-- DO NOT EDIT | GENERATED CONTENT --> +# secret enable + +Enable a secret so it is injected into workspaces + +## Usage + +```console +coder secret enable <name> +``` diff --git a/docs/reference/cli/secret_import.md b/docs/reference/cli/secret_import.md new file mode 100644 index 0000000000000..0c94b04d22697 --- /dev/null +++ b/docs/reference/cli/secret_import.md @@ -0,0 +1,26 @@ +<!-- DO NOT EDIT | GENERATED CONTENT --> +# secret import + +Import secrets from a file + +## Usage + +```console +coder secret import [flags] <file> +``` + +## Description + +```console +Every key in the file becomes a secret. Keys allowed as environment variable names are injected into workspaces under the same name. The import is all or nothing, and existing secrets are never overwritten. Pass - to read the file from non-interactive stdin (pipe or redirect). +``` + +## Options + +### --input-format + +| | | +|------|------------------------------| +| Type | <code>env\|json\|yaml</code> | + +Format of the secrets file. Inferred from the file extension when unset, and required when reading from stdin. diff --git a/docs/reference/cli/secret_list.md b/docs/reference/cli/secret_list.md index 9bffcd6a495b9..2a93cf659b6db 100644 --- a/docs/reference/cli/secret_list.md +++ b/docs/reference/cli/secret_list.md @@ -23,10 +23,10 @@ Secret values are omitted from the output. ### -c, --column -| | | -|---------|---------------------------------------------------------------| -| Type | <code>[created\|name\|updated\|env\|file\|description]</code> | -| Default | <code>name,created,updated,env,file,description</code> | +| | | +|---------|------------------------------------------------------------------------| +| Type | <code>[created\|name\|updated\|env\|file\|enabled\|description]</code> | +| Default | <code>name,created,updated,env,file,enabled,description</code> | Columns to display in table output. diff --git a/docs/reference/cli/secret_update.md b/docs/reference/cli/secret_update.md index 83b03b2b3a599..fd628f03ebe4d 100644 --- a/docs/reference/cli/secret_update.md +++ b/docs/reference/cli/secret_update.md @@ -12,7 +12,7 @@ coder secret update [flags] <name> ## Description ```console -At least one of --value, --description, --env, or --file must be specified. Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect). +At least one of --value, --description, --env, --file, or --enabled must be specified. Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect). ``` ## Options @@ -48,3 +48,11 @@ Name of the workspace environment variable that this secret will set. Pass an em | Type | <code>string</code> | Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it. + +### --enabled + +| | | +|------|-------------------| +| Type | <code>bool</code> | + +Whether the secret is injected into workspaces. An enabled secret must keep at least one of --env or --file; pass --enabled=false to stop injecting it without deleting it. diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index f4ae058d8781f..61e353573c8a3 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -1201,6 +1201,16 @@ Disable workspace sharing. Workspace ACL checking is disabled and only owners ca Disable chat sharing. Chat ACL checking is disabled and only owners can access their chats. +### --disable-workspace-agent-context-sync + +| | | +|-------------|----------------------------------------------------------| +| Type | <code>bool</code> | +| Environment | <code>$CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC</code> | +| YAML | <code>disableWorkspaceAgentContextSync</code> | + +Stop persisting workspace agent context snapshots (instructions, skills, and MCP state used for pinned chat context). When set, coderd rejects agent context pushes as unimplemented and agents stop sending them; chats cannot pin workspace context. Use this to shed the database write load of context sync on large deployments. + ### --session-duration | | | diff --git a/docs/tutorials/testing-templates.md b/docs/tutorials/testing-templates.md index 00e5b29e94e37..b18214b1ba02a 100644 --- a/docs/tutorials/testing-templates.md +++ b/docs/tutorials/testing-templates.md @@ -131,5 +131,5 @@ jobs: - name: Promote template version if: success() run: | - coder template version promote --template=$TEMPLATE_NAME --template-version=${{ steps.name.outputs.version_name }} --yes + coder template version promote --template=$TEMPLATE_NAME --template-version=${{ steps.name.outputs.version_name }} ``` diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index 7dd3d8f4d0a0c..df9494f666887 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -11,9 +11,19 @@ Each user secret has: - A value, which contains the sensitive content. - An optional description. - An optional environment variable target, file target, or both. +- An enabled flag that controls whether Coder injects the secret into your + workspaces. -A secret without an environment variable target or file target is stored, but is -not injected into workspaces. +An enabled secret must have at least one of an environment variable target or a +file target. To keep a secret stored without injecting it, disable it +(`enabled = false`) instead of clearing both targets. A create or update that +would leave an enabled secret with no target is rejected with a 400 and +directs you to disable the secret instead. + +Disabled secrets stay visible and editable in the CLI, REST API, and dashboard, +but are not injected into workspaces. Secrets that predate the enabled flag and +had no target were migrated to disabled, so they show as disabled and need a +target before you can enable them. User secrets apply to all workspaces that you own. @@ -39,6 +49,16 @@ time the workspace agent reconnects to Coder, for example after the workspace or the agent restarts. To pick up a change to a secret while a workspace is running, restart the workspace. +Disabling a secret (`coder secret disable`) stops it from being injected from +the next workspace start onward. Running sessions keep values that were already +injected until the agent manifest is refetched, which happens on workspace +restart. Disabling does not remove a file that was already written; the same +"Coder never deletes secret files" rule below applies. + +Coder controls where a secret is delivered, not whether it is still valid. +Changing or deleting a secret in Coder does not revoke a credential that a workspace has already received. +If a credential is exposed, rotate or revoke it in the system that issued it. + ### Environment variable secrets Coder injects environment variable secrets into every new shell, terminal, @@ -46,11 +66,13 @@ app, SSH session, and startup script that you start in your workspace. Existing shells and processes keep the environment they were given when they started. -| If you... | ...then in your workspace | -|--------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------| -| Create or update an env secret | The change applies after the next workspace start. Until then, your running workspace continues to use the secrets it had when it last started. | -| Rename the env var (`--env NEW_NAME`) | After the next workspace start, new shells get `NEW_NAME` and the old name is no longer set. | -| Clear the env target (`--env ""`) or delete the secret | After the next workspace start, the variable is no longer injected. | +| If you... | ...then in your workspace | +|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Create or update an env secret | The change applies after the next workspace start. Until then, your running workspace continues to use the secrets it had when it last started. | +| Rename the env var (`--env NEW_NAME`) | After the next workspace start, new shells get `NEW_NAME` and the old name is no longer set. | +| Clear the env target (`--env ""`) | Only succeeds if the secret keeps its file target or is disabled in the same request; otherwise the request is rejected with a 400. After the next workspace start, the variable is no longer injected. | +| Disable the secret (`coder secret disable`) | After the next workspace start, the variable is no longer injected. Running sessions keep the value until the agent manifest is refetched (workspace restart). | +| Delete the secret | After the next workspace start, the variable is no longer injected. | To pick up a change in a long-running shell or app started after a restart, restart that shell or app. @@ -62,11 +84,13 @@ starts, before any startup scripts run. New parent directories are created as needed. If the file already exists, Coder overwrites the contents and leaves the existing permissions alone. -| If you... | ...then in your workspace | -|----------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| -| Create or update a file secret | The file is written or overwritten at the next workspace start. | -| Change the file path (`--file NEW_PATH`) | At the next workspace start, a file is written at `NEW_PATH`. **The file at the previous path stays on disk with its old value.** | -| Clear the file target (`--file ""`) or delete the secret | **The previously-written file stays on disk with its last value.** | +| If you... | ...then in your workspace | +|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Create or update a file secret | The file is written or overwritten at the next workspace start. | +| Change the file path (`--file NEW_PATH`) | At the next workspace start, a file is written at `NEW_PATH`. **The file at the previous path stays on disk with its old value.** | +| Clear the file target (`--file ""`) | Only succeeds if the secret keeps its env target or is disabled in the same request; otherwise the request is rejected with a 400. **The previously-written file stays on disk with its last value.** | +| Disable the secret (`coder secret disable`) | The file is no longer written at the next workspace start. **The previously-written file stays on disk with its last value.** | +| Delete the secret | **The previously-written file stays on disk with its last value.** | > [!IMPORTANT] > Coder never deletes secret files it has written for you. If you remove a @@ -75,9 +99,10 @@ the existing permissions alone. > a terminal in your workspace and run `rm <path>`. Rebuilding the workspace > may clear stale files when your template recreates the filesystem. -If you set two file secrets that resolve to the same absolute path (for -example `~/config` and `/home/coder/config`), only one of them ends up on -disk; the workspace agent logs a warning to help spot this. Use +Coder rejects a second secret that uses a file path you already use. Two +different paths can still resolve to the same absolute path (for example +`~/config` and `/home/coder/config`). Coder accepts both, but only one of them +ends up on disk; the workspace agent logs a warning to help spot this. Use distinct paths to avoid the collision. ## Limits @@ -120,7 +145,10 @@ You can create, edit, and delete user secrets from the Coder dashboard: From this page you can add a new secret, update an existing secret's value, description, or environment variable and file targets, and delete secrets you -no longer need. +no longer need. Each row has an enable/disable toggle that controls whether +Coder injects the secret. A secret with no environment variable or file target +cannot be enabled from the dashboard; the toggle is disabled with a tooltip, +mirroring the API invariant that an enabled secret must have a target. The rest of this guide shows the equivalent CLI commands. The same behaviors, limits, and injection rules apply whether you manage secrets from the @@ -194,11 +222,38 @@ want to store a trailing newline: echo -n "$API_KEY" | coder secret create api-key --env API_KEY ``` +### Import multiple secrets from a file + +Use `coder secret import <file>` to create a secret for every key in a dotenv, +JSON, or YAML file. The format is inferred from the file extension. Pass `-` +to read from non-interactive stdin, which requires `--input-format`: + +```sh +coder secret import ./secrets.env + +coder secret import - --input-format yaml < ./secrets.yaml +``` + +The import is all or nothing and never overwrites existing secrets. Keys that +are valid environment variable names are injected under the same name; other +keys are imported without an environment variable target. For details, see +[`coder secret import`](../reference/cli/secret_import.md). + +### Create a disabled secret + +An enabled secret must set `--env`, `--file`, or both. To store a secret +without injecting it, pass `--enabled=false`. You can add a target and enable +it later with `coder secret enable`. + +```sh +echo -n "$API_KEY" | coder secret create api-key --enabled=false +``` + ## Update a secret Use `coder secret update` to update a secret value, description, environment variable target, or file target. At least one of `--value`, `--description`, -`--env`, or `--file` must be specified. +`--env`, `--file`, or `--enabled` must be specified. ```sh # Update a secret value. @@ -207,10 +262,32 @@ echo -n "$NEW_API_KEY" | coder secret update api-key # Change the environment variable target. coder secret update api-key --env NEW_API_KEY -# Clear the file injection target while keeping the secret. +# Clear the file injection target while keeping the secret. This only +# succeeds because api-key still has an environment variable target; a +# request that clears the last target of an enabled secret is rejected. coder secret update api-key --file "" ``` +Environment variable names and file paths are unique among your own secrets. +Coder rejects an update that uses an environment variable name or file path that another of your secrets already uses. + +Clearing a target frees it for your other secrets to use. +If another secret takes it, setting the original target back is rejected until you free it again. + +### Enable and disable a secret + +Disable a secret to stop injecting it without deleting it, then enable it again +to resume. Enabling a secret that has no target is rejected; add a target +first. + +```sh +# Stop injecting a secret without deleting it. +coder secret disable api-key + +# Resume injection. +coder secret enable api-key +``` + ## List and delete secrets List, show, and delete your secrets with the `coder secret` CLI: @@ -227,10 +304,47 @@ coder secret delete api-key ``` The list and show commands return secret metadata only. They never return the -secret value. +secret value. The `coder secret list` table includes an `enabled` column so you +can see which secrets are currently injected. See [How your secrets reach a workspace](#how-your-secrets-reach-a-workspace) for what happens to running workspaces when you delete a secret. +## Import secrets from a file + +If you keep secrets in a dotenv file, a flat JSON object, or a flat YAML +mapping, you can import the whole file instead of creating each secret +individually: + +1. Go to the [**Secrets** page](#manage-secrets-from-the-dashboard) and select + **Add secret**. +1. Drop or select a `.env`, `.json`, `.yaml`, or `.yml` file in the upload + area. Coder imports the file as soon as you choose it. + +Every key in the file becomes a secret. For example, this dotenv file creates +two secrets, `API_KEY` and `DATABASE_URL`, each injected as an environment +variable of the same name: + +```sh +API_KEY=abc123 +DATABASE_URL=postgres://user:pass@db.internal/app +``` + +In JSON and YAML files, every value must be a string. Quote numeric and +boolean values, for example `"PORT": "8080"`. + +The import is all or nothing. If any entry fails validation, conflicts with +an existing secret, or exceeds a [limit](#limits), Coder cancels the import +and creates no secrets. The file must also be 1 MiB or smaller and contain no +more than 50 keys. + +Keys that are not valid environment variable names, such as `MY-TOKEN` or the +reserved name `PATH`, are imported without an environment variable target. +They are not injected into workspaces until you add a valid environment +variable or file target. + +To import secrets programmatically, use the +[Secrets API](../reference/api/secrets.md#import-user-secrets-from-a-file). + For full command details, see [`coder secret`](../reference/cli/secret.md) and the [Secrets API reference](../reference/api/secrets.md). diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index 722094ce2f684..e6a437035a741 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -26,7 +26,7 @@ customization options, keyboard shortcuts, and troubleshooting guides. ## SSH -### Through with the CLI +### Through the CLI Coder will use the optimal path for an SSH connection (determined by your deployment's [networking configuration](../../admin/infrastructure/index.md)) @@ -228,7 +228,7 @@ services or preview environments. You can also [share ports](./port-forwarding.md#sharing-ports) with other users, or [port-forward](./port-forwarding.md#the-coder-port-forward-command) through -the CLI with `coder port forward`. Read more in the +the CLI with `coder port-forward`. Read more in the [docs on workspace ports](./port-forwarding.md). ![Open Ports window](../../images/networking/listeningports.png) diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 0941c2da4b557..fb7506b1d80e2 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd.go @@ -28,6 +28,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" + aibridgeconfig "github.com/coder/coder/v2/aibridge/config" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" ) @@ -132,7 +133,7 @@ type Server struct { // refreshProviders fetches the live provider snapshot on Reload. // Nil disables hot-reload. refreshProviders RefreshProvidersFunc - // providerRouter holds the live (mitmHosts, nameByHost) pair. + // providerRouter holds the live routing snapshot. providerRouter atomic.Pointer[providerRouter] // allowedPorts is the port allowlist for CONNECT requests. Fixed at // construction; not reloadable. @@ -149,19 +150,26 @@ type Server struct { metrics *Metrics } +type routedProvider struct { + name string + providerType string +} + // providerRouter keeps CONNECT matching and provider lookup in sync. type providerRouter struct { - mitmHosts []string // host:port set the goproxy condition matches against. - nameByHost map[string]string // lowercase hostname -> provider name. + mitmHosts []string // host:port set the goproxy condition matches against. + providerByHost map[string]routedProvider // lowercase hostname -> provider. } // emptyProviderRouter is used before the first Reload (or when the // operator deconfigures every provider) so handlers can safely call // loadProviderRouter without a nil check. -var emptyProviderRouter = &providerRouter{nameByHost: map[string]string{}} +var emptyProviderRouter = &providerRouter{ + providerByHost: map[string]routedProvider{}, +} -func (r *providerRouter) providerFromHost(host string) string { - return r.nameByHost[strings.ToLower(host)] +func (r *providerRouter) providerFromHost(host string) routedProvider { + return r.providerByHost[strings.ToLower(host)] } // requestContext holds metadata propagated through the proxy request/response chain. @@ -655,13 +663,13 @@ func (s *Server) authMiddleware(host string, ctx *goproxy.ProxyCtx) (*goproxy.Co provider := s.loadProviderRouter().providerFromHost(ctx.Req.URL.Hostname()) // A concurrent Reload can swap the router between CONNECT matching // and provider lookup, so treat a missing mapping as a runtime miss. - if provider == "" { + if provider.name == "" { logger.Warn(s.ctx, "rejecting CONNECT request with no provider mapping") return goproxy.RejectConnect, host } logger = logger.With( - slog.F("provider", provider), + slog.F("provider", provider.name), ) proxyAuth := ctx.Req.Header.Get("Proxy-Authorization") @@ -685,7 +693,7 @@ func (s *Server) authMiddleware(host string, ctx *goproxy.ProxyCtx) (*goproxy.Co ctx.UserData = &requestContext{ ConnectSessionID: connectSessionID, CoderToken: coderToken, - Provider: provider, + Provider: provider.name, } logger.Debug(s.ctx, "request CONNECT authenticated") @@ -936,14 +944,14 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. } } liveProvider := s.loadProviderRouter().providerFromHost(host) - if liveProvider == "" || liveProvider != reqCtx.Provider { + if liveProvider.name == "" || liveProvider.name != reqCtx.Provider { s.logger.Warn(s.ctx, "provider mapping changed or removed since CONNECT, passing through", slog.F("connect_id", reqCtx.ConnectSessionID.String()), slog.F("host", req.Host), slog.F("method", req.Method), slog.F("path", originalPath), slog.F("connect_provider", reqCtx.Provider), - slog.F("live_provider", liveProvider), + slog.F("live_provider", liveProvider.name), ) return req, nil } @@ -992,7 +1000,8 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. req.URL = parsedGatewayTargetURL req.Host = parsedGatewayTargetURL.Host - injectBYOKHeaderIfNeeded(req.Header, reqCtx.CoderToken) + // Prepare Coder authentication for centralized and BYOK requests. + prepareAIGatewayAuth(req.Header, reqCtx.CoderToken, liveProvider.providerType) // Set request ID header to correlate requests between aibridgeproxyd and aibridged. req.Header.Set(agplaibridge.HeaderCoderRequestID, reqCtx.RequestID.String()) @@ -1019,24 +1028,34 @@ func (s *Server) handleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http. return req, nil } -// injectBYOKHeaderIfNeeded sets HeaderCoderToken when the -// Authorization header carries a bearer token that differs from the -// Coder token, indicating the client is using its own LLM -// credentials. Clients that can set custom headers -// do this themselves; this handles clients that cannot. -// -// In centralized mode, Authorization carries the Coder token -// itself, so aibridged discovers it via ExtractAuthToken -// without any extra header. -func injectBYOKHeaderIfNeeded(header http.Header, coderToken string) { - // Don’t overwrite the header if it’s already set. - if header.Get(agplaibridge.HeaderCoderToken) != "" { +// prepareAIGatewayAuth prepares the Coder authentication headers for AI +// Gateway. Copilot is always BYOK, while other providers may use centralized +// or BYOK authentication. +func prepareAIGatewayAuth(headers http.Header, coderToken, providerType string) { + // Copilot is always BYOK, even when a route does not include a provider + // credential (e.g., /_ping). Prevent the Coder token from being forwarded + // to Copilot as a provider credential. + if providerType == aibridgeconfig.ProviderCopilot { + headers.Set(agplaibridge.HeaderCoderToken, coderToken) + + if extractCoderTokenFromBearerAuth(headers.Get("Authorization")) == coderToken { + headers.Del("Authorization") + } + if strings.TrimSpace(headers.Get("X-Api-Key")) == coderToken { + headers.Del("X-Api-Key") + } + return + } + + // For other providers, only add the Coder token when a separate provider + // credential indicates BYOK. + if headers.Get(agplaibridge.HeaderCoderToken) != "" { return } - bearer := extractCoderTokenFromBearerAuth(header.Get("Authorization")) + bearer := extractCoderTokenFromBearerAuth(headers.Get("Authorization")) if bearer != "" && bearer != coderToken { - header.Set(agplaibridge.HeaderCoderToken, coderToken) + headers.Set(agplaibridge.HeaderCoderToken, coderToken) } } diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 580b020ce15cc..99fc40bfce1e4 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -193,26 +193,21 @@ func withProviders(providers ...aibridgeproxyd.ReloadedProvider) testProxyOption } // withProviderHosts is a convenience that builds enabled -// ReloadedProvider entries from each host, looking up the well-known -// provider name via testProviderFromHost and falling back to -// "test-provider" for hosts without a well-known mapping. Equivalent -// to passing each entry individually to withProviders. +// ReloadedProvider entries from each host, looking up well-known providers +// via testProviderFromHost. Unknown hosts use a generic name and OpenAI type. func withProviderHosts(hosts ...string) testProxyOption { return func(cfg *testProxyConfig) { providers := make([]aibridgeproxyd.ReloadedProvider, 0, len(hosts)) for _, h := range hosts { - name := testProviderFromHost(h) - if name == "" { - name = "test-provider" - } + provider := testProviderFromHost(h) host, _, splitErr := net.SplitHostPort(h) if splitErr != nil { host = h } providers = append(providers, aibridgeproxyd.ReloadedProvider{ ProviderOutcome: aibridged.ProviderOutcome{ - Name: name, - Type: "openai", + Name: provider.name, + Type: provider.providerType, Status: aibridged.ProviderStatusEnabled, }, Host: strings.ToLower(host), @@ -222,24 +217,29 @@ func withProviderHosts(hosts ...string) testProxyOption { } } -// testProviderFromHost maps well-known AI provider hostnames to -// provider names for test use. Unknown hosts return "". -func testProviderFromHost(host string) string { +type testProvider struct { + name string + providerType string +} + +// testProviderFromHost maps well-known AI provider hostnames to providers for +// test use. Unknown hosts use a generic name and OpenAI type. +func testProviderFromHost(host string) testProvider { switch strings.ToLower(host) { case aibridgeproxyd.HostAnthropic: - return aibridge.ProviderAnthropic + return testProvider{name: aibridge.ProviderAnthropic, providerType: aibridge.ProviderAnthropic} case aibridgeproxyd.HostOpenAI: - return aibridge.ProviderOpenAI + return testProvider{name: aibridge.ProviderOpenAI, providerType: aibridge.ProviderOpenAI} case aibridgeproxyd.HostCopilot: - return aibridge.ProviderCopilot + return testProvider{name: aibridge.ProviderCopilot, providerType: aibridge.ProviderCopilot} case agplaibridge.HostCopilotBusiness: - return agplaibridge.ProviderCopilotBusiness + return testProvider{name: agplaibridge.ProviderCopilotBusiness, providerType: aibridge.ProviderCopilot} case agplaibridge.HostCopilotEnterprise: - return agplaibridge.ProviderCopilotEnterprise + return testProvider{name: agplaibridge.ProviderCopilotEnterprise, providerType: aibridge.ProviderCopilot} case agplaibridge.HostChatGPT: - return agplaibridge.ProviderChatGPT + return testProvider{name: agplaibridge.ProviderChatGPT, providerType: aibridge.ProviderOpenAI} default: - return "" + return testProvider{name: "test-provider", providerType: aibridge.ProviderOpenAI} } } @@ -1613,13 +1613,13 @@ func TestProxy_MITM_BYOKInjection(t *testing.T) { srv := newTestProxy(t, withGatewayURL(aibridgedServer.URL), - withProviderHosts(aibridgeproxyd.HostCopilot), + withProviderHosts(aibridgeproxyd.HostOpenAI), ) certPool := getProxyCertPool(t) client := newProxyClient(t, srv, makeProxyAuthHeader(coderToken), certPool, false) - req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://"+aibridgeproxyd.HostCopilot+"/chat/completions", strings.NewReader(`{}`)) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://"+aibridgeproxyd.HostOpenAI+"/chat/completions", strings.NewReader(`{}`)) require.NoError(t, err) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", tt.authzHeader) @@ -1643,6 +1643,146 @@ func TestProxy_MITM_BYOKInjection(t *testing.T) { } } +func TestProxy_MITM_CopilotAuth(t *testing.T) { + t.Parallel() + + const coderToken = "coder-token" + stringPtr := func(value string) *string { return &value } + tests := []struct { + name string + host string + providerType string + authorization string + apiKey string + coderToken string + expectCoderToken *string + expectAuthorization *string + expectAPIKey *string + }{ + { + name: "NoProviderCredential", + host: aibridgeproxyd.HostCopilot, + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "StripCoderBearer", + host: aibridgeproxyd.HostCopilot, + authorization: "Bearer " + coderToken, + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "StripCoderAPIKey", + host: aibridgeproxyd.HostCopilot, + apiKey: coderToken, + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "PreserveProviderBearer", + host: aibridgeproxyd.HostCopilot, + authorization: "Bearer copilot-token", + expectCoderToken: stringPtr(coderToken), + expectAuthorization: stringPtr("Bearer copilot-token"), + expectAPIKey: nil, + }, + { + name: "ReplaceClientCoderToken", + host: aibridgeproxyd.HostCopilot, + coderToken: "other-coder-token", + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "CustomCopilotProvider", + host: "copilot.example.com", + providerType: aibridge.ProviderCopilot, + expectCoderToken: stringPtr(coderToken), + expectAuthorization: nil, + expectAPIKey: nil, + }, + { + name: "NonCopilotProvider", + host: aibridgeproxyd.HostCopilot, + providerType: aibridge.ProviderOpenAI, + expectCoderToken: nil, + expectAuthorization: nil, + expectAPIKey: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var receivedCoderToken, receivedAuthorization, receivedAPIKey string + aibridgedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedCoderToken = r.Header.Get(agplaibridge.HeaderCoderToken) + receivedAuthorization = r.Header.Get("Authorization") + receivedAPIKey = r.Header.Get("X-Api-Key") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(aibridgedServer.Close) + + provider := testProviderFromHost(tt.host) + if tt.providerType != "" { + provider.providerType = tt.providerType + } + srv := newTestProxy(t, + withGatewayURL(aibridgedServer.URL), + withProviders(aibridgeproxyd.ReloadedProvider{ + ProviderOutcome: aibridged.ProviderOutcome{ + Name: provider.name, + Type: provider.providerType, + Status: aibridged.ProviderStatusEnabled, + }, + Host: tt.host, + }), + ) + + certPool := getProxyCertPool(t) + client := newProxyClient(t, srv, makeProxyAuthHeader(coderToken), certPool, false) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://"+tt.host, nil) + require.NoError(t, err) + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + if tt.apiKey != "" { + req.Header.Set("X-Api-Key", tt.apiKey) + } + if tt.coderToken != "" { + req.Header.Set(agplaibridge.HeaderCoderToken, tt.coderToken) + } + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + if tt.expectAuthorization == nil { + require.Empty(t, receivedAuthorization) + } else { + require.Equal(t, *tt.expectAuthorization, receivedAuthorization) + } + if tt.expectAPIKey == nil { + require.Empty(t, receivedAPIKey) + } else { + require.Equal(t, *tt.expectAPIKey, receivedAPIKey) + } + if tt.expectCoderToken == nil { + require.Empty(t, receivedCoderToken) + } else { + require.Equal(t, *tt.expectCoderToken, receivedCoderToken) + } + }) + } +} + // TestListenerTLS verifies that the proxy works correctly when its listener is wrapped in TLS. // It tests both tunneled and MITM'd requests through an HTTPS proxy listener. func TestListenerTLS(t *testing.T) { diff --git a/enterprise/aibridgeproxyd/reload.go b/enterprise/aibridgeproxyd/reload.go index 04b1f5438b0ec..9dc54c5fff647 100644 --- a/enterprise/aibridgeproxyd/reload.go +++ b/enterprise/aibridgeproxyd/reload.go @@ -119,7 +119,7 @@ func (s *Server) mitmHostsCondition() goproxy.ReqConditionFunc { // defense-in-depth measure even though the refresh function should // mark duplicates as errors. func buildProviderRouter(reload ProviderReload, allowedPorts []string) (*providerRouter, error) { - nameByHost := make(map[string]string, len(reload.Providers)) + providerByHost := make(map[string]routedProvider, len(reload.Providers)) domains := make([]string, 0, len(reload.Providers)) for _, p := range reload.Providers { if p.Status != aibridged.ProviderStatusEnabled { @@ -129,15 +129,18 @@ func buildProviderRouter(reload ProviderReload, allowedPorts []string) (*provide if host == "" { continue } - if _, exists := nameByHost[host]; exists { + if _, exists := providerByHost[host]; exists { continue } - nameByHost[host] = p.Name + providerByHost[host] = routedProvider{name: p.Name, providerType: p.Type} domains = append(domains, host) } mitmHosts, err := convertDomainsToHosts(domains, allowedPorts) if err != nil { return nil, err } - return &providerRouter{mitmHosts: mitmHosts, nameByHost: nameByHost}, nil + return &providerRouter{ + mitmHosts: mitmHosts, + providerByHost: providerByHost, + }, nil } diff --git a/enterprise/aibridgeproxyd/reload_internal_test.go b/enterprise/aibridgeproxyd/reload_internal_test.go index 5ccba37ec7bd0..537392fc44751 100644 --- a/enterprise/aibridgeproxyd/reload_internal_test.go +++ b/enterprise/aibridgeproxyd/reload_internal_test.go @@ -40,7 +40,7 @@ func TestServerReloadSwapsProviderRouter(t *testing.T) { srv.providerRouter.Store(emptyProviderRouter) require.NoError(t, srv.Reload(ctx)) - assert.Equal(t, "old", srv.loadProviderRouter().providerFromHost("old.example.com")) + assert.Equal(t, routedProvider{name: "old", providerType: "openai"}, srv.loadProviderRouter().providerFromHost("old.example.com")) assert.Empty(t, srv.loadProviderRouter().providerFromHost("new.example.com")) reload = ProviderReload{Providers: []ReloadedProvider{enabledProvider("new", "new.example.com")}} @@ -48,7 +48,7 @@ func TestServerReloadSwapsProviderRouter(t *testing.T) { router := srv.loadProviderRouter() assert.Empty(t, router.providerFromHost("old.example.com")) - assert.Equal(t, "new", router.providerFromHost("new.example.com")) + assert.Equal(t, routedProvider{name: "new", providerType: "openai"}, router.providerFromHost("new.example.com")) assert.Equal(t, []string{"new.example.com:443"}, router.mitmHosts) } @@ -74,14 +74,14 @@ func TestServerReloadPreservesProviderRouterOnRefreshError(t *testing.T) { require.NoError(t, srv.Reload(ctx)) before := srv.loadProviderRouter() - assert.Equal(t, "old", before.providerFromHost("old.example.com")) + assert.Equal(t, routedProvider{name: "old", providerType: "openai"}, before.providerFromHost("old.example.com")) failRefresh = true require.ErrorIs(t, srv.Reload(ctx), refreshErr) after := srv.loadProviderRouter() assert.Same(t, before, after) - assert.Equal(t, "old", after.providerFromHost("old.example.com")) + assert.Equal(t, routedProvider{name: "old", providerType: "openai"}, after.providerFromHost("old.example.com")) assert.Equal(t, []string{"old.example.com:443"}, after.mitmHosts) } @@ -95,7 +95,7 @@ func TestBuildProviderRouter(t *testing.T) { reload := ProviderReload{Providers: []ReloadedProvider{ enabledProvider("openai", "api.openai.com"), - enabledProvider("anthropic", "api.anthropic.com"), + {ProviderOutcome: aibridged.ProviderOutcome{Name: "anthropic", Type: "anthropic", Status: aibridged.ProviderStatusEnabled}, Host: "api.anthropic.com"}, enabledProvider("custom", "custom-llm.example.com"), // Host is populated on the non-enabled rows so the Status // guard, not the empty-host guard, is what excludes them. @@ -106,9 +106,9 @@ func TestBuildProviderRouter(t *testing.T) { router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "openai", router.providerFromHost("api.openai.com")) - assert.Equal(t, "anthropic", router.providerFromHost("api.anthropic.com")) - assert.Equal(t, "custom", router.providerFromHost("custom-llm.example.com")) + assert.Equal(t, routedProvider{name: "openai", providerType: "openai"}, router.providerFromHost("api.openai.com")) + assert.Equal(t, routedProvider{name: "anthropic", providerType: "anthropic"}, router.providerFromHost("api.anthropic.com")) + assert.Equal(t, routedProvider{name: "custom", providerType: "openai"}, router.providerFromHost("custom-llm.example.com")) assert.Empty(t, router.providerFromHost("unknown.com")) assert.Empty(t, router.providerFromHost("disabled.example.com"), "disabled provider must not be routable even with a populated Host") @@ -130,8 +130,8 @@ func TestBuildProviderRouter(t *testing.T) { router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "provider", router.providerFromHost("API.Example.COM")) - assert.Equal(t, "provider", router.providerFromHost("api.example.com")) + assert.Equal(t, routedProvider{name: "provider", providerType: "openai"}, router.providerFromHost("API.Example.COM")) + assert.Equal(t, routedProvider{name: "provider", providerType: "openai"}, router.providerFromHost("api.example.com")) }) t.Run("DefensiveDeduplicatesSameHost", func(t *testing.T) { @@ -148,7 +148,7 @@ func TestBuildProviderRouter(t *testing.T) { router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "first", router.providerFromHost("api.example.com")) + assert.Equal(t, routedProvider{name: "first", providerType: "openai"}, router.providerFromHost("api.example.com")) }) t.Run("SkipsRowsWithEmptyHost", func(t *testing.T) { @@ -162,7 +162,7 @@ func TestBuildProviderRouter(t *testing.T) { router, err := buildProviderRouter(reload, []string{"443"}) require.NoError(t, err) - assert.Equal(t, "good", router.providerFromHost("api.good.example.com")) + assert.Equal(t, routedProvider{name: "good", providerType: "openai"}, router.providerFromHost("api.good.example.com")) assert.Equal(t, []string{"api.good.example.com:443"}, router.mitmHosts) }) } diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 540f4cce34d47..ea66b57933266 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -503,6 +503,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "description": ActionTrack, "env_name": ActionTrack, "file_path": ActionTrack, + "enabled": ActionTrack, "value": ActionSecret, diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 369b2fe72c805..3fab5d4176ac0 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -56,6 +56,13 @@ OPTIONS: the workspace serves malicious JavaScript. This is recommended for security purposes if a --wildcard-access-url is configured. + --disable-workspace-agent-context-sync bool, $CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC + Stop persisting workspace agent context snapshots (instructions, + skills, and MCP state used for pinned chat context). When set, coderd + rejects agent context pushes as unimplemented and agents stop sending + them; chats cannot pin workspace context. Use this to shed the + database write load of context sync on large deployments. + --disable-workspace-sharing bool, $CODER_DISABLE_WORKSPACE_SHARING Disable workspace sharing. Workspace ACL checking is disabled and only owners can have ssh, apps and terminal access to workspaces. Access diff --git a/enterprise/coderd/agentfirewall.go b/enterprise/coderd/agentfirewall.go index 928a99e0d9906..2773031f25c1a 100644 --- a/enterprise/coderd/agentfirewall.go +++ b/enterprise/coderd/agentfirewall.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" @@ -109,30 +110,6 @@ func (api *API) agentFirewallSessionLogs(rw http.ResponseWriter, r *http.Request } httpapi.Write(ctx, rw, http.StatusOK, codersdk.AgentFirewallSessionLogsResponse{ - Results: agentFirewallLogsFromDB(dbLogs), + Results: db2sdk.AgentFirewallLogs(dbLogs), }) } - -// agentFirewallLogsFromDB converts database boundary logs to SDK -// representation. Allowed is derived from MatchedRule being non-NULL. -func agentFirewallLogsFromDB(dbLogs []database.BoundaryLog) []codersdk.AgentFirewallLog { - results := make([]codersdk.AgentFirewallLog, 0, len(dbLogs)) - for _, l := range dbLogs { - bl := codersdk.AgentFirewallLog{ - ID: l.ID, - SessionID: l.SessionID, - SequenceNumber: l.SequenceNumber, - Allowed: l.MatchedRule.Valid, - CreatedAt: l.CreatedAt, - Proto: l.Proto, - Method: l.Method, - Detail: l.Detail, - CapturedAt: &l.CapturedAt, - } - if l.MatchedRule.Valid { - bl.MatchedRule = &l.MatchedRule.String - } - results = append(results, bl) - } - return results -} diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 5b6f256e68aa0..06e305b7fec8d 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -47,6 +47,11 @@ const ( // maxAISpendExportPeriod bounds an explicit AI spend export window to at // most 31 days, matching the maximum length of the monthly default period. maxAISpendExportPeriod = 31 * 24 * time.Hour + // aiBridgeSessionNetworkCallsLimit caps the per-session network call list + // returned with session threads. The header count still reflects the full + // summary total, so the UI surfaces truncation when a session exceeds this. + // Sessions past the cap need pagination to see the remainder. + aiBridgeSessionNetworkCallsLimit = 1000 ) // errInvalidCursor is returned when a pagination cursor does not @@ -369,6 +374,8 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques toolUsages []database.AIBridgeToolUsage userPrompts []database.AIBridgeUserPrompt modelThoughts []database.AIBridgeModelThought + topDomains []database.GetAIBridgeSessionTopDomainsRow + networkCalls []database.BoundaryLog ) err = api.Database.InTx(func(db database.Store) error { // Validate cursor IDs before querying threads. The SQL @@ -435,6 +442,29 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return xerrors.Errorf("list model thoughts: %w", err) } + // Aggregate the session's top network destination. Scoped by session + // ID (not the page) so the summary reflects the whole session. The + // summary card renders only the single most-contacted domain plus a + // "+N more" count derived from NetworkDomainCount, so we fetch one row. + topDomains, err = db.GetAIBridgeSessionTopDomains(ctx, database.GetAIBridgeSessionTopDomainsParams{ + SessionID: sessionIDParam, + Limit: 1, + }) + if err != nil { + return xerrors.Errorf("get session top domains: %w", err) + } + + // List the session's individual network calls. Scoped by session ID + // (not the page) so the list reflects the whole session, consistent + // with the network call summary. + networkCalls, err = db.ListAIBridgeSessionNetworkCalls(ctx, database.ListAIBridgeSessionNetworkCallsParams{ + SessionID: sessionIDParam, + Limit: aiBridgeSessionNetworkCallsLimit, + }) + if err != nil { + return xerrors.Errorf("list session network calls: %w", err) + } + return nil }, &database.TxOptions{ Isolation: sql.LevelRepeatableRead, @@ -456,7 +486,16 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return } - resp := db2sdk.AIBridgeSessionThreads(session, threadRows, tokenUsages, toolUsages, userPrompts, modelThoughts) + resp := db2sdk.AIBridgeSessionThreads(db2sdk.AIBridgeSessionThreadsParams{ + Session: session, + Interceptions: threadRows, + TokenUsages: tokenUsages, + ToolUsages: toolUsages, + UserPrompts: userPrompts, + ModelThoughts: modelThoughts, + TopDomains: topDomains, + NetworkCalls: networkCalls, + }) httpapi.Write(ctx, rw, http.StatusOK, resp) } @@ -611,6 +650,22 @@ func (api *API) groupAIBudget(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.GroupAIBudget(groupBudget)) } +// validAISpendLimit reports whether the limit is within the configurable +// maximum, writing a 400 when it is not. +func validAISpendLimit(ctx context.Context, rw http.ResponseWriter, spendLimitMicros int64) bool { + if spendLimitMicros <= codersdk.MaxAISpendLimitMicros { + return true + } + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid AI spend limit.", + Validations: []codersdk.ValidationError{{ + Field: "spend_limit_micros", + Detail: fmt.Sprintf("Must not exceed %d.", codersdk.MaxAISpendLimitMicros), + }}, + }) + return false +} + // @Summary Upsert group AI budget // @ID upsert-group-ai-budget // @Security CoderSessionToken @@ -640,6 +695,9 @@ func (api *API) upsertGroupAIBudget(rw http.ResponseWriter, r *http.Request) { if !httpapi.Read(ctx, rw, r, &req) { return } + if !validAISpendLimit(ctx, rw, req.SpendLimitMicros) { + return + } // Capture the existing budget (if any) so the audit log records the // before-state. An absent row leaves aReq.Old as the zero value. @@ -713,7 +771,7 @@ func (api *API) deleteGroupAIBudget(rw http.ResponseWriter, r *http.Request) { // @Tags Enterprise // @Param user path string true "User ID, username, or me" // @Success 200 {object} codersdk.UserAIBudgetOverride -// @Router /api/v2/users/{user}/ai/budget [get] +// @Router /api/v2/users/{user}/ai/budget/override [get] func (api *API) userAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) @@ -741,7 +799,7 @@ func (api *API) userAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { // @Param user path string true "User ID, username, or me" // @Param request body codersdk.UpsertUserAIBudgetOverrideRequest true "Upsert user AI budget override request" // @Success 200 {object} codersdk.UserAIBudgetOverride -// @Router /api/v2/users/{user}/ai/budget [put] +// @Router /api/v2/users/{user}/ai/budget/override [put] func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) @@ -750,6 +808,9 @@ func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque if !httpapi.Read(ctx, rw, r, &req) { return } + if !validAISpendLimit(ctx, rw, req.SpendLimitMicros) { + return + } // Look up the new group first so a missing or forbidden group_id // returns 404. We also need the group for the audit log. @@ -835,7 +896,7 @@ func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Reque // @Tags Enterprise // @Param user path string true "User ID, username, or me" // @Success 204 -// @Router /api/v2/users/{user}/ai/budget [delete] +// @Router /api/v2/users/{user}/ai/budget/override [delete] func (api *API) deleteUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() user := httpmw.UserParam(r) @@ -939,8 +1000,10 @@ func (api *API) userAISpendStatus(rw http.ResponseWriter, r *http.Request) { if ok { resp.EffectiveGroupID = &effectiveGroup.GroupID if effectiveGroup.Limit != nil { - resp.SpendLimitMicros = &effectiveGroup.Limit.SpendLimitMicros - resp.LimitSource = &effectiveGroup.Limit.Source + resp.EffectiveBudget = &codersdk.AIBudgetLimit{ + SpendLimitMicros: effectiveGroup.Limit.SpendLimitMicros, + LimitSource: effectiveGroup.Limit.Source, + } } logger = logger.With(slog.F("effective_group_id", effectiveGroup.GroupID)) @@ -1232,6 +1295,58 @@ func (api *API) exportOrganizationAISpend(rw http.ResponseWriter, r *http.Reques } } +// @Summary Get group AI spend +// @Description Returns the AI spend limit and aggregate spend for the group. +// @ID get-group-ai-spend +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param group path string true "Group ID" format(uuid) +// @Success 200 {object} codersdk.GroupAISpend +// @Router /api/v2/groups/{group}/ai/spend [get] +func (api *API) groupAISpend(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + group := httpmw.GroupParam(r) + logger := api.Logger.With(slog.F("group_id", group.ID)) + + periodWindow, err := api.currentAIBudgetWindow() + if err != nil { + logger.Error(ctx, "failed to compute AI budget period", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + logger = logger.With( + slog.F("period_start", periodWindow.Start), + slog.F("period_end", periodWindow.End), + ) + + rows, err := api.Database.GetOrganizationGroupsAISpend(ctx, database.GetOrganizationGroupsAISpendParams{ + OrganizationID: group.OrganizationID, + GroupIds: []uuid.UUID{group.ID}, + PeriodStart: periodWindow.Start, + }) + if err != nil { + logger.Error(ctx, "failed to get group AI spend", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + // Read access was already established when the group was extracted from + // the route, so the query only returns no rows when the group was deleted + // in between. + if len(rows) == 0 { + httpapi.ResourceNotFound(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.GroupAISpend{ + AISpendPeriodWindow: codersdk.AISpendPeriodWindow{ + PeriodStart: periodWindow.Start, + PeriodEnd: periodWindow.End, + }, + OrganizationGroupAISpend: db2sdk.OrganizationGroupAISpend(rows[0]), + }) +} + // @Summary Get group members AI spend by organization // @Description Returns aggregate AI spend attributed to the group per requested user. // @Description A maximum of 100 user IDs may be requested per call, and requests with more are rejected, so callers are expected to batch across multiple requests. diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 38e467f0c6093..cba71e261786c 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1562,6 +1562,39 @@ func TestAIBridgeConcurrencyLimiting(t *testing.T) { } } +type boundaryLogSeed struct { + seq int32 + proto string + detail string + allowed bool +} + +// seedBoundaryLogs writes boundary logs for a firewall session via the raw +// store. A non-empty matched_rule marks a call allowed; a blocked call stores a +// NULL rule. No RBAC role grants boundary_log:create, so tests seed directly. +func seedBoundaryLogs(t *testing.T, db database.Store, fw, ownerID uuid.UUID, at time.Time, seeds []boundaryLogSeed) { + t.Helper() + logs := make([]database.BoundaryLog, 0, len(seeds)) + for _, s := range seeds { + rule := "" + if s.allowed { + rule = "allow " + s.detail + } + logs = append(logs, database.BoundaryLog{ + SessionID: fw, + OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true}, + SequenceNumber: s.seq, + CapturedAt: at, + CreatedAt: at, + Proto: s.proto, + Method: "GET", + Detail: s.detail, + MatchedRule: sql.NullString{String: rule, Valid: rule != ""}, + }) + } + dbgen.BoundaryLogs(t, db, logs) +} + func TestAIBridgeGetSessionThreads(t *testing.T) { t.Parallel() @@ -1672,6 +1705,301 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.Nil(t, res.Threads[1].AgentFirewallSequenceNumber) }) + t.Run("NetworkSummary", func(t *testing.T) { + t.Parallel() + // Use the raw store so boundary logs can be seeded directly. No RBAC + // role grants boundary_log:create; they are written by the agent path. + db, ps := dbtestutil.NewDB(t) + opts := aibridgeOpts(t) + opts.Options.Database = db + opts.Options.Pubsub = ps + client, _, firstUser := coderdenttest.NewWithDatabase(t, opts) + ctx := testutil.Context(t, testutil.WaitLong) + + now := dbtime.Now() + fw := uuid.New() + + // One interception marked at firewall seq 0, so its window is (0, +inf) + // and the LLM-provider call logged at seq 0 is excluded. + endedAt := now.Add(time.Minute) + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + StartedAt: now, + ClientSessionID: sql.NullString{String: "net-session", Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: 0, Valid: true}, + }, &endedAt) + + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, []boundaryLogSeed{ + {0, "http", "https://api.github.com/llm", true}, // LLM call, excluded + {1, "http", "https://api.github.com/repos/coder", true}, // github egress + {2, "http", "https://api.github.com/repos/other", true}, // github egress + {3, "http", "https://registry.npmjs.org/lodash", false}, // npm egress, blocked + {4, "http", "https://api.github.com/repos/more", true}, // github egress + {5, "dns", "example.com", true}, // non-http, ignored by top domains + {6, "http", "https://api.github.com:8080/repos", true}, // port-suffixed; host stripped to api.github.com + }) + + res, err := client.AIBridgeGetSessionThreads(ctx, "net-session", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + + // total counts seq 1-6 (LLM call at seq 0 excluded); one blocked. + require.NotNil(t, res.NetworkCalls) + require.EqualValues(t, 6, res.NetworkCalls.Total) + require.EqualValues(t, 1, res.NetworkCalls.Blocked) + + // Top domains covers HTTP egress only and is capped at one row (the + // summary card renders a single domain). github wins with 4 HTTP calls: + // seqs 1, 2, 4, and the port-suffixed seq 6 whose host strips to + // api.github.com (proving the port is not treated as a separate host). + // The dns log (seq 5) is excluded from domains. NetworkDomainCount is a + // window aggregate independent of the row cap, so it still reports the + // two distinct HTTP domains (github, npm). + require.Len(t, res.NetworkTopDomains, 1) + require.Equal(t, "api.github.com", res.NetworkTopDomains[0].Domain) + require.EqualValues(t, 4, res.NetworkTopDomains[0].Count) + require.EqualValues(t, 2, res.NetworkDomainCount) + + // The per-call list spans the same window as the summary (all protos, + // seq 0 LLM call excluded), ordered chronologically. Its length and + // blocked count agree with the summary counts. + gotSeqs := make([]int32, len(res.NetworkCallLogs)) + blocked := 0 + for i, c := range res.NetworkCallLogs { + gotSeqs[i] = c.SequenceNumber + if !c.Allowed { + blocked++ + } + } + require.Equal(t, []int32{1, 2, 3, 4, 5, 6}, gotSeqs) + require.EqualValues(t, res.NetworkCalls.Total, len(res.NetworkCallLogs)) + require.EqualValues(t, res.NetworkCalls.Blocked, blocked) + + // The blocked npm call (seq 3, index 2) has no matched rule; allowed + // calls do. + npm := res.NetworkCallLogs[2] + require.Equal(t, int32(3), npm.SequenceNumber) + require.Equal(t, "https://registry.npmjs.org/lodash", npm.Detail) + require.False(t, npm.Allowed) + require.Nil(t, npm.MatchedRule) + require.True(t, res.NetworkCallLogs[0].Allowed) + require.NotNil(t, res.NetworkCallLogs[0].MatchedRule) + + // Non-http protocols appear in the list (unlike top domains): seq 5 dns. + require.Equal(t, "dns", res.NetworkCallLogs[4].Proto) + }) + + t.Run("NetworkMultipleInterceptions", func(t *testing.T) { + t.Parallel() + // Two interceptions in the same firewall session must produce two + // consecutive, non-overlapping windows: (0, 5) for the first and + // (5, +inf) for the second. Each interception's own LLM call (logged at + // its own sequence) is excluded by the exclusive lower bound. + db, ps := dbtestutil.NewDB(t) + opts := aibridgeOpts(t) + opts.Options.Database = db + opts.Options.Pubsub = ps + client, _, firstUser := coderdenttest.NewWithDatabase(t, opts) + ctx := testutil.Context(t, testutil.WaitLong) + + now := dbtime.Now() + fw := uuid.New() + + for _, seq := range []int32{0, 5} { + endedAt := now.Add(time.Minute) + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + StartedAt: now, + ClientSessionID: sql.NullString{String: "multi-net", Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: seq, Valid: true}, + }, &endedAt) + } + + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, []boundaryLogSeed{ + {0, "http", "https://api.github.com/llm", true}, // interception 1 LLM call, excluded + {1, "http", "https://api.github.com/a", true}, // window (0,5) + {2, "http", "https://api.github.com/b", true}, // window (0,5) + {3, "http", "https://registry.npmjs.org/x", false}, // window (0,5), blocked + {5, "http", "https://api.github.com/llm2", true}, // interception 2 LLM call, excluded + {6, "http", "https://api.github.com/c", true}, // window (5,+inf) + {7, "http", "https://registry.npmjs.org/y", false}, // window (5,+inf), blocked + }) + + res, err := client.AIBridgeGetSessionThreads(ctx, "multi-net", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + + // Both windows contribute: seqs 1,2,3,6,7. The two LLM calls (0, 5) are + // excluded. Blocked = seqs 3 and 7. + require.NotNil(t, res.NetworkCalls) + require.EqualValues(t, 5, res.NetworkCalls.Total) + require.EqualValues(t, 2, res.NetworkCalls.Blocked) + // github: seqs 1,2,6 = 3; npm: seqs 3,7 = 2. Two distinct domains. + require.Len(t, res.NetworkTopDomains, 1) + require.Equal(t, "api.github.com", res.NetworkTopDomains[0].Domain) + require.EqualValues(t, 3, res.NetworkTopDomains[0].Count) + require.EqualValues(t, 2, res.NetworkDomainCount) + + // The per-call list spans both windows in chronological order, and its + // length and blocked count agree with the summary (three-way agreement + // across summary, top domains, and list). + gotSeqs := make([]int32, len(res.NetworkCallLogs)) + blocked := 0 + for i, c := range res.NetworkCallLogs { + gotSeqs[i] = c.SequenceNumber + if !c.Allowed { + blocked++ + } + } + require.Equal(t, []int32{1, 2, 3, 6, 7}, gotSeqs) + require.EqualValues(t, res.NetworkCalls.Total, len(res.NetworkCallLogs)) + require.EqualValues(t, res.NetworkCalls.Blocked, blocked) + }) + + t.Run("NetworkSharedFirewallSessionNoBleed", func(t *testing.T) { + t.Parallel() + // Two AI sessions share one firewall session. next_seq considers every + // interception in the firewall session, so session A's window is bounded + // by session B's interception and B's calls never bleed into A's counts. + db, ps := dbtestutil.NewDB(t) + opts := aibridgeOpts(t) + opts.Options.Database = db + opts.Options.Pubsub = ps + client, _, firstUser := coderdenttest.NewWithDatabase(t, opts) + ctx := testutil.Context(t, testutil.WaitLong) + + now := dbtime.Now() + fw := uuid.New() + + // Session A anchored at firewall seq 0; session B at seq 10. + for _, s := range []struct { + session string + seq int32 + }{{"sess-a", 0}, {"sess-b", 10}} { + endedAt := now.Add(time.Minute) + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + StartedAt: now, + ClientSessionID: sql.NullString{String: s.session, Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: s.seq, Valid: true}, + }, &endedAt) + } + + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, []boundaryLogSeed{ + {0, "http", "https://api.github.com/llm-a", true}, // A's LLM call, excluded + {1, "http", "https://api.github.com/a1", true}, // A's window (0,10) + {2, "http", "https://registry.npmjs.org/a2", false}, // A's window (0,10), blocked + {10, "http", "https://api.github.com/llm-b", true}, // B's LLM call, excluded + {11, "http", "https://api.github.com/b1", true}, // B's window (10,+inf) + {12, "http", "https://api.github.com/b2", true}, // B's window (10,+inf) + {13, "http", "https://registry.npmjs.org/b3", false}, // B's window (10,+inf), blocked + }) + + // Session A sees only its own two calls (seqs 1, 2), not B's, in both + // the summary and the per-call list. + resA, err := client.AIBridgeGetSessionThreads(ctx, "sess-a", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + require.NotNil(t, resA.NetworkCalls) + require.EqualValues(t, 2, resA.NetworkCalls.Total) + require.EqualValues(t, 1, resA.NetworkCalls.Blocked) + seqsA := make([]int32, len(resA.NetworkCallLogs)) + for i, c := range resA.NetworkCallLogs { + seqsA[i] = c.SequenceNumber + } + require.Equal(t, []int32{1, 2}, seqsA) + + // Session B sees only its own three calls (seqs 11, 12, 13), not A's. + resB, err := client.AIBridgeGetSessionThreads(ctx, "sess-b", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + require.NotNil(t, resB.NetworkCalls) + require.EqualValues(t, 3, resB.NetworkCalls.Total) + require.EqualValues(t, 1, resB.NetworkCalls.Blocked) + seqsB := make([]int32, len(resB.NetworkCallLogs)) + for i, c := range resB.NetworkCallLogs { + seqsB[i] = c.SequenceNumber + } + require.Equal(t, []int32{11, 12, 13}, seqsB) + }) + + t.Run("NetworkCallsTruncated", func(t *testing.T) { + t.Parallel() + // The per-call list is capped server-side while the summary total + // reflects the whole session. When a session exceeds the cap the list is + // truncated but the summary total stays authoritative. + db, ps := dbtestutil.NewDB(t) + opts := aibridgeOpts(t) + opts.Options.Database = db + opts.Options.Pubsub = ps + client, _, firstUser := coderdenttest.NewWithDatabase(t, opts) + ctx := testutil.Context(t, testutil.WaitLong) + + now := dbtime.Now() + fw := uuid.New() + + endedAt := now.Add(time.Minute) + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + StartedAt: now, + ClientSessionID: sql.NullString{String: "trunc-net", Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: 0, Valid: true}, + }, &endedAt) + + // Allowed HTTP calls at seqs 1..total, all in the interception's + // window (0, +inf), seeded past the server-side cap. + const listCap = 1000 + const total = listCap + 5 + seeds := make([]boundaryLogSeed, 0, total) + for seq := int32(1); seq <= total; seq++ { + seeds = append(seeds, boundaryLogSeed{seq, "http", "https://api.github.com/x", true}) + } + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, seeds) + + res, err := client.AIBridgeGetSessionThreads(ctx, "trunc-net", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + + // The summary reflects every call; the list stops at the cap. + require.NotNil(t, res.NetworkCalls) + require.EqualValues(t, total, res.NetworkCalls.Total) + require.Len(t, res.NetworkCallLogs, listCap) + // The cap keeps the earliest calls in chronological order. + require.EqualValues(t, 1, res.NetworkCallLogs[0].SequenceNumber) + require.EqualValues(t, listCap, res.NetworkCallLogs[listCap-1].SequenceNumber) + }) + + t.Run("NetworkSummaryDisabled", func(t *testing.T) { + t.Parallel() + client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + now := dbtime.Now() + endedAt := now.Add(time.Minute) + // No firewall correlation: network monitoring was not active. + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + StartedAt: now, + ClientSessionID: sql.NullString{String: "no-fw-session", Valid: true}, + }, &endedAt) + + res, err := client.AIBridgeGetSessionThreads(ctx, "no-fw-session", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + require.Nil(t, res.NetworkCalls) + require.Empty(t, res.NetworkTopDomains) + require.EqualValues(t, 0, res.NetworkDomainCount) + require.Empty(t, res.NetworkCallLogs) + }) + t.Run("ThreadsWithAgenticActions", func(t *testing.T) { t.Parallel() client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t)) @@ -2289,6 +2617,40 @@ func TestGroupAIBudget(t *testing.T) { require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) }) + t.Run("SpendLimitMaximum", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + limit int64 + wantError bool + }{ + {name: "AtMaximum", limit: codersdk.MaxAISpendLimitMicros}, + {name: "AboveMaximum", limit: codersdk.MaxAISpendLimitMicros + 1, wantError: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + adminClient, group := setupGroupAIBudgetTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + budget, err := adminClient.UpsertGroupAIBudget(ctx, group.ID, codersdk.UpsertGroupAIBudgetRequest{ + SpendLimitMicros: tc.limit, + }) + if tc.wantError { + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + return + } + require.NoError(t, err) + require.Equal(t, tc.limit, budget.SpendLimitMicros) + }) + } + }) + t.Run("AcceptsZeroSpendLimitToBlock", func(t *testing.T) { t.Parallel() @@ -2590,6 +2952,41 @@ func TestUserAIBudgetOverride(t *testing.T) { require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) }) + t.Run("Upsert/SpendLimitMaximum", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + limit int64 + wantError bool + }{ + {name: "AtMaximum", limit: codersdk.MaxAISpendLimitMicros}, + {name: "AboveMaximum", limit: codersdk.MaxAISpendLimitMicros + 1, wantError: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "override-max-group"}) + ctx := testutil.Context(t, testutil.WaitLong) + + override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: tc.limit, + }) + if tc.wantError { + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + return + } + require.NoError(t, err) + require.Equal(t, tc.limit, override.SpendLimitMicros) + }) + } + }) + t.Run("Upsert/RejectsUnknownGroup", func(t *testing.T) { t.Parallel() @@ -2929,7 +3326,6 @@ func TestUserAIBudgetOverrideRoleAccess(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -3027,7 +3423,6 @@ func TestUserAIBudgetOverrideDeletedOnMembershipRemoval(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -3117,7 +3512,6 @@ func TestUserAISpendStatus(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} client, _ := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -3133,61 +3527,44 @@ func TestUserAISpendStatus(t *testing.T) { require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) }) - t.Run("RequiresExperiment", func(t *testing.T) { - t.Parallel() - - dv := coderdtest.DeploymentValues(t) - dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - client, _ := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{DeploymentValues: dv}, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureAIBridge: 1, - }, - }, - }) - ctx := testutil.Context(t, testutil.WaitLong) - - //nolint:gocritic // Owner role is irrelevant here; the request is blocked before RBAC. - _, err := client.UserAISpendStatus(ctx, uuid.New()) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) - }) - tests := []struct { name string groupBudget *int64 // nil = no group budget configured overrideLimit *int64 // nil = no user override configured spent int64 // 0 = no spend seeded wantHasEffectiveGroup bool - wantSpendLimitMicros *int64 - wantLimitSource *codersdk.AIBudgetLimitSource + wantEffectiveBudget *codersdk.AIBudgetLimit wantCurrentSpendMicros int64 }{ { name: "GroupBudget/ZeroSpend", groupBudget: ptr.Ref(int64(1_000_000_000)), wantHasEffectiveGroup: true, - wantSpendLimitMicros: ptr.Ref(int64(1_000_000_000)), - wantLimitSource: ptr.Ref(codersdk.AIBudgetLimitSourceGroup), + wantEffectiveBudget: &codersdk.AIBudgetLimit{ + SpendLimitMicros: 1_000_000_000, + LimitSource: codersdk.AIBudgetLimitSourceGroup, + }, }, { - name: "GroupBudget/PartialSpend", - groupBudget: ptr.Ref(int64(1_000_000_000)), - spent: 250_000_000, - wantHasEffectiveGroup: true, - wantSpendLimitMicros: ptr.Ref(int64(1_000_000_000)), - wantLimitSource: ptr.Ref(codersdk.AIBudgetLimitSourceGroup), + name: "GroupBudget/PartialSpend", + groupBudget: ptr.Ref(int64(1_000_000_000)), + spent: 250_000_000, + wantHasEffectiveGroup: true, + wantEffectiveBudget: &codersdk.AIBudgetLimit{ + SpendLimitMicros: 1_000_000_000, + LimitSource: codersdk.AIBudgetLimitSourceGroup, + }, wantCurrentSpendMicros: 250_000_000, }, { - name: "GroupBudget/SpendExceedsLimit", - groupBudget: ptr.Ref(int64(1_000_000_000)), - spent: 1_500_000_000, - wantHasEffectiveGroup: true, - wantSpendLimitMicros: ptr.Ref(int64(1_000_000_000)), - wantLimitSource: ptr.Ref(codersdk.AIBudgetLimitSourceGroup), + name: "GroupBudget/SpendExceedsLimit", + groupBudget: ptr.Ref(int64(1_000_000_000)), + spent: 1_500_000_000, + wantHasEffectiveGroup: true, + wantEffectiveBudget: &codersdk.AIBudgetLimit{ + SpendLimitMicros: 1_000_000_000, + LimitSource: codersdk.AIBudgetLimitSourceGroup, + }, wantCurrentSpendMicros: 1_500_000_000, }, { @@ -3195,27 +3572,33 @@ func TestUserAISpendStatus(t *testing.T) { groupBudget: ptr.Ref(int64(5_000_000_000)), overrideLimit: ptr.Ref(int64(200_000_000)), wantHasEffectiveGroup: true, - wantSpendLimitMicros: ptr.Ref(int64(200_000_000)), - wantLimitSource: ptr.Ref(codersdk.AIBudgetLimitSourceUserOverride), + wantEffectiveBudget: &codersdk.AIBudgetLimit{ + SpendLimitMicros: 200_000_000, + LimitSource: codersdk.AIBudgetLimitSourceUserOverride, + }, }, { - name: "UserOverride/PartialSpend", - groupBudget: ptr.Ref(int64(5_000_000_000)), - overrideLimit: ptr.Ref(int64(200_000_000)), - spent: 50_000_000, - wantHasEffectiveGroup: true, - wantSpendLimitMicros: ptr.Ref(int64(200_000_000)), - wantLimitSource: ptr.Ref(codersdk.AIBudgetLimitSourceUserOverride), + name: "UserOverride/PartialSpend", + groupBudget: ptr.Ref(int64(5_000_000_000)), + overrideLimit: ptr.Ref(int64(200_000_000)), + spent: 50_000_000, + wantHasEffectiveGroup: true, + wantEffectiveBudget: &codersdk.AIBudgetLimit{ + SpendLimitMicros: 200_000_000, + LimitSource: codersdk.AIBudgetLimitSourceUserOverride, + }, wantCurrentSpendMicros: 50_000_000, }, { - name: "UserOverride/SpendExceedsLimit", - groupBudget: ptr.Ref(int64(5_000_000_000)), - overrideLimit: ptr.Ref(int64(200_000_000)), - spent: 350_000_000, - wantHasEffectiveGroup: true, - wantSpendLimitMicros: ptr.Ref(int64(200_000_000)), - wantLimitSource: ptr.Ref(codersdk.AIBudgetLimitSourceUserOverride), + name: "UserOverride/SpendExceedsLimit", + groupBudget: ptr.Ref(int64(5_000_000_000)), + overrideLimit: ptr.Ref(int64(200_000_000)), + spent: 350_000_000, + wantHasEffectiveGroup: true, + wantEffectiveBudget: &codersdk.AIBudgetLimit{ + SpendLimitMicros: 200_000_000, + LimitSource: codersdk.AIBudgetLimitSourceUserOverride, + }, wantCurrentSpendMicros: 350_000_000, }, } @@ -3274,8 +3657,7 @@ func TestUserAISpendStatus(t *testing.T) { wantEffectiveGroupID = &group.ID } require.Equal(t, wantEffectiveGroupID, got.EffectiveGroupID) - require.Equal(t, tt.wantSpendLimitMicros, got.SpendLimitMicros) - require.Equal(t, tt.wantLimitSource, got.LimitSource) + require.Equal(t, tt.wantEffectiveBudget, got.EffectiveBudget) }) } @@ -3308,8 +3690,7 @@ func TestUserAISpendStatus(t *testing.T) { got, err := adminClient.UserAISpendStatus(ctx, targetUser.ID) require.NoError(t, err) require.Equal(t, &everyoneGroupID, got.EffectiveGroupID) - require.Nil(t, got.SpendLimitMicros) - require.Nil(t, got.LimitSource) + require.Nil(t, got.EffectiveBudget) require.Equal(t, int64(100_000_000), got.CurrentSpendMicros) }) @@ -3333,8 +3714,7 @@ func TestUserAISpendStatus(t *testing.T) { got, err := adminClient.UserAISpendStatus(ctx, orglessUser.ID) require.NoError(t, err) require.Nil(t, got.EffectiveGroupID) - require.Nil(t, got.SpendLimitMicros) - require.Nil(t, got.LimitSource) + require.Nil(t, got.EffectiveBudget) require.Equal(t, int64(0), got.CurrentSpendMicros) }) } @@ -3344,7 +3724,6 @@ func TestUserAISpendStatusRoleAccess(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -3398,7 +3777,6 @@ func TestOrganizationGroupsAISpend(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} client, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -3417,30 +3795,6 @@ func TestOrganizationGroupsAISpend(t *testing.T) { require.Contains(t, sdkErr.Message, "AI Gateway is a Premium feature") }) - t.Run("RequiresExperiment", func(t *testing.T) { - t.Parallel() - - dv := coderdtest.DeploymentValues(t) - dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - client, owner := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{DeploymentValues: dv}, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureTemplateRBAC: 1, - codersdk.FeatureAIBridge: 1, - }, - }, - }) - ctx := testutil.Context(t, testutil.WaitLong) - - //nolint:gocritic // Owner role is irrelevant here; the request is blocked before RBAC. - _, err := client.OrganizationGroupsAISpend(ctx, owner.OrganizationID, []uuid.UUID{uuid.New()}) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) - require.Contains(t, sdkErr.Message, "ai-gateway-cost-control") - }) - t.Run("MissingGroupIDs", func(t *testing.T) { t.Parallel() @@ -3544,46 +3898,53 @@ func TestOrganizationGroupsAISpend(t *testing.T) { require.Equal(t, group.ID, resp.Groups[0].GroupID) }) + // The group has a single member, so a budgeted group's total matches its + // per-member limit and an unbudgeted group reports null. tests := []struct { - name string - setBudget bool - spendLimit int64 - spent int64 - wantSpendLimit *int64 - wantCurrentSpend int64 + name string + setBudget bool + spendLimit int64 + spent int64 + wantSpendLimit *int64 + wantTotalSpendLimit *int64 + wantCurrentSpend int64 }{ { name: "NoBudgetNoSpend", }, { - name: "ZeroLimitBudget", - setBudget: true, - spendLimit: 0, - wantSpendLimit: ptr.Ref(int64(0)), - wantCurrentSpend: 0, + name: "ZeroLimitBudget", + setBudget: true, + spendLimit: 0, + wantSpendLimit: ptr.Ref(int64(0)), + wantTotalSpendLimit: ptr.Ref(int64(0)), + wantCurrentSpend: 0, }, { - name: "BudgetZeroSpend", - setBudget: true, - spendLimit: 1_000_000_000, - wantSpendLimit: ptr.Ref(int64(1_000_000_000)), - wantCurrentSpend: 0, + name: "BudgetZeroSpend", + setBudget: true, + spendLimit: 1_000_000_000, + wantSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantTotalSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantCurrentSpend: 0, }, { - name: "BudgetWithSpend", - setBudget: true, - spendLimit: 1_000_000_000, - spent: 250_000_000, - wantSpendLimit: ptr.Ref(int64(1_000_000_000)), - wantCurrentSpend: 250_000_000, + name: "BudgetWithSpend", + setBudget: true, + spendLimit: 1_000_000_000, + spent: 250_000_000, + wantSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantTotalSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantCurrentSpend: 250_000_000, }, { - name: "SpendExceedsLimit", - setBudget: true, - spendLimit: 1_000_000_000, - spent: 1_500_000_000, - wantSpendLimit: ptr.Ref(int64(1_000_000_000)), - wantCurrentSpend: 1_500_000_000, + name: "SpendExceedsLimit", + setBudget: true, + spendLimit: 1_000_000_000, + spent: 1_500_000_000, + wantSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantTotalSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantCurrentSpend: 1_500_000_000, }, } @@ -3631,9 +3992,38 @@ func TestOrganizationGroupsAISpend(t *testing.T) { require.Len(t, got.Groups, 1) require.Equal(t, group.ID, got.Groups[0].GroupID) require.Equal(t, tt.wantSpendLimit, got.Groups[0].SpendLimitMicros) + require.Equal(t, tt.wantTotalSpendLimit, got.Groups[0].TotalSpendLimitMicros) require.Equal(t, tt.wantCurrentSpend, got.Groups[0].CurrentSpendMicros) }) } + + t.Run("TotalCombinesAllMembers", func(t *testing.T) { + t.Parallel() + + adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "total-all-members-org-group"}) + ctx := testutil.Context(t, testutil.WaitLong) + + // Given: a second member in a group with a per-member budget. + _, second := coderdtest.CreateAnotherUser(t, adminClient, group.OrganizationID) + _, err := adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{second.ID.String()}, + }) + require.NoError(t, err) + _, err = adminClient.UpsertGroupAIBudget(ctx, group.ID, codersdk.UpsertGroupAIBudgetRequest{ + SpendLimitMicros: 1_000_000_000, + }) + require.NoError(t, err) + + // When: querying the group's spend. + got, err := adminClient.OrganizationGroupsAISpend(ctx, group.OrganizationID, []uuid.UUID{group.ID}) + require.NoError(t, err) + + // Then: the total covers both members while the per-member limit is unchanged. + require.Len(t, got.Groups, 1) + require.Equal(t, ptr.Ref(int64(1_000_000_000)), got.Groups[0].SpendLimitMicros) + require.Equal(t, ptr.Ref(int64(2_000_000_000)), got.Groups[0].TotalSpendLimitMicros) + require.Equal(t, int64(0), got.Groups[0].CurrentSpendMicros) + }) } func TestOrganizationGroupsAISpendRoleAccess(t *testing.T) { @@ -3641,7 +4031,6 @@ func TestOrganizationGroupsAISpendRoleAccess(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -3744,22 +4133,14 @@ func TestExportOrganizationAISpend(t *testing.T) { cases := []struct { name string - experiments []string features license.Features wantMsgContains string }{ { name: "RequiresLicenseFeature", - experiments: []string{string(codersdk.ExperimentAIGatewayCostControl)}, features: license.Features{}, wantMsgContains: "AI Gateway is a Premium feature", }, - { - name: "RequiresExperiment", - experiments: nil, - features: license.Features{codersdk.FeatureAIBridge: 1}, - wantMsgContains: "ai-gateway-cost-control", - }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -3767,9 +4148,6 @@ func TestExportOrganizationAISpend(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - if len(tc.experiments) > 0 { - dv.Experiments = tc.experiments - } client, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{Features: tc.features}, @@ -4412,7 +4790,6 @@ func TestExportOrganizationAISpend(t *testing.T) { db, ps := dbtestutil.NewDB(t) dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv, Database: db, Pubsub: ps, Clock: clock}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -4570,6 +4947,254 @@ func TestExportOrganizationAISpend(t *testing.T) { }) } +func TestGroupAISpend(t *testing.T) { + t.Parallel() + + t.Run("RequiresLicenseFeature", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + ctx := testutil.Context(t, testutil.WaitLong) + group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "req-license-feature-spend-group", + }) + require.NoError(t, err) + + _, err = adminClient.GroupAISpend(ctx, group.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "AI Gateway is a Premium feature") + }) + + t.Run("MalformedGroupID", func(t *testing.T) { + t.Parallel() + + adminClient, _, _ := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "malformed-group-id-spend-group"}) + ctx := testutil.Context(t, testutil.WaitLong) + + // Given: a malformed UUID in the path. + // When: querying spend. + res, err := adminClient.Request(ctx, http.MethodGet, "/api/v2/groups/not-a-uuid/ai/spend", nil) + require.NoError(t, err) + defer res.Body.Close() + + // Then: 400. + require.Equal(t, http.StatusBadRequest, res.StatusCode) + }) + + t.Run("UnknownGroup", func(t *testing.T) { + t.Parallel() + + adminClient, _, _ := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "unknown-group-spend-group"}) + ctx := testutil.Context(t, testutil.WaitLong) + + // Given: a group ID that does not exist. + // When: querying spend. + _, err := adminClient.GroupAISpend(ctx, uuid.New()) + + // Then: request fails with 404. + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + // The group has a single member, so a budgeted group's total matches its + // per-member limit and an unbudgeted group reports null. + tests := []struct { + name string + setBudget bool + spendLimit int64 + spent int64 + wantSpendLimit *int64 + wantTotalSpendLimit *int64 + wantCurrentSpend int64 + }{ + { + name: "NoBudgetNoSpend", + }, + { + name: "ZeroLimitBudget", + setBudget: true, + spendLimit: 0, + wantSpendLimit: ptr.Ref(int64(0)), + wantTotalSpendLimit: ptr.Ref(int64(0)), + wantCurrentSpend: 0, + }, + { + name: "BudgetZeroSpend", + setBudget: true, + spendLimit: 1_000_000_000, + wantSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantTotalSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantCurrentSpend: 0, + }, + { + name: "BudgetWithSpend", + setBudget: true, + spendLimit: 1_000_000_000, + spent: 250_000_000, + wantSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantTotalSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantCurrentSpend: 250_000_000, + }, + { + name: "SpendExceedsLimit", + setBudget: true, + spendLimit: 1_000_000_000, + spent: 1_500_000_000, + wantSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantTotalSpendLimit: ptr.Ref(int64(1_000_000_000)), + wantCurrentSpend: 1_500_000_000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Given: an admin, a group, and optionally a budget and seeded spend. + clock := quartz.NewMock(t) + db, ps := dbtestutil.NewDB(t) + adminClient, targetUser, group := setupAICostControlTest(t, aiCostControlTestOptions{ + GroupName: "group-spend-test-group", + Clock: clock, + Database: db, + Pubsub: ps, + }) + ctx := testutil.Context(t, testutil.WaitLong) + clock.Set(time.Date(2026, time.March, 15, 12, 0, 0, 0, time.UTC)) + wantPeriodStart := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) + wantPeriodEnd := time.Date(2026, time.April, 1, 0, 0, 0, 0, time.UTC) + + if tt.setBudget { + _, err := adminClient.UpsertGroupAIBudget(ctx, group.ID, codersdk.UpsertGroupAIBudgetRequest{ + SpendLimitMicros: tt.spendLimit, + }) + require.NoError(t, err) + } + if tt.spent > 0 { + _, err := db.IncrementUserAIDailySpend(ctx, database.IncrementUserAIDailySpendParams{ + UserID: targetUser.ID, + EffectiveGroupID: group.ID, + Day: clock.Now(), + CostMicros: tt.spent, + }) + require.NoError(t, err) + } + + // When: querying the group's spend. + got, err := adminClient.GroupAISpend(ctx, group.ID) + require.NoError(t, err) + + // Then: the response reports the expected budget and spend. + require.Equal(t, wantPeriodStart, got.PeriodStart) + require.Equal(t, wantPeriodEnd, got.PeriodEnd) + require.Equal(t, group.ID, got.GroupID) + require.Equal(t, tt.wantSpendLimit, got.SpendLimitMicros) + require.Equal(t, tt.wantTotalSpendLimit, got.TotalSpendLimitMicros) + require.Equal(t, tt.wantCurrentSpend, got.CurrentSpendMicros) + }) + } + + t.Run("TotalCombinesAllMembers", func(t *testing.T) { + t.Parallel() + + adminClient, _, group := setupAICostControlTest(t, aiCostControlTestOptions{GroupName: "total-all-members-group"}) + ctx := testutil.Context(t, testutil.WaitLong) + + // Given: a second member in a group with a per-member budget. + _, second := coderdtest.CreateAnotherUser(t, adminClient, group.OrganizationID) + _, err := adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{second.ID.String()}, + }) + require.NoError(t, err) + _, err = adminClient.UpsertGroupAIBudget(ctx, group.ID, codersdk.UpsertGroupAIBudgetRequest{ + SpendLimitMicros: 1_000_000_000, + }) + require.NoError(t, err) + + // When: querying the group's spend. + got, err := adminClient.GroupAISpend(ctx, group.ID) + require.NoError(t, err) + + // Then: the total covers both members while the per-member limit is unchanged. + require.Equal(t, ptr.Ref(int64(1_000_000_000)), got.SpendLimitMicros) + require.Equal(t, ptr.Ref(int64(2_000_000_000)), got.TotalSpendLimitMicros) + require.Equal(t, int64(0), got.CurrentSpendMicros) + }) +} + +func TestGroupAISpendRoleAccess(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAIBridge: 1, + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + orgUserAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgUserAdmin(owner.OrganizationID)) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + otherOrg := coderdenttest.CreateOrganization(t, ownerClient, coderdenttest.CreateOrganizationOptions{}) + otherOrgMemberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, otherOrg.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + group, err := userAdminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "group-spend-role-access-group", + }) + require.NoError(t, err) + + cases := []struct { + name string + client *codersdk.Client + wantGroup bool + }{ + {name: "Owner", client: ownerClient, wantGroup: true}, + {name: "UserAdmin", client: userAdminClient, wantGroup: true}, + {name: "OrgAdmin", client: orgAdminClient, wantGroup: true}, + {name: "OrgUserAdmin", client: orgUserAdminClient, wantGroup: true}, + {name: "Member", client: memberClient, wantGroup: true}, + {name: "OtherOrgMember", client: otherOrgMemberClient, wantGroup: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + resp, err := tc.client.GroupAISpend(ctx, group.ID) + if !tc.wantGroup { + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + return + } + require.NoError(t, err) + require.Equal(t, group.ID, resp.GroupID) + }) + } +} + func TestExportOrganizationAISpendRoleAccess(t *testing.T) { t.Parallel() @@ -4584,7 +5209,6 @@ func TestExportOrganizationAISpendRoleAccess(t *testing.T) { db, ps := dbtestutil.NewDB(t) dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv, Database: db, Pubsub: ps, Clock: clock}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -4676,7 +5300,6 @@ func TestGroupMembersAISpend(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -4699,34 +5322,6 @@ func TestGroupMembersAISpend(t *testing.T) { require.Contains(t, sdkErr.Message, "AI Gateway is a Premium feature") }) - t.Run("RequiresExperiment", func(t *testing.T) { - t.Parallel() - - dv := coderdtest.DeploymentValues(t) - dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{DeploymentValues: dv}, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureTemplateRBAC: 1, - codersdk.FeatureAIBridge: 1, - }, - }, - }) - adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) - ctx := testutil.Context(t, testutil.WaitLong) - group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ - Name: "req-experiment-members-group", - }) - require.NoError(t, err) - - _, err = adminClient.GroupMembersAISpend(ctx, group.ID, []uuid.UUID{uuid.New()}) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) - require.Contains(t, sdkErr.Message, "ai-gateway-cost-control") - }) - t.Run("MissingUserIDs", func(t *testing.T) { t.Parallel() @@ -4838,14 +5433,14 @@ func TestGroupMembersAISpend(t *testing.T) { spent int64 wantEffectiveGroup bool wantEffectiveEveryone bool - wantGroupBudget *codersdk.AIGroupBudget + wantGroupBudget *codersdk.AIBudgetLimit wantSpendMicros int64 }{ { name: "BudgetZeroSpend", groupLimit: 1_000_000_000, wantEffectiveGroup: true, - wantGroupBudget: &codersdk.AIGroupBudget{ + wantGroupBudget: &codersdk.AIBudgetLimit{ SpendLimitMicros: 1_000_000_000, LimitSource: codersdk.AIBudgetLimitSourceGroup, }, @@ -4855,7 +5450,7 @@ func TestGroupMembersAISpend(t *testing.T) { groupLimit: 1_000_000_000, spent: 250_000_000, wantEffectiveGroup: true, - wantGroupBudget: &codersdk.AIGroupBudget{ + wantGroupBudget: &codersdk.AIBudgetLimit{ SpendLimitMicros: 1_000_000_000, LimitSource: codersdk.AIBudgetLimitSourceGroup, }, @@ -4865,7 +5460,7 @@ func TestGroupMembersAISpend(t *testing.T) { name: "OverrideBudget", overrideLimit: 500_000_000, wantEffectiveGroup: true, - wantGroupBudget: &codersdk.AIGroupBudget{ + wantGroupBudget: &codersdk.AIBudgetLimit{ SpendLimitMicros: 500_000_000, LimitSource: codersdk.AIBudgetLimitSourceUserOverride, }, @@ -4956,7 +5551,6 @@ func TestGroupMembersAISpend(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} db, ps := dbtestutil.NewDB(t) ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv, Database: db, Pubsub: ps}, @@ -5015,7 +5609,6 @@ func TestGroupMembersAISpend(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} db, ps := dbtestutil.NewDB(t) ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv, Database: db, Pubsub: ps}, @@ -5105,7 +5698,6 @@ func TestGroupMembersAISpendRoleAccess(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{DeploymentValues: dv}, LicenseOptions: &coderdenttest.LicenseOptions{ @@ -5195,16 +5787,14 @@ type aiCostControlTestOptions struct { Retention *time.Duration } -// setupAICostControlTest builds a deployment with FeatureAIBridge licensed -// and the AI Gateway cost control experiment enabled, creates an admin -// client and target user, adds the target user to a group, and returns -// the admin client, target user, and group. +// setupAICostControlTest builds a deployment with FeatureAIBridge licensed, +// creates an admin client and target user, adds the target user to a group, +// and returns the admin client, target user, and group. func setupAICostControlTest(t *testing.T, opts aiCostControlTestOptions) (*codersdk.Client, codersdk.User, codersdk.Group) { t.Helper() dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} if opts.Retention != nil { dv.AI.BridgeConfig.Retention = serpent.Duration(*opts.Retention) } @@ -5256,7 +5846,6 @@ func setupUserAIBudgetOverrideAuditTest(t *testing.T) (database.Store, *codersdk ) dv := coderdtest.DeploymentValues(t) dv.AI.BridgeConfig.Enabled = serpent.Bool(true) - dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ AuditLogging: true, Options: &coderdtest.Options{ diff --git a/enterprise/coderd/aibridgeserve.go b/enterprise/coderd/aibridgeserve.go index 09c48086a6dce..44cd4dd16af58 100644 --- a/enterprise/coderd/aibridgeserve.go +++ b/enterprise/coderd/aibridgeserve.go @@ -159,7 +159,7 @@ func (api *API) aiGatewayServe(rw http.ResponseWriter, r *http.Request) { return } - server := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, + server := drpcsdk.NewServer(logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 0a6118214da7b..8df7476400e31 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -506,8 +506,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Route("/ai/spend", func(r chi.Router) { // AI cost controls are a paid feature (AI Governance add-on). r.Use( - // TODO(AIGOV-443): remove once AI Gateway cost control functionality is stable. - httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentAIGatewayCostControl), api.RequireFeatureMW(codersdk.FeatureAIBridge), ) r.Get("/", api.organizationGroupsAISpend) @@ -522,8 +520,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Route("/members/ai/spend", func(r chi.Router) { // AI cost controls are a paid feature (AI Governance add-on). r.Use( - // TODO(AIGOV-443): remove once AI Gateway cost control functionality is stable. - httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentAIGatewayCostControl), api.RequireFeatureMW(codersdk.FeatureAIBridge), ) r.Get("/", api.groupMembersAISpendByOrganization) @@ -535,8 +531,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Use( apiKeyMiddleware, httpmw.ExtractOrganizationParam(api.Database), - // TODO(AIGOV-443): remove once AI Gateway cost control functionality is stable. - httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentAIGatewayCostControl), api.RequireFeatureMW(codersdk.FeatureAIBridge), ) r.Get("/export", api.exportOrganizationAISpend) @@ -627,12 +621,17 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Route("/members/ai/spend", func(r chi.Router) { // AI cost controls are a paid feature (AI Governance add-on). r.Use( - // TODO(AIGOV-443): remove once AI Gateway cost control functionality is stable. - httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentAIGatewayCostControl), api.RequireFeatureMW(codersdk.FeatureAIBridge), ) r.Get("/", api.groupMembersAISpend) }) + r.Route("/ai/spend", func(r chi.Router) { + // AI cost controls are a paid feature (AI Governance add-on). + r.Use( + api.RequireFeatureMW(codersdk.FeatureAIBridge), + ) + r.Get("/", api.groupAISpend) + }) r.Route("/ai/budget", func(r chi.Router) { // AI cost controls are a paid feature (AI Governance add-on). r.Use(api.RequireFeatureMW(codersdk.FeatureAIBridge)) @@ -683,13 +682,11 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Route("/users/{user}/ai", func(r chi.Router) { // AI cost controls are a paid feature (AI Governance add-on). r.Use( - // TODO(AIGOV-443): remove once AI Gateway cost control functionality is stable. - httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentAIGatewayCostControl), api.RequireFeatureMW(codersdk.FeatureAIBridge), apiKeyMiddleware, httpmw.ExtractUserParam(options.Database), ) - r.Route("/budget", func(r chi.Router) { + r.Route("/budget/override", func(r chi.Router) { r.Get("/", api.userAIBudgetOverride) r.Put("/", api.upsertUserAIBudgetOverride) r.Delete("/", api.deleteUserAIBudgetOverride) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index c2ba568b9691d..110ff2e13c8dc 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -374,7 +374,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err)) return } - server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + server := drpcsdk.NewServer(logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if xerrors.Is(err, io.EOF) { diff --git a/enterprise/coderd/usersecrets_audit_test.go b/enterprise/coderd/usersecrets_audit_test.go index 46deac17f768c..f95023be70f30 100644 --- a/enterprise/coderd/usersecrets_audit_test.go +++ b/enterprise/coderd/usersecrets_audit_test.go @@ -58,6 +58,7 @@ func TestUserSecretAuditDiffRedaction(t *testing.T) { Name: "createDiff-target", Description: initialDescription, Value: initialValue, + EnvName: "CREATE_DIFF_TARGET", }) require.NoError(t, err) diff --git a/enterprise/dbcrypt/cliutil.go b/enterprise/dbcrypt/cliutil.go index b298828055df9..086c9ba6b4f97 100644 --- a/enterprise/dbcrypt/cliutil.go +++ b/enterprise/dbcrypt/cliutil.go @@ -95,6 +95,8 @@ func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciphe EnvName: "", UpdateFilePath: false, FilePath: "", + UpdateEnabled: false, + Enabled: false, }); err != nil { return xerrors.Errorf("rotate user secret user_id=%s name=%s: %w", uid, secret.Name, err) } @@ -307,6 +309,8 @@ func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciph EnvName: "", UpdateFilePath: false, FilePath: "", + UpdateEnabled: false, + Enabled: false, }); err != nil { return xerrors.Errorf("decrypt user secret user_id=%s name=%s: %w", uid, secret.Name, err) } diff --git a/enterprise/tailnet/connio.go b/enterprise/tailnet/connio.go index 7c186dc1a0480..0803aa79f5fe3 100644 --- a/enterprise/tailnet/connio.go +++ b/enterprise/tailnet/connio.go @@ -3,6 +3,7 @@ package tailnet import ( "context" "fmt" + "runtime/debug" "slices" "sync" @@ -100,6 +101,17 @@ func (c *connIO) recvLoop() { } }() defer c.Close() + // This must be deferred after Close so it runs first: Enqueue needs the + // response channel open to deliver CloseErrInternal to the peer. + defer func() { + if recovered := recover(); recovered != nil { + c.logger.Error(c.peerCtx, "panic handling peer request (recovered)", + slog.F("panic", recovered), + slog.F("stack", string(debug.Stack())), + ) + _ = c.Enqueue(&proto.CoordinateResponse{Error: agpl.CloseErrInternal}) + } + }() for { select { case <-c.coordCtx.Done(): @@ -128,6 +140,10 @@ var errDisconnect = xerrors.New("graceful disconnect") func (c *connIO) handleRequest(req *proto.CoordinateRequest) error { c.logger.Debug(c.peerCtx, "got request") + if err := agpl.ValidateCoordinateRequest(req); err != nil { + c.logger.Warn(c.peerCtx, "invalid coordinate request", slog.Error(err)) + return err + } err := c.auth.Authorize(c.peerCtx, req) if err != nil { c.logger.Warn(c.peerCtx, "unauthorized request", slog.Error(err)) diff --git a/enterprise/tailnet/connio_internal_test.go b/enterprise/tailnet/connio_internal_test.go new file mode 100644 index 0000000000000..77649c2ed8332 --- /dev/null +++ b/enterprise/tailnet/connio_internal_test.go @@ -0,0 +1,89 @@ +package tailnet + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/sloghuman" + "cdr.dev/slog/v3/sloggers/slogtest" + agpl "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + agpltest "github.com/coder/coder/v2/tailnet/test" + "github.com/coder/coder/v2/testutil" +) + +func TestConnIOHandleRequestRejectsBeforeMutation(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + bindings := make(chan binding, 1) + var logbuf strings.Builder + c := &connIO{ + id: uuid.New(), + coordCtx: ctx, + peerCtx: ctx, + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).AppendSinks(sloghuman.Sink(&logbuf)), + bindings: bindings, + auth: agpl.SingleTailnetCoordinateeAuth{}, + } + + err := c.handleRequest(&proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{ + PreferredDerp: 31002, + }}, + ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{nil}, + }) + require.EqualError(t, err, "ready_for_handshake entries must not be nil") + require.Contains(t, logbuf.String(), "invalid coordinate request") + select { + case binding := <-bindings: + t.Fatalf("unexpected binding: %+v", binding) + default: + } +} + +func TestConnIORecvLoopPanicAfterAuthorization(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + bindings := make(chan binding, 1) + requests := make(chan *proto.CoordinateRequest, 1) + responses := make(chan *proto.CoordinateResponse, 1) + rfhs := make(chan readyForHandshake) + close(rfhs) + authorized := make(chan struct{}, 1) + peerID := uuid.New() + destinationID := uuid.New() + c := newConnIO(ctx, ctx, logger, bindings, make(chan tunnel, 1), rfhs, + requests, responses, peerID, t.Name(), agpltest.CoordinateeAuthFunc(func(context.Context, *proto.CoordinateRequest) error { + authorized <- struct{}{} + return nil + })) + t.Cleanup(func() { require.NoError(t, c.Close()) }) + c.setLatestMapping([]mapping{{peer: destinationID}}) + + testutil.RequireSend(ctx, t, requests, &proto.CoordinateRequest{ + ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{{Id: agpl.UUIDToByteSlice(destinationID)}}, + }) + testutil.RequireReceive(ctx, t, authorized) + response := testutil.RequireReceive(ctx, t, responses) + require.Equal(t, agpl.CloseErrInternal, response.Error) + require.NotContains(t, response.Error, "send on closed channel") + select { + case _, ok := <-responses: + require.False(t, ok) + case <-ctx.Done(): + t.Fatal("response channel did not close") + } + select { + case <-c.Done(): + case <-ctx.Done(): + t.Fatal("connection did not close") + } + withdrawn := testutil.RequireReceive(ctx, t, bindings) + require.Equal(t, bKey(peerID), withdrawn.bKey) + require.Equal(t, proto.CoordinateResponse_PeerUpdate_LOST, withdrawn.kind) +} diff --git a/enterprise/tailnet/mapper_internal_test.go b/enterprise/tailnet/mapper_internal_test.go new file mode 100644 index 0000000000000..f5c2c70f5649a --- /dev/null +++ b/enterprise/tailnet/mapper_internal_test.go @@ -0,0 +1,55 @@ +package tailnet + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + agpl "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestMapperPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + bindings := make(chan binding, 1) + responses := make(chan *proto.CoordinateResponse, 2) + c := newConnIO(ctx, ctx, logger, bindings, make(chan tunnel, 1), nil, + make(chan *proto.CoordinateRequest), responses, uuid.New(), t.Name(), agpl.SingleTailnetCoordinateeAuth{}) + t.Cleanup(func() { require.NoError(t, c.Close()) }) + coordinatorID := uuid.New() + m := &mapper{ + ctx: c.peerCtx, + logger: logger, + c: c, + mappings: make(chan []mapping), + heartbeats: &heartbeats{self: coordinatorID}, + // A nil sent map forces a panic after the mapping has been selected. + sent: nil, + } + go m.run() + require.NoError(t, agpl.SendCtx(ctx, m.mappings, []mapping{{ + peer: uuid.New(), coordinator: coordinatorID, + node: &proto.Node{}, kind: proto.CoordinateResponse_PeerUpdate_NODE, + }})) + response := testutil.RequireReceive(ctx, t, responses) + require.Equal(t, agpl.CloseErrInternal, response.Error) + select { + case _, ok := <-responses: + require.False(t, ok) + case <-ctx.Done(): + t.Fatal("response channel did not close") + } + select { + case <-c.Done(): + case <-ctx.Done(): + t.Fatal("connection did not close") + } + withdrawn := testutil.RequireReceive(ctx, t, bindings) + require.Equal(t, bKey(c.id), withdrawn.bKey) + require.Equal(t, proto.CoordinateResponse_PeerUpdate_LOST, withdrawn.kind) +} diff --git a/enterprise/tailnet/pgcoord.go b/enterprise/tailnet/pgcoord.go index 08325e567fcf7..4018b8aabf781 100644 --- a/enterprise/tailnet/pgcoord.go +++ b/enterprise/tailnet/pgcoord.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "math" + "runtime/debug" "slices" "strings" "sync" @@ -742,6 +743,16 @@ func newMapper(c *connIO, logger slog.Logger, h *heartbeats) *mapper { } func (m *mapper) run() { + defer func() { + if recovered := recover(); recovered != nil { + m.logger.Error(m.ctx, "panic processing peer mappings (recovered)", + slog.F("panic", recovered), + slog.F("stack", string(debug.Stack())), + ) + _ = m.c.Enqueue(&proto.CoordinateResponse{Error: agpl.CloseErrInternal}) + _ = m.c.Close() + } + }() for { var best map[uuid.UUID]mapping select { diff --git a/enterprise/tailnet/requests_test.go b/enterprise/tailnet/requests_test.go new file mode 100644 index 0000000000000..32ebeeb2ada69 --- /dev/null +++ b/enterprise/tailnet/requests_test.go @@ -0,0 +1,33 @@ +package tailnet_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/enterprise/tailnet" + "github.com/coder/coder/v2/tailnet/test" + "github.com/coder/coder/v2/testutil" +) + +func TestPGCoordinator_InvalidRequests(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + store, ps := dbtestutil.NewDB(t) + coordinator, err := tailnet.NewPGCoord(ctx, slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), ps, store) + require.NoError(t, err) + t.Cleanup(func() { _ = coordinator.Close() }) + test.InvalidCoordinateRequestTest(ctx, t, coordinator) +} + +func TestPGCoordinator_RequestPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + store, ps := dbtestutil.NewDB(t) + coordinator, err := tailnet.NewPGCoord(ctx, slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), ps, store) + require.NoError(t, err) + t.Cleanup(func() { _ = coordinator.Close() }) + test.CoordinateRequestPanicTest(ctx, t, coordinator) +} diff --git a/flake.nix b/flake.nix index 3057b32d2eb74..504e03ef68a4c 100644 --- a/flake.nix +++ b/flake.nix @@ -196,28 +196,28 @@ releases = { x86_64-linux = { platform = "linux_amd64"; - hash = "sha256-cCshNq9nKMj/A3+EPdLbzit62IeGtzgdHXKu+iUPYBw="; + hash = "sha256-OANLONAwljmaehJkoUVhqAKbK0lv0fFpRSZW5Tlo8bc="; }; aarch64-linux = { platform = "linux_arm64"; - hash = "sha256-Bue0jegmFGxtkzG6NbE9oSMy2Dkr4w0d1reJukcT//A="; + hash = "sha256-p1C1oRA48WWEayZYxAyIzXBwqmemG6tDGhS6J5KOqeo="; }; aarch64-darwin = { platform = "darwin_arm64"; - hash = "sha256-ARN2YFEABbkYu6ghVIZvvqxDkxY9gnfCq+hh37WELDw="; + hash = "sha256-heG8eKtFvcX4KnusOSnjfQcoNKWE0iWDokmNxnu2JM0="; }; x86_64-darwin = { platform = "darwin_amd64"; - hash = "sha256-NofQfANLPn3u1bByzYris0g1vLE5uuw/xPX9U02r9e0="; + hash = "sha256-eUo2w/fr9sWPIInCJgaKU50XBbo1ilZ5tRZxyNx+vHs="; }; }; target = releases.${system} or null; in if target != null then - pkgs.runCommand "terraform-1.15.5" { + pkgs.runCommand "terraform-1.16.6" { nativeBuildInputs = [ pkgs.unzip ]; src = pkgs.fetchurl { - url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_${target.platform}.zip"; + url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_${target.platform}.zip"; hash = target.hash; }; } '' diff --git a/go.mod b/go.mod index 06189f1f7c1f7..30081da81cb29 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/coder/coder/v2 -go 1.26.5 +go 1.26.9 // Required until a v3 of chroma is created to lazily initialize all XML files. // None of our dependencies seem to use the registries anyways, so this @@ -131,7 +131,7 @@ replace github.com/anthropics/anthropic-sdk-go v1.19.0 => github.com/dannykoppin // `coder/pinned` is the branch we track. // // To update, run: `go mod edit -replace github.com/openai/openai-go/v3=github.com/coder/openai-go/v3@coder/pinned; go mod tidy` -replace github.com/openai/openai-go/v3 => github.com/coder/openai-go/v3 v3.0.0-20260708125056-3633b71968f7 +replace github.com/openai/openai-go/v3 => github.com/coder/openai-go/v3 v3.0.0-20260810175933-92b5addb22d2 require ( cdr.dev/slog/v3 v3.1.0 @@ -145,7 +145,7 @@ require ( github.com/aquasecurity/trivy-iac v0.8.0 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible - github.com/aws/smithy-go v1.27.3 + github.com/aws/smithy-go v1.27.5 github.com/bramvdbogaerde/go-scp v1.6.0 github.com/briandowns/spinner v1.23.0 github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 @@ -239,7 +239,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/swaggo/http-swagger/v2 v2.0.1 github.com/swaggo/swag v1.16.6 - github.com/tidwall/gjson v1.18.0 + github.com/tidwall/gjson v1.19.0 github.com/u-root/u-root v0.14.0 github.com/unrolled/secure v1.17.0 github.com/valyala/fasthttp v1.72.0 @@ -255,16 +255,16 @@ require ( go.uber.org/goleak v1.3.1-0.20240429205332-517bace7cc29 go.uber.org/mock v0.6.0 go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.57.0 golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f - golang.org/x/mod v0.38.0 - golang.org/x/net v0.57.0 + golang.org/x/mod v0.41.0 + golang.org/x/net v0.60.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sync v0.22.0 - golang.org/x/sys v0.47.0 - golang.org/x/term v0.45.0 - golang.org/x/text v0.40.0 - golang.org/x/tools v0.48.0 + golang.org/x/sync v0.23.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 + golang.org/x/text v0.42.0 + golang.org/x/tools v0.49.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da google.golang.org/api v0.289.0 google.golang.org/grpc v1.82.1 @@ -309,19 +309,19 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aws/aws-sdk-go-v2 v1.42.1 - github.com/aws/aws-sdk-go-v2/config v1.32.25 - github.com/aws/aws-sdk-go-v2/credentials v1.19.24 - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2 v1.43.2 + github.com/aws/aws-sdk-go-v2/config v1.32.33 + github.com/aws/aws-sdk-go-v2/credentials v1.19.32 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33 // indirect github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.14 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.67.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 + github.com/aws/aws-sdk-go-v2/service/sso v1.33.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.2 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -518,7 +518,7 @@ require ( github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.77.0 // indirect github.com/DataDog/go-libddwaf/v4 v4.9.0 // indirect github.com/apparentlymart/go-textseg/v17 v17.0.1 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect @@ -553,7 +553,7 @@ require ( github.com/mark3labs/mcp-go v0.38.0 github.com/nats-io/nats-server/v2 v2.14.2 github.com/nats-io/nats.go v1.52.0 - github.com/openai/openai-go/v3 v3.28.0 + github.com/openai/openai-go/v3 v3.50.0 github.com/scim2/filter-parser/v2 v2.3.1 github.com/shopspring/decimal v1.4.0 github.com/smallstep/pkcs7 v0.2.1 @@ -574,8 +574,8 @@ require ( cloud.google.com/go/monitoring v1.29.0 // indirect cloud.google.com/go/storage v1.62.3 // indirect git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.77.0 // indirect github.com/DataDog/datadog-agent/pkg/version v0.77.0 // indirect github.com/DataDog/dd-trace-go/v2 v2.8.1 // indirect @@ -592,7 +592,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/bits-and-blooms/bitset v1.24.5 // indirect @@ -684,7 +684,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect - golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect google.golang.org/genai v1.60.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect diff --git a/go.sum b/go.sum index 9761644f7ba0c..4160f34b705ed 100644 --- a/go.sum +++ b/go.sum @@ -31,16 +31,16 @@ filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= -github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= @@ -170,46 +170,46 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= -github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= -github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2 v1.43.2 h1:cl+IXwWb3qazClUcm08tGSsB6OiuV83JVJO9B0jQcPc= +github.com/aws/aws-sdk-go-v2 v1.43.2/go.mod h1:WEzLKBh/mEjXvx1FtQMWgSxMSTVqxQzjkRtk5fa3wkg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= -github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= -github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= -github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= -github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/config v1.32.33 h1:M1m/Q6f0OKDEDGwhiNOqx1OjTdrewe3v+GDbHmKczWk= +github.com/aws/aws-sdk-go-v2/config v1.32.33/go.mod h1:fGj1iQj2QpIZzp7jE4aQQ+71TE8cd4z9K4+xCd6EqmE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.32 h1:eNE0JnIblBo1NCvd3tqEYuZz9XDefn69R74CHd3nT7U= +github.com/aws/aws-sdk-go-v2/credentials v1.19.32/go.mod h1:yYJu+6tqKUYZuJSYcpSGjz/6sV/SUaAaKIufnWKx2OU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33 h1:MobhiR6KIerWxmO74Zit5I3379+mSc2DOdZ3DeRFB9w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.33/go.mod h1:xu02847OdZfNr/jAfZpHtyRk0b3v4d0kaoxNHxZGG/w= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.14 h1:gKXU53GYsPuYgkdTdMHh6vNdcbIgoxFQLQGjg+iRG+k= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.14/go.mod h1:jyoemRAktfCyZR9bTb5gT3kn/Vj2KwYDm0Pev5TsmEQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33 h1:HAp1wLFZzch054uh3FK7rcVYg4v7J2FxVf3h3IGNZas= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.33/go.mod h1:mJk5fmqnF+WUlMdPG37pR2Fh3oh6r8F6ZGUgPKvzu0c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33 h1:0YA0aCKgsJyno6xkFfaIgjE3/wK08+Qxo9nQfe1UrWM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.33/go.mod h1:UZqj4WIdTH+ga8Y/DgpAuy/8cGjM3h7gDCliJYGg2SE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34 h1:HQYnjFnXpX8EbPW5M1QT8mXzesRPwly0HEPTcFlS02Y= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.34/go.mod h1:tGzj56niKYZBbDIRhwPGDqrULzmWv5b6uBQGqyNaFZw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 h1:SA43nfaY7+1jjMNIc2ywu99JLJLButtIdLP6j+bT870= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14/go.mod h1:Du3llKcwbQvHsTXSLzTOGQz0DTDBMEzdg7DAGu7inrY= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33 h1:mqI7OrxN/DUH85F5OqVn3cIfuZ3+HVcebUm2N8mLlgQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.33/go.mod h1:eZ5jdEpvaaOU8nWWE4cTAJETSEA5FZoWxvNRao4piHY= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE= github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.2 h1:EjI1CZzDcBxPkTa3j1BdtIrUDbqnOGssFMeyUS+6W0I= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.2/go.mod h1:vN3eb5H8MEAZ4dx0F5Wc9LT8eb3eW7bZZ5BjGJdbw9k= github.com/aws/aws-sdk-go-v2/service/ssm v1.67.4 h1:pOwUUY5FzKUsxtxGR6qsczZP7MuZMVlMbAOPQOcmJlo= github.com/aws/aws-sdk-go-v2/service/ssm v1.67.4/go.mod h1:+nlWvcgDPQ56mChEBzTC0puAMck+4onOFaHg5cE+Lgg= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= -github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU= -github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= -github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= -github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.2 h1:zMP1FDFE08L7sM5f1QqkH/ZgKKg8Uc0Dz7KhSSYqWkw= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.2/go.mod h1:0LoIZSUKjdo2BleHfT1hv/jlD33LQS00IrBlzoUsoUQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2 h1:9eTqUYl+SyVmaRPMyBXSO9wwqC6TRwZB82pKENK2hdQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.2/go.mod h1:DThweuz22kiLc7lGHop5vQ9c3bx5W6Azs/YqSHa2fu8= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.2 h1:EJd8vZO3E8SE6nmPqxuxlQ1NeSb8as50sf6eGdV4Saw= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.2/go.mod h1:OgpPvKzsO2Ranjpli/20djMkg6UrV5mw4W3pZpq1Mqo= +github.com/aws/smithy-go v1.27.5 h1:d1ro7KpYOYwP6m73YFa+Kc/A130VsAdX68SpsJwARMM= +github.com/aws/smithy-go v1.27.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= @@ -345,8 +345,8 @@ github.com/coder/guts v1.7.0 h1:TaZ/PR9wgN8dlbcckaWV1MxkkuEFZRwSRwBBEm8dYXs= github.com/coder/guts v1.7.0/go.mod h1:30SShdvpmsauNlsNjECRB5AppScjYk08rf2ZVpH3MFg= github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714 h1:j7tyq3rv0ZXkbyy/BE5K3lYiIqEsV8sDTiTjpLLxjiw= github.com/coder/gvisor v0.0.0-20260313164934-7a658db7b714/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= -github.com/coder/openai-go/v3 v3.0.0-20260708125056-3633b71968f7 h1:YEThquYyw8RkFRojQdrGIYUQ4juXvsUNFfx1ZN9II+U= -github.com/coder/openai-go/v3 v3.0.0-20260708125056-3633b71968f7/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/coder/openai-go/v3 v3.0.0-20260810175933-92b5addb22d2 h1:84CQp2XsKlhthzgSmAJXJCYqOu3eHS/CWPSDTNFdY24= +github.com/coder/openai-go/v3 v3.0.0-20260810175933-92b5addb22d2/go.mod h1:Ogjo0gDct+Jm7yCqaCjLGQGygeV8xNfNHV1/yKvCji0= github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151 h1:YAxwg3lraGNRwoQ18H7R7n+wsCqNve7Brdvj0F1rDnU= github.com/coder/pq v1.10.5-0.20250807075151-6ad9b0a25151/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= @@ -1188,8 +1188,8 @@ github.com/testcontainers/testcontainers-go/modules/localstack v0.40.0/go.mod h1 github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= @@ -1413,8 +1413,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= @@ -1425,8 +1425,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -1439,8 +1439,8 @@ golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1453,8 +1453,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1493,11 +1493,11 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= -golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= +golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1510,8 +1510,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -1525,8 +1525,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1536,8 +1536,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/helm/ai-gateway/README.md b/helm/ai-gateway/README.md index 40a5643f9c16b..a1bce8edcbe02 100644 --- a/helm/ai-gateway/README.md +++ b/helm/ai-gateway/README.md @@ -3,7 +3,7 @@ This chart deploys the Coder AI Gateway as a standalone Kubernetes Deployment. The Gateway connects to Coder using `CODER_URL` and an AI Gateway key. To forward proxied AI traffic to the standalone Gateway, configure the Coder AI Gateway -Proxy (`aibridgeproxyd`) after installing the chart. +Proxy after installing the chart. The chart does not create credentials or TLS Secrets. diff --git a/helm/ai-gateway/templates/service.yaml b/helm/ai-gateway/templates/service.yaml index e32cac88de53c..4e17d53e7398f 100644 --- a/helm/ai-gateway/templates/service.yaml +++ b/helm/ai-gateway/templates/service.yaml @@ -29,7 +29,7 @@ spec: port: {{ .Values.service.port }} targetPort: http protocol: TCP - {{- with .Values.service.nodePort }} - nodePort: {{ . }} + {{- if .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} {{- end }} {{- end }} diff --git a/helm/ai-gateway/tests/chart_test.go b/helm/ai-gateway/tests/chart_test.go index 835a22fa51ae5..363f55d5de672 100644 --- a/helm/ai-gateway/tests/chart_test.go +++ b/helm/ai-gateway/tests/chart_test.go @@ -22,21 +22,85 @@ var testCases = []testCase{ { name: "default_values", fixture: "default_values", + // The fixture enables every optional resource, so this case covers + // rendering of all templates with the minimum required values. + apiVersions: []string{"gateway.networking.k8s.io/v1/HTTPRoute"}, + mustNotContain: []string{ + // Service fields that only render when their value is set. + "nodePort:", + "loadBalancerClass:", + "loadBalancerIP:", + // externalTrafficPolicy only applies to NodePort and LoadBalancer. + "externalTrafficPolicy:", + // Ingress and HTTPRoute render with required fields only. + "ingressClassName:", + "hostnames:", + // No secret is configured, so nothing mounts a Secret or switches + // the probes and listener to TLS. + "secretName:", + "CODER_AI_GATEWAY_KEY_FILE", + "CODER_AI_GATEWAY_TLS_CERT_FILE", + "CODER_CLIENT_TLS_CA_FILE", + "CODER_CLIENT_TLS_CERT_FILE", + "scheme: HTTPS", + // Opt-in workload settings. + "startupProbe:", + "envFrom:", + "imagePullSecrets:", + "priorityClassName:", + "kind: ConfigMap", + }, }, { name: "networking", fixture: "networking", namespace: "ai-gateway-test", apiVersions: []string{"gateway.networking.k8s.io/v1/HTTPRoute"}, + mustNotContain: []string{ + // The Service is a LoadBalancer without an explicit nodePort, so + // Kubernetes allocates the port. + "nodePort:", + "loadBalancerIP:", + // Only the Gateway key Secret is configured. + "ai-gateway-listener", + "coder-client-ca", + "coder-client-tls", + "scheme: HTTPS", + "startupProbe:", + "envFrom:", + }, }, { name: "custom", fixture: "custom", namespace: "ai-gateway-test", + mustNotContain: []string{ + // coder.serviceAccount.disableCreate is true, so the chart uses the + // existing account instead of creating one. + "kind: ServiceAccount", + // Only the default ClusterIP Service renders. + "kind: Ingress", + "kind: HTTPRoute", + "nodePort:", + "loadBalancerClass:", + "loadBalancerIP:", + "externalTrafficPolicy:", + }, }, { name: "nodeport", fixture: "nodeport", + mustNotContain: []string{ + // LoadBalancer-only fields stay absent for a NodePort Service. + "loadBalancerClass:", + "loadBalancerIP:", + "kind: Ingress", + "kind: HTTPRoute", + "ai-gateway-listener", + "coder-client-ca", + "coder-client-tls", + "scheme: HTTPS", + }, }, { name: "missing_key_field", @@ -54,6 +118,20 @@ var testCases = []testCase{ { name: "listener_tls_with_ingress", fixture: "listener_tls_with_ingress", + mustNotContain: []string{ + // Listener TLS does not imply client TLS to coderd. + "coder-client-ca", + "coder-client-tls", + "CODER_CLIENT_TLS_CA_FILE", + "CODER_CLIENT_TLS_CERT_FILE", + // The Ingress renders with required fields only. + "ingressClassName:", + "kind: HTTPRoute", + "nodePort:", + "loadBalancerClass:", + "loadBalancerIP:", + "externalTrafficPolicy:", + }, }, { name: "partial_client_tls", @@ -104,6 +182,12 @@ type testCase struct { namespace string expectedError string apiVersions []string + // mustNotContain holds substrings that must be absent from the rendered + // output. Golden files are rewritten wholesale by TestUpdateGoldenFiles, + // so absence assertions live here to keep an unintended field from being + // baked into the golden file. Each entry names an optional field or + // resource that the fixture leaves unset. + mustNotContain []string } func (tc testCase) valuesFilePath() string { @@ -143,6 +227,9 @@ func TestRenderChart(t *testing.T) { return } require.NoError(t, err, output) + for _, absent := range tc.mustNotContain { + require.NotContains(t, output, absent) + } golden, err := os.ReadFile(tc.goldenFilePath()) require.NoError(t, err) golden = bytes.ReplaceAll(golden, []byte("\r"), nil) diff --git a/helm/ai-gateway/tests/testdata/default_values.golden b/helm/ai-gateway/tests/testdata/default_values.golden index 66be5cd0566ec..78c3938bfcb6d 100644 --- a/helm/ai-gateway/tests/testdata/default_values.golden +++ b/helm/ai-gateway/tests/testdata/default_values.golden @@ -137,3 +137,54 @@ spec: serviceAccountName: coder-ai-gateway terminationGracePeriodSeconds: 330 volumes: [] +--- +# Source: coder-ai-gateway/templates/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: coder-ai-gateway + namespace: default + labels: + helm.sh/chart: coder-ai-gateway-0.1.0 + app.kubernetes.io/name: coder-ai-gateway + app.kubernetes.io/instance: ai-gateway + app.kubernetes.io/part-of: coder-ai-gateway + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm +spec: + rules: + - host: "ai.example.com" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: coder-ai-gateway + port: + number: 80 +--- +# Source: coder-ai-gateway/templates/httproute.yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: coder-ai-gateway + namespace: default + labels: + helm.sh/chart: coder-ai-gateway-0.1.0 + app.kubernetes.io/name: coder-ai-gateway + app.kubernetes.io/instance: ai-gateway + app.kubernetes.io/part-of: coder-ai-gateway + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm +spec: + parentRefs: + - name: shared-gateway + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: coder-ai-gateway + port: 80 diff --git a/helm/ai-gateway/tests/testdata/default_values.yaml b/helm/ai-gateway/tests/testdata/default_values.yaml index 5eb707ca0cd7b..46be6740cdec9 100644 --- a/helm/ai-gateway/tests/testdata/default_values.yaml +++ b/helm/ai-gateway/tests/testdata/default_values.yaml @@ -11,3 +11,13 @@ coder: name: ai-gateway-key key: key aigateway: {} +# Enable every optional resource with only the values each one requires, so +# the golden file covers rendering of all templates without opting into +# non-default settings such as service.nodePort. +ingress: + enable: true + host: ai.example.com +httproute: + enable: true + parentRefs: + - name: shared-gateway diff --git a/helm/ai-gateway/values.schema.json b/helm/ai-gateway/values.schema.json index bb41786cfabdc..efb0cb25940dd 100644 --- a/helm/ai-gateway/values.schema.json +++ b/helm/ai-gateway/values.schema.json @@ -92,7 +92,7 @@ }, "service": { "type": "object", "additionalProperties": false, - "required": ["enable", "type", "port", "annotations", "sessionAffinity", "externalTrafficPolicy", "loadBalancerClass", "loadBalancerIP", "nodePort"], + "required": ["enable", "type", "port", "annotations", "sessionAffinity", "externalTrafficPolicy", "loadBalancerClass", "loadBalancerIP"], "properties": { "enable": {"type": "boolean"}, "type": {"enum": ["ClusterIP", "NodePort", "LoadBalancer"]}, diff --git a/install.sh b/install.sh index daf4f598369ee..51350857a76b8 100755 --- a/install.sh +++ b/install.sh @@ -276,7 +276,7 @@ EOF main() { MAINLINE=1 STABLE=0 - TERRAFORM_VERSION="1.15.5" + TERRAFORM_VERSION="1.16.6" if [ "${TRACE-}" ]; then set -x diff --git a/mise.lock b/mise.lock index b96d6b9ae62aa..d98f7d51d3ef2 100644 --- a/mise.lock +++ b/mise.lock @@ -481,52 +481,52 @@ checksum = "sha256:e1245a0a760a45b236e7a25bf118c1defc8447734bdeb4260ea3ec15d1797 url = "https://github.com/digitalocean/doctl/releases/download/v1.158.0/doctl-1.158.0-windows-amd64.zip" [[tools.go]] -version = "1.26.5" +version = "1.26.9" backend = "core:go" [tools.go."platforms.linux-arm64"] -checksum = "sha256:fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49" -url = "https://dl.google.com/go/go1.26.5.linux-arm64.tar.gz" +checksum = "sha256:4a97373d49fcacdcf3694fea368a500b00ee3e963974f3e7514132717632f052" +url = "https://dl.google.com/go/go1.26.9.linux-arm64.tar.gz" [tools.go."platforms.linux-arm64-musl"] -checksum = "sha256:fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49" -url = "https://dl.google.com/go/go1.26.5.linux-arm64.tar.gz" +checksum = "sha256:4a97373d49fcacdcf3694fea368a500b00ee3e963974f3e7514132717632f052" +url = "https://dl.google.com/go/go1.26.9.linux-arm64.tar.gz" [tools.go."platforms.linux-x64"] -checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" -url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" +checksum = "sha256:42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" +url = "https://dl.google.com/go/go1.26.9.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-baseline"] -checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" -url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" +checksum = "sha256:42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" +url = "https://dl.google.com/go/go1.26.9.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl"] -checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" -url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" +checksum = "sha256:42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" +url = "https://dl.google.com/go/go1.26.9.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl-baseline"] -checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053" -url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz" +checksum = "sha256:42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d" +url = "https://dl.google.com/go/go1.26.9.linux-amd64.tar.gz" [tools.go."platforms.macos-arm64"] -checksum = "sha256:efb87ff28af9a188d0536ef5d42e63dd52ba8263cd7344a993cc48dd11dedb6a" -url = "https://dl.google.com/go/go1.26.5.darwin-arm64.tar.gz" +checksum = "sha256:f9bb7c0a02506c5d9bf0d1eb1f7ee6c7684f844ae49558308a0427b830e022cc" +url = "https://dl.google.com/go/go1.26.9.darwin-arm64.tar.gz" [tools.go."platforms.macos-x64"] -checksum = "sha256:6231d8d3b8f5552ec6cbf6d685bdd5482e1e703214b120e89b3bf0d7bf1ef725" -url = "https://dl.google.com/go/go1.26.5.darwin-amd64.tar.gz" +checksum = "sha256:00c29e3d4c8562f547410daa18d2959b641bd691ab00c4a525ea817a0253d4bd" +url = "https://dl.google.com/go/go1.26.9.darwin-amd64.tar.gz" [tools.go."platforms.macos-x64-baseline"] -checksum = "sha256:6231d8d3b8f5552ec6cbf6d685bdd5482e1e703214b120e89b3bf0d7bf1ef725" -url = "https://dl.google.com/go/go1.26.5.darwin-amd64.tar.gz" +checksum = "sha256:00c29e3d4c8562f547410daa18d2959b641bd691ab00c4a525ea817a0253d4bd" +url = "https://dl.google.com/go/go1.26.9.darwin-amd64.tar.gz" [tools.go."platforms.windows-x64"] -checksum = "sha256:97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38" -url = "https://dl.google.com/go/go1.26.5.windows-amd64.zip" +checksum = "sha256:d722201a9c0c086d1610e111c48203009af690892ed708072bd5ae20160e7a59" +url = "https://dl.google.com/go/go1.26.9.windows-amd64.zip" [tools.go."platforms.windows-x64-baseline"] -checksum = "sha256:97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38" -url = "https://dl.google.com/go/go1.26.5.windows-amd64.zip" +checksum = "sha256:d722201a9c0c086d1610e111c48203009af690892ed708072bd5ae20160e7a59" +url = "https://dl.google.com/go/go1.26.9.windows-amd64.zip" [[tools."go:github.com/coder/paralleltestctx/cmd/paralleltestctx"]] version = "0.0.2" @@ -990,52 +990,52 @@ checksum = "sha256:7af7acb9f81bdddbc343855cb3a42e1d38ae9a1b044bfcd9b975a118d1078 url = "https://github.com/anchore/syft/releases/download/v1.26.1/syft_1.26.1_windows_amd64.zip" [[tools.terraform]] -version = "1.15.5" +version = "1.16.6" backend = "aqua:hashicorp/terraform" [tools.terraform."platforms.linux-arm64"] -checksum = "sha256:06e7b48de826146c6d9331ba35b13da12332d8392be30d1dd6b789ba4713fff0" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_arm64.zip" +checksum = "sha256:a750b5a11038f165846b2658c40c88cd7070aa67a61bab431a14ba27928ea9ea" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_arm64.zip" [tools.terraform."platforms.linux-arm64-musl"] -checksum = "sha256:06e7b48de826146c6d9331ba35b13da12332d8392be30d1dd6b789ba4713fff0" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_arm64.zip" +checksum = "sha256:a750b5a11038f165846b2658c40c88cd7070aa67a61bab431a14ba27928ea9ea" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_arm64.zip" [tools.terraform."platforms.linux-x64"] -checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" +checksum = "sha256:38034b38d03096399a7a1264a14561a8029b2b496fd1f169452656e53968f1b7" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip" [tools.terraform."platforms.linux-x64-baseline"] -checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" +checksum = "sha256:38034b38d03096399a7a1264a14561a8029b2b496fd1f169452656e53968f1b7" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip" [tools.terraform."platforms.linux-x64-musl"] -checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" +checksum = "sha256:38034b38d03096399a7a1264a14561a8029b2b496fd1f169452656e53968f1b7" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip" [tools.terraform."platforms.linux-x64-musl-baseline"] -checksum = "sha256:702b2136af6728c8ff037f843dd2dbce2b7ad88786b7381d1d72aefa250f601c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_amd64.zip" +checksum = "sha256:38034b38d03096399a7a1264a14561a8029b2b496fd1f169452656e53968f1b7" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_amd64.zip" [tools.terraform."platforms.macos-arm64"] -checksum = "sha256:01137660510005b918bba82154866fbeac4393163d8277c2abe861dfb5842c3c" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_arm64.zip" +checksum = "sha256:85e1bc78ab45bdc5f82a7bac3929e37d072834a584d22583a2498dc67bb624cd" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_darwin_arm64.zip" [tools.terraform."platforms.macos-x64"] -checksum = "sha256:3687d07c034b3e7deed5b072cd8ae2b34835bcb139baec3fc4f5fd534dabf5ed" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_amd64.zip" +checksum = "sha256:794a36c3f7ebf6c58f2089c226068a539d1705ba358a5679b51671c8dc7ebc7b" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_darwin_amd64.zip" [tools.terraform."platforms.macos-x64-baseline"] -checksum = "sha256:3687d07c034b3e7deed5b072cd8ae2b34835bcb139baec3fc4f5fd534dabf5ed" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_darwin_amd64.zip" +checksum = "sha256:794a36c3f7ebf6c58f2089c226068a539d1705ba358a5679b51671c8dc7ebc7b" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_darwin_amd64.zip" [tools.terraform."platforms.windows-x64"] -checksum = "sha256:2f652dd854af7b7fbb51301afc55b5ef1d3f6e287be7889d4cc3818df891cd38" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_amd64.zip" +checksum = "sha256:6ea2c186e0e76a113af30fbcdcf7a4b4c70756317994a04929e5c90b9e94b1df" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_windows_amd64.zip" [tools.terraform."platforms.windows-x64-baseline"] -checksum = "sha256:2f652dd854af7b7fbb51301afc55b5ef1d3f6e287be7889d4cc3818df891cd38" -url = "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_windows_amd64.zip" +checksum = "sha256:6ea2c186e0e76a113af30fbcdcf7a4b4c70756317994a04929e5c90b9e94b1df" +url = "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_windows_amd64.zip" [[tools.vercel]] version = "54.14.0" diff --git a/mise.toml b/mise.toml index db02df3a16849..6005fe2f040c4 100644 --- a/mise.toml +++ b/mise.toml @@ -9,7 +9,7 @@ lockfile = true [tools] # Languages and runtimes. bun = "1.2.15" -go = "1.26.5" +go = "1.26.9" node = "22.19.0" pnpm = "10.33.2" @@ -50,7 +50,7 @@ crane = "0.21.6" helm = "3.21.0" kubectx = "0.9.4" syft = "1.26.1" -terraform = "1.15.5" +terraform = "1.16.6" zizmor = "1.11.0" # Developer-environment niceties for the dogfood image. Non-dogfood diff --git a/provisioner/terraform/install.go b/provisioner/terraform/install.go index 2137e99cb9280..f4b229ce56ee4 100644 --- a/provisioner/terraform/install.go +++ b/provisioner/terraform/install.go @@ -22,10 +22,10 @@ var ( // when Terraform is not available on the system. // NOTE: Keep this in sync with the version in scripts/Dockerfile.base. // NOTE: Keep this in sync with the version in install.sh. - TerraformVersion = version.Must(version.NewVersion("1.14.5")) + TerraformVersion = version.Must(version.NewVersion("1.16.6")) minTerraformVersion = version.Must(version.NewVersion("1.1.0")) - maxTerraformVersion = version.Must(version.NewVersion("1.14.9")) // use .9 to automatically allow patch releases + maxTerraformVersion = version.Must(version.NewVersion("1.16.9")) // use .9 to automatically allow patch releases errTerraformMinorVersionMismatch = xerrors.New("Terraform binary minor version mismatch.") ) diff --git a/provisioner/terraform/testdata/version.txt b/provisioner/terraform/testdata/version.txt index d32434904bcb3..de646d2fc11c7 100644 --- a/provisioner/terraform/testdata/version.txt +++ b/provisioner/terraform/testdata/version.txt @@ -1 +1 @@ -1.15.5 +1.16.6 diff --git a/provisionersdk/serve.go b/provisionersdk/serve.go index 4afcee962693c..013626b22a4a9 100644 --- a/provisionersdk/serve.go +++ b/provisionersdk/serve.go @@ -92,7 +92,7 @@ func Serve(ctx context.Context, server Server, options *ServeOptions) error { if err != nil { return xerrors.Errorf("register provisioner: %w", err) } - srv := drpcserver.NewWithOptions(&tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ + srv := drpcsdk.NewServer(options.Logger, &tracing.DRPCHandler{Handler: mux}, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), }) diff --git a/scripts/Dockerfile.base b/scripts/Dockerfile.base index 7ed3b76276c26..93146ce7226ac 100644 --- a/scripts/Dockerfile.base +++ b/scripts/Dockerfile.base @@ -27,7 +27,7 @@ RUN apk add --no-cache \ # Terraform was disabled in the edge repo due to a build issue. # https://gitlab.alpinelinux.org/alpine/aports/-/commit/f3e263d94cfac02d594bef83790c280e045eba35 # Using wget for now. Note that busybox unzip doesn't support streaming. -RUN ARCH="$(arch)"; if [ "${ARCH}" == "x86_64" ]; then ARCH="amd64"; elif [ "${ARCH}" == "aarch64" ]; then ARCH="arm64"; elif [ "${ARCH}" == "armv7l" ]; then ARCH="arm"; fi; wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.15.5/terraform_1.15.5_linux_${ARCH}.zip" && \ +RUN ARCH="$(arch)"; if [ "${ARCH}" == "x86_64" ]; then ARCH="amd64"; elif [ "${ARCH}" == "aarch64" ]; then ARCH="arm64"; elif [ "${ARCH}" == "armv7l" ]; then ARCH="arm"; fi; wget -O /tmp/terraform.zip "https://releases.hashicorp.com/terraform/1.16.6/terraform_1.16.6_linux_${ARCH}.zip" && \ busybox unzip /tmp/terraform.zip -d /usr/local/bin && \ rm -f /tmp/terraform.zip && \ chmod +x /usr/local/bin/terraform && \ diff --git a/scripts/aibridgepricesgen/curation.json b/scripts/aibridgepricesgen/curation.json index b528c8d0d3326..be22f21e86a3a 100644 --- a/scripts/aibridgepricesgen/curation.json +++ b/scripts/aibridgepricesgen/curation.json @@ -1,4 +1,115 @@ { + "anthropic": [ + { + "modelIdentifier": "claude-fable-5", + "reasoningEffort": "high" + }, + { + "modelIdentifier": "claude-mythos-5", + "reasoningEffort": "high" + }, + { + "modelIdentifier": "claude-opus-4-8", + "reasoningEffort": "high" + }, + { + "modelIdentifier": "claude-opus-4-7", + "reasoningEffort": "high" + }, + { + "modelIdentifier": "claude-opus-4-6", + "reasoningEffort": "high" + }, + { + "modelIdentifier": "claude-sonnet-5", + "reasoningEffort": "high" + }, + { + "modelIdentifier": "claude-sonnet-4-6", + "reasoningEffort": "medium" + }, + { + "modelIdentifier": "claude-haiku-4-5", + "aliases": ["claude-haiku-4-5-20251001"], + "displayName": "Claude Haiku 4.5", + "thinkingBudgetTokens": 8192 + }, + { + "modelIdentifier": "claude-sonnet-4-5", + "aliases": ["claude-sonnet-4-5-20250929"], + "displayName": "Claude Sonnet 4.5", + "thinkingBudgetTokens": 8192 + } + ], + "azure": [ + { + "modelIdentifier": "claude-fable-5" + }, + { + "modelIdentifier": "claude-opus-5" + }, + { + "modelIdentifier": "claude-sonnet-5" + }, + { + "modelIdentifier": "claude-opus-4-8" + }, + { + "modelIdentifier": "claude-sonnet-4-6" + }, + { + "modelIdentifier": "claude-haiku-4-5" + }, + { + "modelIdentifier": "gpt-5.6-sol" + }, + { + "modelIdentifier": "gpt-5.6-terra" + }, + { + "modelIdentifier": "gpt-5.6-luna" + }, + { + "modelIdentifier": "gpt-5.5" + }, + { + "modelIdentifier": "deepseek-v4-flash" + } + ], + "bedrock": [ + { + "modelIdentifier": "anthropic.claude-haiku-4-5-20251001-v1:0", + "aliases": ["global.anthropic.claude-haiku-4-5-20251001-v1:0"] + }, + { + "modelIdentifier": "anthropic.claude-sonnet-5", + "aliases": ["global.anthropic.claude-sonnet-5"] + }, + { + "modelIdentifier": "anthropic.claude-opus-5", + "aliases": ["global.anthropic.claude-opus-5"] + }, + { + "modelIdentifier": "anthropic.claude-fable-5", + "aliases": ["global.anthropic.claude-fable-5"] + }, + { + "modelIdentifier": "anthropic.claude-sonnet-4-6", + "aliases": ["global.anthropic.claude-sonnet-4-6"] + }, + { + "modelIdentifier": "anthropic.claude-opus-4-8", + "aliases": ["global.anthropic.claude-opus-4-8"] + } + ], + "google": [ + { + "modelIdentifier": "gemini-flash-latest" + }, + { + "modelIdentifier": "gemini-3.6-flash" + } + ], "openai": [ { "modelIdentifier": "gpt-5.6-sol", @@ -36,46 +147,98 @@ "reasoningEffort": "medium" } ], - "anthropic": [ + "openrouter": [ { - "modelIdentifier": "claude-fable-5", - "reasoningEffort": "high" + "modelIdentifier": "openai/gpt-5.6-sol" }, { - "modelIdentifier": "claude-mythos-5", - "reasoningEffort": "high" + "modelIdentifier": "openai/gpt-5.6-terra" }, { - "modelIdentifier": "claude-opus-4-8", - "reasoningEffort": "high" + "modelIdentifier": "openai/gpt-5.6-luna" }, { - "modelIdentifier": "claude-opus-4-7", - "reasoningEffort": "high" + "modelIdentifier": "openai/gpt-oss-120b" }, { - "modelIdentifier": "claude-opus-4-6", - "reasoningEffort": "high" + "modelIdentifier": "moonshotai/kimi-k3" }, { - "modelIdentifier": "claude-sonnet-5", - "reasoningEffort": "high" + "modelIdentifier": "z-ai/glm-5.2" }, { - "modelIdentifier": "claude-sonnet-4-6", - "reasoningEffort": "medium" + "modelIdentifier": "anthropic/claude-fable-5" }, { - "modelIdentifier": "claude-haiku-4-5", - "aliases": ["claude-haiku-4-5-20251001"], - "displayName": "Claude Haiku 4.5", - "thinkingBudgetTokens": 8192 + "modelIdentifier": "anthropic/claude-opus-5" }, { - "modelIdentifier": "claude-sonnet-4-5", - "aliases": ["claude-sonnet-4-5-20250929"], - "displayName": "Claude Sonnet 4.5", - "thinkingBudgetTokens": 8192 + "modelIdentifier": "anthropic/claude-sonnet-5" + }, + { + "modelIdentifier": "anthropic/claude-opus-4.8" + }, + { + "modelIdentifier": "anthropic/claude-sonnet-4.6" + }, + { + "modelIdentifier": "anthropic/claude-haiku-4.5" + }, + { + "modelIdentifier": "deepseek/deepseek-v4-flash" + }, + { + "modelIdentifier": "deepseek/deepseek-v4-pro" + }, + { + "modelIdentifier": "qwen/qwen3.8-max" + }, + { + "modelIdentifier": "qwen/qwen3-coder-next" + } + ], + "vercel": [ + { + "modelIdentifier": "openai/gpt-5.6-sol" + }, + { + "modelIdentifier": "openai/gpt-5.6-terra" + }, + { + "modelIdentifier": "openai/gpt-5.6-luna" + }, + { + "modelIdentifier": "openai/gpt-oss-120b" + }, + { + "modelIdentifier": "moonshotai/kimi-k3" + }, + { + "modelIdentifier": "zai/glm-5.2" + }, + { + "modelIdentifier": "anthropic/claude-fable-5" + }, + { + "modelIdentifier": "anthropic/claude-opus-5" + }, + { + "modelIdentifier": "anthropic/claude-sonnet-5" + }, + { + "modelIdentifier": "anthropic/claude-opus-4.8" + }, + { + "modelIdentifier": "anthropic/claude-sonnet-4.6" + }, + { + "modelIdentifier": "anthropic/claude-haiku-4.5" + }, + { + "modelIdentifier": "deepseek/deepseek-v4-flash" + }, + { + "modelIdentifier": "deepseek/deepseek-v4-pro" } ] } diff --git a/scripts/aibridgepricesgen/main.go b/scripts/aibridgepricesgen/main.go index 19778c357f3e9..51ad5bb8bda91 100644 --- a/scripts/aibridgepricesgen/main.go +++ b/scripts/aibridgepricesgen/main.go @@ -27,7 +27,16 @@ import ( // supportedProviders lists the providers we ship prices for. Adding a // provider here is enough to include it on the next regeneration. -var supportedProviders = []string{"anthropic", "openai"} +var supportedProviders = []string{ + "anthropic", + "azure", + "bedrock", + "copilot", + "google", + "openai", + "openrouter", + "vercel", +} // upstreamProvider is the subset of a models.dev per-provider entry we read. type upstreamProvider struct { diff --git a/scripts/aibridgepricesgen/overrides.jq b/scripts/aibridgepricesgen/overrides.jq index cc1b424a1f80d..12c3985654e41 100644 --- a/scripts/aibridgepricesgen/overrides.jq +++ b/scripts/aibridgepricesgen/overrides.jq @@ -30,3 +30,18 @@ end | .name = "Claude Mythos 5" ) end + +# Mapping of provider names on models.dev to our own names +# Ref. table definition for ai_provider_type +# amazon-bedrock -> bedrock +# github-copilot -> copilot +| if (has("amazon-bedrock") | not) then + error("overrides.jq: amazon-bedrock not present upstream; drop or update the rename") +else + .bedrock = ."amazon-bedrock" | del(."amazon-bedrock") +end +| if (has("github-copilot") | not) then + error("overrides.jq: github-copilot not present upstream; drop or update the rename") +else + .copilot = ."github-copilot" | del(."github-copilot") +end diff --git a/scripts/check_emdash.sh b/scripts/check_emdash.sh index 4433a6d6b9dfe..bf8d5fb8d3a10 100755 --- a/scripts/check_emdash.sh +++ b/scripts/check_emdash.sh @@ -26,6 +26,11 @@ exclude_pathspecs=( # Generated CLI golden files embed serpent's emdash-bordered footer. ":(exclude)cli/testdata/*.golden" ":(exclude)enterprise/cli/testdata/*.golden" + # Generated notification golden files embed every stored notification + # template, and one carries an emdash from before this check existed + # (migration 000324). It lives in an applied migration, so it cannot be + # edited in place. + ":(exclude)coderd/notifications/testdata/rendered-templates/**/*.golden" ) scan_all_files() { diff --git a/scripts/configdocgen/main.go b/scripts/configdocgen/main.go new file mode 100644 index 0000000000000..3bfb6efdc64b4 --- /dev/null +++ b/scripts/configdocgen/main.go @@ -0,0 +1,509 @@ +// Command configdocgen generates the Coder server configuration reference at +// docs/admin/setup/configuration-reference.md from codersdk.DeploymentValues. +// It lists every visible deployment option grouped by serpent group. Each +// option is rendered as a heading with its description followed by the +// environment variable, CLI flag, YAML key, and default that apply to it. +// Because the source is DeploymentValues, the page stays in sync as options +// change. +package main + +import ( + "cmp" + "flag" + "fmt" + "os" + "slices" + "strings" + "unicode" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/scripts/atomicwrite" + "github.com/coder/flog" + "github.com/coder/serpent" +) + +const header = `<!-- DO NOT EDIT | GENERATED CONTENT --> +# Configuration reference + +Coder server is configured primarily through environment variables. This page +lists every option so you can search by environment variable name, CLI flag, or +YAML key. For first-time setup guidance and worked examples, see +[Configure Control Plane Access](./index.md). + +Each option can be set through one or more of the methods below. An option lists +only the methods that apply to it. + +- An environment variable (recommended for production deployments running as a + system service, container, or Helm chart). +- A CLI flag passed to ` + "`coder server`" + ` (useful for one-off invocations + and local development). +- A key in a YAML configuration file passed with ` + "`--config`" + `. + +For a full description of each option's accepted values and behavior, follow the +flag link into the [` + "`coder server`" + ` CLI reference](../../reference/cli/server.md). + +Deprecated options are listed at the end of each section. + +` + +// generalSection holds options that do not belong to a serpent group. +const generalSection = "General" + +// option is the normalized data needed to render one deployment option. +type option struct { + title string // short, sentence-case heading text + env string + flagName string + flagAnchor string + yaml string + defValue string + desc string + deprecated bool + sortKey string // original serpent name, for stable ordering +} + +// node is one section of the reference: a serpent group (or the synthetic +// "General" group) with its direct options and any child sections. +type node struct { + name string // raw group name (leaf); sentence-cased at render time + intro string // group description, if any + options []option + children []*node + childIdx map[string]*node +} + +func newNode(name string) *node { + return &node{name: name, childIdx: map[string]*node{}} +} + +// child returns the named child section, creating it on first use. +func (n *node) child(name string) *node { + if c, ok := n.childIdx[name]; ok { + return c + } + c := newNode(name) + n.childIdx[name] = c + n.children = append(n.children, c) + return c +} + +// prepareEnv mirrors scripts/clidocgen so the generated defaults do not +// depend on the generating host. Without it, defaults derived from +// os.UserCacheDir and the config dir embed the local home directory. +func prepareEnv() { + for _, env := range os.Environ() { + if strings.HasPrefix(env, "CODER_") { + name, _, _ := strings.Cut(env, "=") + if err := os.Unsetenv(name); err != nil { + panic(err) + } + } + } + + err := os.Setenv("CLIDOCGEN_CACHE_DIRECTORY", "~/.cache") + if err != nil { + panic(err) + } + err = os.Setenv("CLIDOCGEN_CONFIG_DIRECTORY", "~/.config/coderv2") + if err != nil { + panic(err) + } + err = os.Setenv("TMPDIR", "/tmp") + if err != nil { + panic(err) + } +} + +func main() { + prepareEnv() + + out := flag.String("out", "docs/admin/setup/configuration-reference.md", "path to write the generated reference page") + flag.Parse() + + var vals codersdk.DeploymentValues + opts := vals.Options() + + root := buildTree(opts) + body := render(root) + + content := header + body + content = strings.TrimRight(content, "\n") + "\n" + if err := atomicwrite.File(*out, []byte(content)); err != nil { + flog.Fatalf("write %s: %v", *out, err) + } + flog.Successf("wrote %s", *out) +} + +// buildTree groups options into a section tree, skipping hidden options and +// options that have no environment variable, flag, or YAML key (those cannot +// be set by an operator). +func buildTree(opts serpent.OptionSet) *node { + root := newNode("") + for _, opt := range opts { + if opt.Hidden { + continue + } + if opt.Env == "" && opt.Flag == "" && opt.YAML == "" { + continue + } + sec := sectionFor(root, opt.Group) + sec.options = append(sec.options, toOption(opt)) + } + sortTree(root) + return root +} + +// sectionFor returns the section node for an option's group, creating the +// chain of ancestor sections as needed. Options with no group (or an unnamed +// group) live in the General section. +func sectionFor(root *node, g *serpent.Group) *node { + if g == nil { + return root.child(generalSection) + } + cur := root + for _, ancestor := range g.Ancestry() { + if ancestor.Name == "" { + return root.child(generalSection) + } + cur = cur.child(ancestor.Name) + if cur.intro == "" { + cur.intro = collapse(ancestor.Description) + } + } + return cur +} + +func toOption(opt serpent.Option) option { + var flagName, flagAnchor string + if opt.Flag != "" { + flagName = "--" + opt.Flag + // clidocgen renders a flag heading as "### -s, --flag" when it has a + // shorthand and "### --flag" otherwise, so the anchor must include the + // shorthand to match. + flagAnchor = "--" + opt.Flag + if opt.FlagShorthand != "" { + flagAnchor = "-" + opt.FlagShorthand + "---" + opt.Flag + } + } + + def := opt.Default + if def == "" && opt.DefaultFn != nil { + // DefaultFn results depend on the host environment, so evaluating them + // here would leak host-specific values. Send the reader to the CLI + // reference for the resolved default instead. + def = "(computed at runtime)" + } + + return option{ + title: shortTitle(opt), + env: opt.Env, + flagName: flagName, + flagAnchor: flagAnchor, + yaml: opt.YAMLPath(), + defValue: def, + desc: collapse(opt.Description), + deprecated: isDeprecated(opt), + sortKey: opt.Name, + } +} + +// isDeprecated reports whether an option is deprecated. serpent tracks +// replacements in UseInstead, and codersdk also marks some options by leading +// the description with "Deprecated". +func isDeprecated(opt serpent.Option) bool { + if len(opt.UseInstead) > 0 { + return true + } + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(opt.Description)), "deprecated") +} + +// sortTree orders sections and their options. General sorts first and +// Dangerous last among top-level sections; every other section is +// alphabetical. Within a section, active options come before deprecated ones, +// each alphabetical by their original name. +func sortTree(n *node) { + slices.SortStableFunc(n.children, func(a, b *node) int { + if c := cmp.Compare(sectionRank(a.name), sectionRank(b.name)); c != 0 { + return c + } + return strings.Compare(a.name, b.name) + }) + for _, c := range n.children { + slices.SortStableFunc(c.options, func(a, b option) int { + if a.deprecated != b.deprecated { + if a.deprecated { + return 1 + } + return -1 + } + return strings.Compare(a.sortKey, b.sortKey) + }) + sortTree(c) + } +} + +// sectionRank orders top-level sections: General first, Dangerous last +// (regardless of its emoji prefix), everything else alphabetical. +func sectionRank(name string) int { + switch { + case name == generalSection: + return -1 + case strings.HasSuffix(name, "Dangerous"): + return 1 + default: + return 0 + } +} + +func render(root *node) string { + var b strings.Builder + for _, sec := range root.children { + renderNode(&b, sec, 2) + } + return b.String() +} + +func renderNode(b *strings.Builder, n *node, level int) { + _, _ = fmt.Fprintf(b, "%s %s\n\n", strings.Repeat("#", level), sentenceCase(n.name)) + if n.intro != "" { + _, _ = b.WriteString(n.intro) + _, _ = b.WriteString("\n\n") + } + for _, opt := range n.options { + renderOption(b, opt, level+1) + } + for _, c := range n.children { + renderNode(b, c, level+1) + } +} + +func renderOption(b *strings.Builder, opt option, level int) { + _, _ = fmt.Fprintf(b, "%s %s\n\n", strings.Repeat("#", level), opt.title) + + desc := opt.desc + if opt.deprecated { + desc = emphasizeDeprecation(desc) + } + if desc != "" { + _, _ = b.WriteString(desc) + _, _ = b.WriteString("\n\n") + } + + if opt.env != "" { + _, _ = fmt.Fprintf(b, "- Environment variable: `%s`\n", opt.env) + } + if opt.flagName != "" { + _, _ = fmt.Fprintf(b, "- CLI flag: [`%s`](../../reference/cli/server.md#%s)\n", opt.flagName, opt.flagAnchor) + } + if opt.yaml != "" { + _, _ = fmt.Fprintf(b, "- YAML key: `%s`\n", opt.yaml) + } + if opt.defValue != "" { + _, _ = fmt.Fprintf(b, "- Default value: `%s`\n", opt.defValue) + } + _, _ = b.WriteString("\n") +} + +// emphasizeDeprecation bolds the leading "Deprecated" marker in a description +// so a deprecated option reads clearly. Trailing text is left unbolded so the +// paragraph is not a lone emphasis span (markdownlint MD036). +func emphasizeDeprecation(desc string) string { + const marker = "Deprecated" + if len(desc) >= len(marker) && strings.EqualFold(desc[:len(marker)], marker) { + if strings.TrimSpace(desc[len(marker):]) != "" { + return "**" + desc[:len(marker)] + "**" + desc[len(marker):] + } + return desc + } + if desc == "" { + return "Deprecated." + } + return "**Deprecated.** " + desc +} + +// shortTitle strips the redundant group prefix from an option name and returns +// it in sentence case, e.g. "AI Gateway Send Actor Headers" becomes +// "Send actor headers". +func shortTitle(opt serpent.Option) string { + name := opt.Name + if opt.Group != nil { + name = stripGroupPrefix(name, opt.Group) + } + return sentenceCase(name) +} + +// stripGroupPrefix removes the group name that many option names repeat. For +// space-prefixed names like "AI Gateway Send Actor Headers" it drops the +// longest matching ancestor chain ("AI Gateway"). For colon-prefixed names +// like "Notifications: Email TLS: StartTLS" it drops every segment up to the +// last ": " once the leading segment belongs to the top-level group. Names +// that do not repeat the group are returned unchanged. +func stripGroupPrefix(name string, g *serpent.Group) string { + ancestry := g.Ancestry() + if len(ancestry) == 0 { + return name + } + names := make([]string, len(ancestry)) + for i, a := range ancestry { + names[i] = a.Name + } + + if before, _, ok := strings.Cut(name, ": "); ok { + // Only treat the colon as a group separator when the leading segment + // belongs to the top-level group. This avoids mangling meaningful + // colons such as "Health Check Threshold: Database". + if top := normalize(names[0]); top != "" && strings.HasPrefix(normalize(before), top) { + if idx := strings.LastIndex(name, ": "); idx >= 0 { + if rest := strings.TrimSpace(name[idx+len(": "):]); rest != "" { + return rest + } + } + } + } + + // Try the longest ancestor suffix chain first (start == 0 is the full + // path) so the most specific prefix wins. + for start := range names { + prefix := strings.Join(names[start:], " ") + " " + if rest, ok := cutFold(name, prefix); ok { + if rest = strings.TrimSpace(rest); rest != "" { + return rest + } + } + } + return name +} + +// properNouns lists words that keep their capitalization in sentence case. +// They have ordinary title-case shape, so keepWord's acronym check would not +// otherwise catch them. +var properNouns = map[string]bool{ + "anthropic": true, + "bedrock": true, + "claude": true, + "coder": true, + "google": true, + "helm": true, + "honeycomb": true, + "maven": true, + "postgres": true, + "prometheus": true, + "stackdriver": true, + "tailscale": true, + "terraform": true, + "wireguard": true, +} + +// featureNames are multi-word names whose exact casing is restored after +// sentence-casing. A name that prefixes another comes after the longer one. +var featureNames = []string{ + "AI Gateway Proxy", + "AI Gateway", + "OpenID Connect", + "Template Builder", +} + +// sentenceCase lowercases a heading's words after the first, preserving the +// first word, acronyms and mixed-case tokens, proper nouns, and feature names. +func sentenceCase(s string) string { + words := strings.Fields(s) + seenFirst := false + for i, w := range words { + if !seenFirst { + // Keep any leading symbols (e.g. an emoji) and the first real word. + if hasLetter(w) { + seenFirst = true + } + continue + } + if !keepWord(w) { + words[i] = strings.ToLower(w) + } + } + return restoreFeatureNames(strings.Join(words, " ")) +} + +// restoreFeatureNames rewrites any case-insensitive occurrence of a feature +// name with its canonical casing. +func restoreFeatureNames(s string) string { + for _, name := range featureNames { + s = replaceFold(s, name) + } + return s +} + +// replaceFold replaces case-insensitive occurrences of canonical in s with +// canonical's exact casing. It assumes canonical is ASCII, which holds for the +// feature names above. +func replaceFold(s, canonical string) string { + lower := strings.ToLower(canonical) + var b strings.Builder + for { + idx := strings.Index(strings.ToLower(s), lower) + if idx < 0 { + _, _ = b.WriteString(s) + return b.String() + } + _, _ = b.WriteString(s[:idx]) + _, _ = b.WriteString(canonical) + s = s[idx+len(canonical):] + } +} + +// keepWord reports whether a word must keep its capitalization: proper nouns, +// all-caps or mixed-case acronyms (URL, GitHub), and tokens with digits +// (OAuth2). +func keepWord(w string) bool { + core := strings.Trim(w, "()[]{}:;,.\"'") + if core == "" { + return true + } + if properNouns[strings.ToLower(core)] { + return true + } + for i, r := range core { + if i == 0 { + continue + } + if unicode.IsUpper(r) || unicode.IsDigit(r) { + return true + } + } + return false +} + +func hasLetter(s string) bool { + for _, r := range s { + if unicode.IsLetter(r) { + return true + } + } + return false +} + +// collapse trims a string and collapses internal runs of whitespace to a +// single space so multi-line source text renders as one paragraph. +func collapse(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// normalize lowercases a string and drops everything but letters and digits, +// so prefixes can be compared regardless of spacing, case, or punctuation. +func normalize(s string) string { + var b strings.Builder + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + _, _ = b.WriteRune(unicode.ToLower(r)) + } + } + return b.String() +} + +// cutFold trims prefix from s using a case-insensitive comparison, reporting +// whether it was present. +func cutFold(s, prefix string) (string, bool) { + if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) { + return s[len(prefix):], true + } + return s, false +} diff --git a/scripts/configdocgen/main_test.go b/scripts/configdocgen/main_test.go new file mode 100644 index 0000000000000..b2a475b49b748 --- /dev/null +++ b/scripts/configdocgen/main_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "strings" + "testing" + + "github.com/coder/serpent" +) + +func TestSentenceCase(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want string + }{ + {"lowercases trailing words", "Send Actor Headers", "Send actor headers"}, + {"keeps trailing acronym", "Anthropic Base URL", "Anthropic base URL"}, + {"keeps all-caps token", "Allow BYOK", "Allow BYOK"}, + {"lowercases ordinary word", "Email Authentication", "Email authentication"}, + {"keeps proper noun", "Trace Honeycomb API Key", "Trace Honeycomb API key"}, + {"restores OpenID Connect", "OpenID Connect sign in text", "OpenID Connect sign in text"}, + {"keeps leading mixed-case token", "SSH Keygen Algorithm", "SSH keygen algorithm"}, + {"single lowercase word", "pprof", "pprof"}, + {"feature name", "AI Gateway", "AI Gateway"}, + {"longer feature name wins", "AI Gateway Proxy", "AI Gateway Proxy"}, + {"feature name as whole title", "Template Builder", "Template Builder"}, + {"feature name after leading word", "Disable Template Builder", "Disable Template Builder"}, + {"leading symbol is not the first word", "⚠️ Dangerous", "⚠️ Dangerous"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := sentenceCase(tc.in); got != tc.want { + t.Errorf("sentenceCase(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestStripGroupPrefix(t *testing.T) { + t.Parallel() + + aiGateway := serpent.Group{Name: "AI Gateway"} + email := serpent.Group{Name: "Email"} + emailAuth := serpent.Group{Name: "Email Authentication", Parent: &email} + introspection := serpent.Group{Name: "Introspection"} + healthCheck := serpent.Group{Name: "Health Check", Parent: &introspection} + networking := serpent.Group{Name: "Networking"} + derp := serpent.Group{Name: "DERP", Parent: &networking} + oauth2 := serpent.Group{Name: "OAuth2"} + github := serpent.Group{Name: "GitHub", Parent: &oauth2} + dangerous := serpent.Group{Name: "⚠️ Dangerous"} + + cases := []struct { + name string + group *serpent.Group + want string + }{ + // Space-prefixed names drop the group path. + {"AI Gateway Send Actor Headers", &aiGateway, "Send Actor Headers"}, + {"DERP Config Path", &derp, "Config Path"}, + {"OAuth2 GitHub Allow Everyone", &github, "Allow Everyone"}, + // Colon-prefixed names drop up to the last ": ". + {"Email Auth: Identity", &emailAuth, "Identity"}, + // A meaningful colon that is not a group separator is preserved. + {"Health Check Threshold: Database", &healthCheck, "Threshold: Database"}, + // The Dangerous group's emoji name still matches its "DANGEROUS:" prefix. + {"DANGEROUS: Allow Path App Sharing", &dangerous, "Allow Path App Sharing"}, + // Names that do not repeat the group are unchanged. + {"Access URL", &networking, "Access URL"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := stripGroupPrefix(tc.name, tc.group); got != tc.want { + t.Errorf("stripGroupPrefix(%q) = %q, want %q", tc.name, got, tc.want) + } + }) + } +} + +func TestShortTitle(t *testing.T) { + t.Parallel() + + aiGateway := serpent.Group{Name: "AI Gateway"} + cases := []struct { + opt serpent.Option + want string + }{ + {serpent.Option{Name: "AI Gateway Send Actor Headers", Group: &aiGateway}, "Send actor headers"}, + {serpent.Option{Name: "AI Gateway Anthropic Base URL", Group: &aiGateway}, "Anthropic base URL"}, + // No group: only sentence case applies. + {serpent.Option{Name: "Cache Directory"}, "Cache directory"}, + } + for _, tc := range cases { + t.Run(tc.opt.Name, func(t *testing.T) { + t.Parallel() + if got := shortTitle(tc.opt); got != tc.want { + t.Errorf("shortTitle(%q) = %q, want %q", tc.opt.Name, got, tc.want) + } + }) + } +} + +func TestIsDeprecated(t *testing.T) { + t.Parallel() + cases := []struct { + name string + opt serpent.Option + want bool + }{ + {"description prefix", serpent.Option{Description: "Deprecated: use X instead."}, true}, + {"description sentence", serpent.Option{Description: "Deprecated and ignored."}, true}, + {"use instead", serpent.Option{UseInstead: []serpent.Option{{Name: "X"}}}, true}, + {"active", serpent.Option{Description: "A normal option."}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := isDeprecated(tc.opt); got != tc.want { + t.Errorf("isDeprecated(%s) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestEmphasizeDeprecation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in string + want string + }{ + // Description already starts with the marker: only the marker is bolded. + {"marker with sentence", "Deprecated and ignored.", "**Deprecated** and ignored."}, + {"marker with colon", "Deprecated: use X.", "**Deprecated**: use X."}, + // Description does not start with the marker (the UseInstead path): the + // marker is prepended. + {"no marker", "A normal description.", "**Deprecated.** A normal description."}, + {"empty description", "", "Deprecated."}, + // A bare marker with no trailing text is left unbolded (markdownlint MD036). + {"bare marker", "Deprecated", "Deprecated"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := emphasizeDeprecation(tc.in); got != tc.want { + t.Errorf("emphasizeDeprecation(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestCollapse(t *testing.T) { + t.Parallel() + if got := collapse("a\n b\tc "); got != "a b c" { + t.Errorf("collapse() = %q, want %q", got, "a b c") + } +} + +// TestRenderPipeline exercises buildTree and render end to end: section +// nesting and ordering, option skipping, deprecated sinking, and the per-option +// bullet list (environment variable, CLI flag anchor, YAML key, default). +func TestRenderPipeline(t *testing.T) { + t.Parallel() + + email := serpent.Group{Name: "Email", YAML: "email"} + emailAuth := serpent.Group{Name: "Email Authentication", YAML: "emailAuth", Parent: &email} + dangerous := serpent.Group{Name: "⚠️ Dangerous", YAML: "dangerous"} + + opts := serpent.OptionSet{ + // Hidden options and options with no env/flag/YAML are skipped. + {Name: "Hidden Option", Env: "CODER_HIDDEN", Hidden: true}, + {Name: "Unsettable Option"}, + // General section (no group). + {Name: "Access URL", Env: "CODER_ACCESS_URL", Flag: "access-url", Default: "https://example.com", Description: "The access URL."}, + // A DefaultFn with no static Default renders the computed-at-runtime label. + {Name: "Cache Directory", Env: "CODER_CACHE_DIRECTORY", Flag: "cache-dir", DefaultFn: func() string { return "~/.cache/coder" }, Description: "The cache directory."}, + // Deprecated via UseInstead: description does not start with "Deprecated". + {Name: "Email From", Env: "CODER_EMAIL_FROM", Flag: "email-from", YAML: "from", Group: &email, Description: "The sender address.", UseInstead: []serpent.Option{{Name: "Notifications Email From"}}}, + // Active option with a flag shorthand. + {Name: "Email Smarthost", Env: "CODER_EMAIL_SMARTHOST", Flag: "email-smarthost", FlagShorthand: "s", YAML: "smarthost", Group: &email, Description: "The SMTP host."}, + // Nested child section. + {Name: "Email Authentication Identity", Env: "CODER_EMAIL_AUTH_IDENTITY", YAML: "identity", Group: &emailAuth, Description: "The identity."}, + // A Dangerous group sorts last regardless of alphabetical order. + {Name: "DANGEROUS: Allow All Cors", Env: "CODER_DANGEROUS_ALLOW_ALL_CORS", Flag: "dangerous-allow-all-cors", Group: &dangerous, Description: "Allow all cross-origin requests."}, + } + + got := render(buildTree(opts)) + + wantContains := []string{ + "## General", + "### Access URL", + "- Environment variable: `CODER_ACCESS_URL`", + "- CLI flag: [`--access-url`](../../reference/cli/server.md#--access-url)", + "- Default value: `https://example.com`", + "## Email", + "### Smarthost", + // Flag shorthand is folded into the anchor to match the CLI reference. + "- CLI flag: [`--email-smarthost`](../../reference/cli/server.md#-s---email-smarthost)", + // YAML key is the dotted group path. + "- YAML key: `email.from`", + // Deprecated marker is prepended for the UseInstead path. + "**Deprecated.** The sender address.", + // A DefaultFn with no static Default is labeled, not evaluated. + "- Default value: `(computed at runtime)`", + "### Email authentication", + "#### Identity", + "- YAML key: `email.emailAuth.identity`", + // The Dangerous group renders as its own section. + "## ⚠️ Dangerous", + } + for _, w := range wantContains { + if !strings.Contains(got, w) { + t.Errorf("render() missing %q\n---\n%s", w, got) + } + } + + // General (rank -1) sorts before every other top-level section. + if i, j := strings.Index(got, "## General"), strings.Index(got, "## Email"); i < 0 || j < 0 || i > j { + t.Errorf("General should render before Email (got indexes %d, %d)", i, j) + } + // Active options sort before deprecated ones within a section. + if i, j := strings.Index(got, "### Smarthost"), strings.Index(got, "### From"); i < 0 || j < 0 || i > j { + t.Errorf("active option should render before deprecated option (got indexes %d, %d)", i, j) + } + // The Dangerous section sorts last among top-level sections. + if i, j := strings.Index(got, "## Email"), strings.Index(got, "## ⚠️ Dangerous"); i < 0 || j < 0 || i > j { + t.Errorf("Dangerous section should render last (got indexes %d, %d)", i, j) + } + // Hidden and unsettable options never render. + if strings.Contains(got, "Hidden") { + t.Error("hidden option should be skipped") + } + if strings.Contains(got, "Unsettable") { + t.Error("option with no env/flag/YAML should be skipped") + } +} diff --git a/scripts/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index 70ae90436c659..ccce769144d45 100644 --- a/scripts/metricsdocgen/metrics +++ b/scripts/metricsdocgen/metrics @@ -166,9 +166,9 @@ coder_ai_gateway_cost_control_enforcement_duration_seconds_bucket{outcome="allow coder_ai_gateway_cost_control_enforcement_duration_seconds_bucket{outcome="allowed",le="+Inf"} 0 coder_ai_gateway_cost_control_enforcement_duration_seconds_sum{outcome="allowed"} 0 coder_ai_gateway_cost_control_enforcement_duration_seconds_count{outcome="allowed"} 0 -# HELP coder_ai_gateway_cost_control_unpriced_token_usage_records_total The number of recorded AI token-usage records for which no model price was found (provider: anthropic, openai, copilot). +# HELP coder_ai_gateway_cost_control_unpriced_token_usage_records_total The number of recorded AI token-usage records for which no (provider_type, model) price was found. provider is the provider instance name, and provider_type is its configured type. # TYPE coder_ai_gateway_cost_control_unpriced_token_usage_records_total counter -coder_ai_gateway_cost_control_unpriced_token_usage_records_total{model="gpt-5-nano",provider="openai"} 0 +coder_ai_gateway_cost_control_unpriced_token_usage_records_total{model="gpt-5-nano",provider="openai",provider_type="openai"} 0 # HELP coder_ai_gateway_injected_tool_invocations_total The number of times an injected MCP tool was invoked by AI Gateway. # TYPE coder_ai_gateway_injected_tool_invocations_total counter coder_ai_gateway_injected_tool_invocations_total{model="gpt-5-nano",name="coder_list_templates",provider="openai",server="https://xxx.pit-1.try.coder.app/api/experimental/mcp/http"} 1 @@ -195,7 +195,7 @@ coder_ai_gateway_interceptions_total{client="Codex",initiator_id="95f6752b-08cc- coder_ai_gateway_key_pool_state{provider="openai",state="valid"} 2 coder_ai_gateway_key_pool_state{provider="openai",state="temporary"} 0 coder_ai_gateway_key_pool_state{provider="openai",state="permanent"} 0 -# HELP coder_ai_gateway_key_pool_state_transitions_total The number of API key state transitions during failover (reason: rate_limited, unauthorized, forbidden). +# HELP coder_ai_gateway_key_pool_state_transitions_total The number of API key state transitions during failover (reason: rate_limited, unauthorized). # TYPE coder_ai_gateway_key_pool_state_transitions_total counter coder_ai_gateway_key_pool_state_transitions_total{provider="openai",reason="rate_limited"} 1 # HELP coder_ai_gateway_key_pool_exhaustions_total The number of times the key pool was exhausted with no usable key (outcome: rate_limited, auth_failed). diff --git a/scripts/releaser/commit.go b/scripts/releaser/commit.go index 37bdfcb7b5cb1..e182e25d2ee2b 100644 --- a/scripts/releaser/commit.go +++ b/scripts/releaser/commit.go @@ -186,6 +186,21 @@ func humanizeTitle(title string) string { // breakingCommitRe matches conventional commit "!:" breaking changes. var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`) +// findBreakingCommits returns the commits categorized as breaking, +// either by a "!" title marker or a release/breaking label on the +// source PR. Only the given commits are considered, so callers control +// the range (e.g. prevVersion..HEAD). +func findBreakingCommits(commits []commitEntry, prMeta *prMetadataMaps) []commitEntry { + var breaking []commitEntry + for _, c := range commits { + meta := prMeta.lookupCommit(c.FullSHA, c.PRCount) + if categorizeCommit(c.Title, meta.Labels) == "breaking" { + breaking = append(breaking, c) + } + } + return breaking +} + // categorizeCommit determines the release note section for a commit. // The priority order matches the bash script: breaking title first, // then labels (breaking, security, experimental), then prefix. diff --git a/scripts/releaser/commit_test.go b/scripts/releaser/commit_test.go new file mode 100644 index 0000000000000..ff1771b379695 --- /dev/null +++ b/scripts/releaser/commit_test.go @@ -0,0 +1,70 @@ +package main //nolint:testpackage // Tests unexported release helpers. + +import ( + "testing" +) + +func TestFindBreakingCommits(t *testing.T) { + t.Parallel() + + // Metadata for PRs that were labeled release/breaking. #10 is + // keyed by merge-commit SHA (direct merge), #20 only by PR + // number (cherry-picked, so the SHA on the release branch + // differs from the merge commit on main). #99 is labeled but + // deliberately absent from the commit list, simulating a + // breaking PR that shipped in an earlier release on the branch. + prMeta := &prMetadataMaps{ + bySHA: map[string]prMetadata{ + "aaa": {Labels: []string{"release/breaking"}}, + }, + byNumber: map[int]prMetadata{ + 10: {Labels: []string{"release/breaking"}}, + 20: {Labels: []string{"release/breaking"}}, + 99: {Labels: []string{"release/breaking"}}, + }, + } + + commits := []commitEntry{ + {SHA: "aaa", FullSHA: "aaa", Title: "feat: labeled via sha (#10)", PRCount: 10}, + {SHA: "bbb", FullSHA: "bbb", Title: "fix: labeled via pr number (#20)", PRCount: 20}, + {SHA: "ccc", FullSHA: "ccc", Title: "feat(cli)!: bang title (#30)", PRCount: 30}, + {SHA: "ddd", FullSHA: "ddd", Title: "fix: ordinary change (#40)", PRCount: 40}, + {SHA: "eee", FullSHA: "eee", Title: "chore: no pr number"}, + } + + got := findBreakingCommits(commits, prMeta) + + want := []string{"aaa", "bbb", "ccc"} + if len(got) != len(want) { + t.Fatalf("got %d breaking commits, want %d: %+v", len(got), len(want), got) + } + for i, c := range got { + if c.SHA != want[i] { + t.Fatalf("breaking[%d] = %s, want %s", i, c.SHA, want[i]) + } + } +} + +func TestFindBreakingCommitsWithoutMetadata(t *testing.T) { + t.Parallel() + + // With no PR metadata (gh unavailable), only "!" titles are + // detected. + prMeta := &prMetadataMaps{ + bySHA: map[string]prMetadata{}, + byNumber: map[int]prMetadata{}, + } + commits := []commitEntry{ + {SHA: "aaa", FullSHA: "aaa", Title: "feat!: bang (#1)", PRCount: 1}, + {SHA: "bbb", FullSHA: "bbb", Title: "feat: plain (#2)", PRCount: 2}, + } + + got := findBreakingCommits(commits, prMeta) + if len(got) != 1 || got[0].SHA != "aaa" { + t.Fatalf("got %+v, want only aaa", got) + } + + if got := findBreakingCommits(nil, prMeta); got != nil { + t.Fatalf("got %+v for no commits, want nil", got) + } +} diff --git a/scripts/releaser/github.go b/scripts/releaser/github.go index 75df80960f0f7..e63fec5a8eb1f 100644 --- a/scripts/releaser/github.go +++ b/scripts/releaser/github.go @@ -2,6 +2,7 @@ package main import ( "errors" + "io" "os/exec" "slices" "strconv" @@ -73,35 +74,6 @@ func ghListOpenPRs(branch string) ([]ghPR, error) { return prs, nil } -// ghListPRsWithLabel returns merged PRs targeting the given branch -// that have a specific label. -func ghListPRsWithLabel(branch, label string) ([]ghPR, error) { - out, err := ghOutput("pr", "list", - "--repo", owner+"/"+repo, - "--base", branch, - "--state", "merged", - "--label", label, - "--json", "number,title", - "--jq", `.[] | "\(.number)\t\(.title)"`, - ) - if err != nil { - return nil, err - } - if out == "" { - return nil, nil - } - var prs []ghPR - for _, line := range strings.Split(out, "\n") { - parts := strings.SplitN(line, "\t", 2) - if len(parts) < 2 { - continue - } - num, _ := strconv.Atoi(parts[0]) - prs = append(prs, ghPR{Number: num, Title: parts[1]}) - } - return prs, nil -} - // prMetadata holds labels and author for a merged PR. type prMetadata struct { Labels []string @@ -129,6 +101,27 @@ func (m *prMetadataMaps) lookupCommit(fullSHA string, prNumber int) prMetadata { return prMetadata{} } +// loadPRMetadata fetches PR metadata for the given commits via the gh +// CLI. It never fails: when gh is unavailable or the query errors, a +// warning is written and empty maps are returned so callers degrade to +// title-only categorization. +// +//nolint:revive // ghAvailable gates the gh CLI call; matches Run(dryRun). +func loadPRMetadata(w io.Writer, ghAvailable bool, commits []commitEntry) *prMetadataMaps { + if ghAvailable { + prMeta, err := ghBuildPRMetadataMap(commits) + if err != nil { + warnf(w, "Failed to fetch PR metadata: %v", err) + } else if prMeta != nil { + return prMeta + } + } + return &prMetadataMaps{ + bySHA: make(map[string]prMetadata), + byNumber: make(map[int]prMetadata), + } +} + // ghBuildPRMetadataMap returns PR metadata indexed by both // merge-commit SHA and PR number for merged PRs targeting main. // This matches the bash script's approach of querying --base main diff --git a/scripts/releaser/release.go b/scripts/releaser/release.go index 4af28da103377..dd0988ea0c405 100644 --- a/scripts/releaser/release.go +++ b/scripts/releaser/release.go @@ -451,43 +451,26 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx if newVersion.Major == prevVersion.Major && newVersion.Minor == prevVersion.Minor && newVersion.Patch > prevVersion.Patch { infof(w, "Checking for breaking changes in patch release...") + // Both the "!" title marker and the release/breaking PR + // label are evaluated over prevVersion..HEAD only, so + // breaking changes shipped in earlier releases on this + // branch are not reported again. commitRange := prevVersion.String() + "..HEAD" commits, err := commitLog(commitRange) if err != nil { return xerrors.Errorf("reading commit log: %w", err) } - var breakingCommits []commitEntry - for _, c := range commits { - if breakingCommitRe.MatchString(c.Title) { - breakingCommits = append(breakingCommits, c) - } - } + prMeta := loadPRMetadata(w, ghAvailable, commits) + breakingCommits := findBreakingCommits(commits, prMeta) - // Check PR labels for release/breaking. - var breakingPRLabeled []ghPR - if ghAvailable { - breakingPRLabeled, err = ghListPRsWithLabel(currentBranch, "release/breaking") - if err != nil { - warnf(w, "Failed to check PR labels: %v", err) - } - } - - if len(breakingCommits) > 0 || len(breakingPRLabeled) > 0 { + if len(breakingCommits) > 0 { fmt.Fprintln(w) warnf(w, "BREAKING CHANGES detected in a PATCH release — this violates semver!") fmt.Fprintln(w) - if len(breakingCommits) > 0 { - fmt.Fprintln(w, " Breaking commits (by conventional commit prefix):") - for _, c := range breakingCommits { - fmt.Fprintf(w, " - %s %s\n", c.SHA, c.Title) - } - } - if len(breakingPRLabeled) > 0 { - fmt.Fprintln(w, " PRs labeled release/breaking:") - for _, pr := range breakingPRLabeled { - fmt.Fprintf(w, " - #%d %s\n", pr.Number, pr.Title) - } + fmt.Fprintf(w, " Breaking changes since %s:\n", prevVersion) + for _, c := range breakingCommits { + fmt.Fprintf(w, " - %s %s\n", c.SHA, c.Title) } fmt.Fprintln(w) if err := confirmWithDefault(inv, "Continue with patch release despite breaking changes?", cliui.ConfirmNo); err != nil { @@ -576,20 +559,7 @@ func runRelease(ctx context.Context, inv *serpent.Invocation, executor ReleaseEx return xerrors.Errorf("reading commit log: %w", err) } - // Build PR metadata maps (by SHA and PR number) via gh CLI. - var prMeta *prMetadataMaps - if ghAvailable { - prMeta, err = ghBuildPRMetadataMap(commits) - if err != nil { - warnf(w, "Failed to fetch PR metadata: %v", err) - } - } - if prMeta == nil { - prMeta = &prMetadataMaps{ - bySHA: make(map[string]prMetadata), - byNumber: make(map[int]prMetadata), - } - } + prMeta := loadPRMetadata(w, ghAvailable, commits) type section struct { Key string diff --git a/site/permissions.json b/site/permissions.json index 66ef7aa237042..71b91a51b12c1 100644 --- a/site/permissions.json +++ b/site/permissions.json @@ -15,6 +15,14 @@ "object": { "resource_type": "template", "any_org": true }, "action": "create" }, + "createWorkspace": { + "object": { + "resource_type": "workspace", + "any_org": true, + "owner_id": "me" + }, + "action": "create" + }, "updateTemplates": { "object": { "resource_type": "template" }, "action": "update" diff --git a/site/site_test.go b/site/site_test.go index e8f693001e048..bb66ecaf9a641 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -275,6 +275,10 @@ func TestRenderPermissionsResolvesMe(t *testing.T) { err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithRole) require.NoError(t, err) assert.True(t, permsWithRole["createChat"], "user with agents-access role should have createChat = true") + // THEN: createWorkspace = true because the organization-member role + // grants creating a workspace owned by the member, and owner_id "me" + // resolves to the requesting user. + assert.True(t, permsWithRole["createWorkspace"], "org member should have createWorkspace = true") // GIVEN: a user without the agents-access role. userWithoutRole := dbgen.User(t, db, database.User{}) @@ -296,6 +300,36 @@ func TestRenderPermissionsResolvesMe(t *testing.T) { err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithoutRole) require.NoError(t, err) assert.False(t, permsWithoutRole["createChat"], "user without agents-access role should have createChat = false") + // THEN: createWorkspace = false because the user belongs to no + // organization, so the any_org check has no memberships to satisfy it. + assert.False(t, permsWithoutRole["createWorkspace"], "user without an org membership should have createWorkspace = false") + + // GIVEN: an org member whose only membership carries the + // workspace-creation ban role. + bannedUser := dbgen.User(t, db, database.User{}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: org.ID, + UserID: bannedUser.ID, + Roles: []string{rbac.RoleOrgWorkspaceCreationBan()}, + }) + _, bannedToken := dbgen.APIKey(t, db, database.APIKey{ + UserID: bannedUser.ID, + ExpiresAt: time.Now().Add(time.Hour), + }) + + // WHEN: the user loads the page. + r = httptest.NewRequest("GET", "/", nil) + r.Header.Set(codersdk.SessionTokenHeader, bannedToken) + rw = httptest.NewRecorder() + handler.ServeHTTP(rw, r) + require.Equal(t, http.StatusOK, rw.Code) + + // THEN: createWorkspace = false because the ban's negative permission + // overrides the create permission granted by org membership. + var bannedPerms codersdk.AuthorizationResponse + err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &bannedPerms) + require.NoError(t, err) + assert.False(t, bannedPerms["createWorkspace"], "org member with a workspace-creation ban should have createWorkspace = false") } func TestInjectionFailureProducesCleanHTML(t *testing.T) { diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 98a9cb4eff31e..446467c52bbaa 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -484,6 +484,7 @@ describe("api.ts", () => { description: "Example token for tests", env_name: secretName, file_path: "", + enabled: true, created_at: "2026-05-04T00:00:00Z", updated_at: "2026-05-04T00:00:00Z", }; diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 284edf485c402..46248aceea1c4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1615,7 +1615,7 @@ class ApiMethods { userId: TypesGen.User["id"], ): Promise<TypesGen.UserAIBudgetOverride> => { const response = await this.axios.get<TypesGen.UserAIBudgetOverride>( - `/api/v2/users/${encodeURIComponent(userId)}/ai/budget`, + `/api/v2/users/${encodeURIComponent(userId)}/ai/budget/override`, ); return response.data; @@ -1626,7 +1626,7 @@ class ApiMethods { data: TypesGen.UpsertUserAIBudgetOverrideRequest, ): Promise<TypesGen.UserAIBudgetOverride> => { const response = await this.axios.put<TypesGen.UserAIBudgetOverride>( - `/api/v2/users/${encodeURIComponent(userId)}/ai/budget`, + `/api/v2/users/${encodeURIComponent(userId)}/ai/budget/override`, data, ); @@ -1637,7 +1637,7 @@ class ApiMethods { userId: TypesGen.User["id"], ): Promise<void> => { await this.axios.delete( - `/api/v2/users/${encodeURIComponent(userId)}/ai/budget`, + `/api/v2/users/${encodeURIComponent(userId)}/ai/budget/override`, ); }; @@ -1788,6 +1788,18 @@ class ApiMethods { ); }; + importUserSecrets = async ( + userId: string, + request: TypesGen.ImportUserSecretsRequest, + ): Promise<TypesGen.UserSecret[]> => { + const response = await this.axios.post<TypesGen.UserSecret[]>( + `/api/v2/users/${encodeURIComponent(userId)}/secrets/batch`, + request, + ); + + return response.data; + }; + getWorkspaceBuilds = async ( workspaceId: string, req?: TypesGen.WorkspaceBuildsRequest, @@ -3936,71 +3948,6 @@ class ExperimentalApiMethods { const response = await this.axios.get<TypesGen.ChatCostUsersResponse>(url); return response.data; }; - - getChatUsageLimitConfig = - async (): Promise<TypesGen.ChatUsageLimitConfigResponse> => { - const response = - await this.axios.get<TypesGen.ChatUsageLimitConfigResponse>( - "/api/experimental/chats/usage-limits", - ); - return response.data; - }; - - getChatUsageLimitStatus = - async (): Promise<TypesGen.ChatUsageLimitStatus> => { - const response = await this.axios.get<TypesGen.ChatUsageLimitStatus>( - "/api/experimental/chats/usage-limits/status", - ); - return response.data; - }; - - updateChatUsageLimitConfig = async ( - req: TypesGen.ChatUsageLimitConfig, - ): Promise<TypesGen.ChatUsageLimitConfig> => { - const response = await this.axios.put<TypesGen.ChatUsageLimitConfig>( - "/api/experimental/chats/usage-limits", - req, - ); - return response.data; - }; - - upsertChatUsageLimitOverride = async ( - userID: string, - req: TypesGen.UpsertChatUsageLimitOverrideRequest, - ): Promise<TypesGen.ChatUsageLimitOverride> => { - const response = await this.axios.put<TypesGen.ChatUsageLimitOverride>( - `/api/experimental/chats/usage-limits/overrides/${encodeURIComponent(userID)}`, - req, - ); - return response.data; - }; - - deleteChatUsageLimitOverride = async (userID: string): Promise<void> => { - const response = await this.axios.delete( - `/api/experimental/chats/usage-limits/overrides/${encodeURIComponent(userID)}`, - ); - return response.data; - }; - - upsertChatUsageLimitGroupOverride = async ( - groupID: string, - req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest, - ): Promise<TypesGen.ChatUsageLimitGroupOverride> => { - const response = await this.axios.put<TypesGen.ChatUsageLimitGroupOverride>( - `/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`, - req, - ); - return response.data; - }; - - deleteChatUsageLimitGroupOverride = async ( - groupID: string, - ): Promise<void> => { - const response = await this.axios.delete( - `/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`, - ); - return response.data; - }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a23e7efd7d3b6..751696b414ae9 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1994,91 +1994,6 @@ export function paginatedChatCostUsers( }; } -export const chatUsageLimitStatusKey = [ - ...chatsKey, - "usageLimitStatus", -] as const; - -export const chatUsageLimitStatus = () => ({ - queryKey: chatUsageLimitStatusKey, - queryFn: () => API.experimental.getChatUsageLimitStatus(), - refetchInterval: 60_000, -}); - -const chatUsageLimitConfigKey = [...chatsKey, "usageLimitConfig"] as const; - -export const chatUsageLimitConfig = () => ({ - queryKey: chatUsageLimitConfigKey, - queryFn: () => API.experimental.getChatUsageLimitConfig(), -}); - -export const updateChatUsageLimitConfig = (queryClient: QueryClient) => ({ - mutationFn: (req: TypesGen.ChatUsageLimitConfig) => - API.experimental.updateChatUsageLimitConfig(req), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - -type UpsertChatUsageLimitOverrideMutationArgs = { - userID: string; - req: TypesGen.UpsertChatUsageLimitOverrideRequest; -}; - -export const upsertChatUsageLimitOverride = (queryClient: QueryClient) => ({ - mutationFn: ({ userID, req }: UpsertChatUsageLimitOverrideMutationArgs) => - API.experimental.upsertChatUsageLimitOverride(userID, req), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - -export const deleteChatUsageLimitOverride = (queryClient: QueryClient) => ({ - mutationFn: (userID: string) => - API.experimental.deleteChatUsageLimitOverride(userID), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - -type UpsertChatUsageLimitGroupOverrideMutationArgs = { - groupID: string; - req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest; -}; - -export const upsertChatUsageLimitGroupOverride = ( - queryClient: QueryClient, -) => ({ - mutationFn: ({ - groupID, - req, - }: UpsertChatUsageLimitGroupOverrideMutationArgs) => - API.experimental.upsertChatUsageLimitGroupOverride(groupID, req), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - -export const deleteChatUsageLimitGroupOverride = ( - queryClient: QueryClient, -) => ({ - mutationFn: (groupID: string) => - API.experimental.deleteChatUsageLimitGroupOverride(groupID), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: chatUsageLimitConfigKey, - }); - }, -}); - // ── MCP Server Configs ─────────────────────────────────────── export const mcpServerConfigsKey = ["mcp-server-configs"] as const; diff --git a/site/src/api/queries/groups.test.ts b/site/src/api/queries/groups.test.ts new file mode 100644 index 0000000000000..74ebf8606b651 --- /dev/null +++ b/site/src/api/queries/groups.test.ts @@ -0,0 +1,35 @@ +import { QueryClient } from "react-query"; +import { describe, expect, it } from "vitest"; +import { + getGroupMembersAISpendQueryKey, + invalidateGroupMembersAISpend, +} from "./groups"; + +describe("invalidateGroupMembersAISpend", () => { + it("invalidates only group member spend queries containing the user", async () => { + const queryClient = new QueryClient(); + const userId = "user-1"; + const spendWithUser = getGroupMembersAISpendQueryKey("group-1", [ + "user-2", + userId, + ]); + const spendWithoutUser = getGroupMembersAISpendQueryKey("group-2", [ + "user-2", + ]); + const otherGroupQuery = ["group", "group-1"]; + + queryClient.setQueryData(spendWithUser, {}); + queryClient.setQueryData(spendWithoutUser, {}); + queryClient.setQueryData(otherGroupQuery, {}); + + await invalidateGroupMembersAISpend(queryClient, userId); + + expect(queryClient.getQueryState(spendWithUser)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(spendWithoutUser)?.isInvalidated).toBe( + false, + ); + expect(queryClient.getQueryState(otherGroupQuery)?.isInvalidated).toBe( + false, + ); + }); +}); diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index 8093330fc244a..6e714f92c7925 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -64,6 +64,25 @@ export const getGroupMembersAISpendQueryKey = ( userIds: readonly string[], ) => ["group", groupId, "members", "aiSpend", [...userIds].sort()]; +const isGroupMembersAISpendQueryKey = ( + queryKey: readonly unknown[], + userId: string, +): boolean => + queryKey[0] === "group" && + queryKey[2] === "members" && + queryKey[3] === "aiSpend" && + Array.isArray(queryKey[4]) && + queryKey[4].includes(userId); + +export const invalidateGroupMembersAISpend = ( + queryClient: QueryClient, + userId: string, +) => + queryClient.invalidateQueries({ + queryKey: ["group"], + predicate: (query) => isGroupMembersAISpendQueryKey(query.queryKey, userId), + }); + export const groupMembersAISpend = ( groupId: string, userIds: readonly string[], diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 1dcaac36596f6..6caf0d6f958bf 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -294,6 +294,12 @@ export const provisionerJobs = ( }; }; +export const permittedOrganizationsKey = (check: AuthorizationCheck) => [ + "organizations", + "permitted", + check, +]; + /** * Fetch organizations the current user is permitted to use for a given * action. Fetches all organizations, runs a per-org authorization @@ -301,7 +307,7 @@ export const provisionerJobs = ( */ export const permittedOrganizations = (check: AuthorizationCheck) => { return { - queryKey: ["organizations", "permitted", check], + queryKey: permittedOrganizationsKey(check), queryFn: async (): Promise<Organization[]> => { const orgs = await API.getOrganizations(); const checks = Object.fromEntries( diff --git a/site/src/api/queries/userSecrets.ts b/site/src/api/queries/userSecrets.ts index 40463d7851070..340e791c9149d 100644 --- a/site/src/api/queries/userSecrets.ts +++ b/site/src/api/queries/userSecrets.ts @@ -50,3 +50,15 @@ export const deleteUserSecret = (queryClient: QueryClient, userId: string) => { }, }; }; + +export const importUserSecrets = (queryClient: QueryClient, userId: string) => { + return { + mutationFn: (request: TypesGen.ImportUserSecretsRequest) => + API.importUserSecrets(userId, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: userSecretsKey(userId), + }); + }, + }; +}; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index b5a9b2bed6039..47e7f539319a4 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -31,6 +31,7 @@ import { import type { UsePaginatedQueryOptions } from "#/hooks/usePaginatedQuery"; import { prepareQuery } from "#/utils/filters"; import { getAuthorizationKey } from "./authCheck"; +import { invalidateGroupMembersAISpend } from "./groups"; import { cachedQuery } from "./util"; export function usersKey(req: UsersRequest) { @@ -203,6 +204,17 @@ export const userAIBudgetOverride = ( }; }; +const invalidateUserAIBudgetQueries = ( + queryClient: QueryClient, + userId: string, +) => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: getUserAIBudgetOverrideQueryKey(userId), + }), + invalidateGroupMembersAISpend(queryClient, userId), + ]); + export const saveUserAIBudgetOverride = ( queryClient: QueryClient, userId: string, @@ -211,9 +223,7 @@ export const saveUserAIBudgetOverride = ( mutationFn: (request: UpsertUserAIBudgetOverrideRequest) => API.upsertUserAIBudgetOverride(userId, request), onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: getUserAIBudgetOverrideQueryKey(userId), - }); + await invalidateUserAIBudgetQueries(queryClient, userId); }, }; }; @@ -225,9 +235,7 @@ export const deleteUserAIBudgetOverride = ( return { mutationFn: () => API.deleteUserAIBudgetOverride(userId), onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: getUserAIBudgetOverrideQueryKey(userId), - }); + await invalidateUserAIBudgetQueries(queryClient, userId); }, }; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4567c34e07849..d283df627e462 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -141,7 +141,7 @@ export interface AIBridgeSession { readonly threads: number; readonly token_usage_summary: AIBridgeSessionTokenUsageSummary; /** - * NetworkCalls summarizes the Agent Firewall network calls made during the + * NetworkCalls summarizes the Agent Firewall network requests made during the * session. A nil value means the session did not pass through Agent * Firewall, so network call monitoring was not active, which the UI * surfaces as "Disabled". @@ -162,6 +162,16 @@ export interface AIBridgeSessionNetworkCallSummary { readonly blocked: number; } +// From codersdk/aibridge.go +/** + * AIBridgeSessionNetworkDomain is one destination host contacted during a + * session, with the number of network calls made to it. + */ +export interface AIBridgeSessionNetworkDomain { + readonly domain: string; + readonly count: number; +} + // From codersdk/aibridge.go /** * AIBridgeSessionThreadsResponse is the response for GET @@ -181,6 +191,28 @@ export interface AIBridgeSessionThreadsResponse { readonly started_at: string; readonly ended_at?: string; readonly token_usage_summary: AIBridgeSessionThreadsTokenUsage; + /** + * NetworkCalls summarizes the Agent Firewall network calls made during the + * session. A nil value means the session did not pass through Agent + * Firewall, so network call monitoring was not active, which the UI + * surfaces as "Disabled". + */ + readonly network_calls?: AIBridgeSessionNetworkCallSummary; + /** + * NetworkTopDomains lists the most contacted destination hosts, ordered by + * call count descending. NetworkDomainCount is the total number of distinct + * domains, used to render a "+N more" overflow beyond the listed domains. + */ + readonly network_top_domains?: readonly AIBridgeSessionNetworkDomain[]; + readonly network_domain_count?: number; + /** + * NetworkCallLogs is the chronological list of individual network calls made + * during the session, holding the earliest calls up to a server-side cap. + * NetworkCalls remains authoritative for whole-session totals, so a shorter + * list than NetworkCalls.Total means the list was truncated. Empty when the + * session did not pass through Agent Firewall. + */ + readonly network_call_logs?: readonly AgentFirewallLog[]; readonly threads: readonly AIBridgeThread[]; } @@ -266,6 +298,16 @@ export interface AIBridgeToolCall { readonly created_at: string; } +// From codersdk/aibridge.go +/** + * AIBudgetLimit is an AI spend limit and the tier that produced it. Both + * fields are always populated together. + */ +export interface AIBudgetLimit { + readonly spend_limit_micros: number; + readonly limit_source: AIBudgetLimitSource; +} + // From codersdk/aibridge.go export type AIBudgetLimitSource = "group" | "user_override"; @@ -310,16 +352,6 @@ export interface AIGatewayKey { */ export const AIGatewayKeyHeader = "X-Coder-AI-Governance-Gateway-Key"; -// From codersdk/aibridge.go -/** - * AIGroupBudget is an AI spend limit and the tier that produced it. Both - * fields are always populated together. - */ -export interface AIGroupBudget { - readonly spend_limit_micros: number; - readonly limit_source: AIBudgetLimitSource; -} - // From codersdk/aiproviders.go /** * AIProvider represents an AI provider configuration row as returned @@ -4106,8 +4138,11 @@ export interface CreateUserRequestWithOrgs { // From codersdk/usersecrets.go /** * CreateUserSecretRequest is the payload for creating a new user - * secret. Name and Value are required. All other fields are optional - * and default to empty string. + * secret. Name and Value are required. An enabled secret must have at + * least one of EnvName or FilePath non-empty so it has an injection + * target; to keep a secret without injecting it, set Enabled to false. + * All other fields are optional and default to empty string. Enabled + * defaults to true when omitted. */ export interface CreateUserSecretRequest { readonly name: string; @@ -4115,6 +4150,7 @@ export interface CreateUserSecretRequest { readonly description?: string; readonly env_name?: string; readonly file_path?: string; + readonly enabled?: boolean; } // From codersdk/userskills.go @@ -4605,6 +4641,7 @@ export interface DeploymentValues { readonly disable_owner_workspace_exec?: boolean; readonly disable_workspace_sharing?: boolean; readonly disable_chat_sharing?: boolean; + readonly disable_workspace_agent_context_sync?: boolean; readonly proxy_health_status_interval?: number; readonly enable_terraform_debug_mode?: boolean; readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig; @@ -4831,7 +4868,6 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = - | "ai-gateway-cost-control" | "ai-gateway-seat-exclusion" | "auto-fill-parameters" | "chat-advisor" @@ -4847,7 +4883,6 @@ export type Experiment = | "workspace-usage"; export const Experiments: Experiment[] = [ - "ai-gateway-cost-control", "ai-gateway-seat-exclusion", "auto-fill-parameters", "chat-advisor", @@ -5212,6 +5247,15 @@ export interface GroupAIBudget { readonly updated_at: string; } +// From codersdk/aibridge.go +/** + * GroupAISpend is the current AI spend snapshot for a single group within + * the active budget period. + */ +export interface GroupAISpend + extends AISpendPeriodWindow, + OrganizationGroupAISpend {} + // From codersdk/groups.go export interface GroupArguments { /** @@ -5248,7 +5292,7 @@ export interface GroupMemberAISpend { * effective budget source. Null when the user's budget resolves to another * group or no budget applies to the user. */ - readonly group_budget: AIGroupBudget | null; + readonly group_budget: AIBudgetLimit | null; /** * GroupSpendMicros is the user's spend attributed to the queried group * over the current budget period. @@ -5791,6 +5835,13 @@ export interface MatchedProvisioners { readonly most_recently_seen?: string; } +// From codersdk/aibridge.go +/** + * MaxAISpendLimitMicros is the highest AI spend limit that can be configured, + * $1,000,000 per member per budget period. + */ +export const MaxAISpendLimitMicros = 1000000000000; + // From codersdk/chats.go /** * MaxChatFileIDs is the maximum number of file IDs that can be @@ -6667,13 +6718,19 @@ export interface Organization extends MinimalOrganization { export interface OrganizationGroupAISpend { readonly group_id: string; /** - * SpendLimitMicros is the group's configured AI spend limit. Null when - * the group has no configured budget. + * SpendLimitMicros is the group's configured AI spend budget per member. + * Null when the group has no configured budget. */ readonly spend_limit_micros: number | null; /** - * CurrentSpendMicros is the group's spend over the current budget - * period. + * TotalSpendLimitMicros is the currently configured combined budget of the + * members attributed to this group, with each member's override replacing + * their share. Null when the group has no budget, and zero when no members + * are attributed to it. + */ + readonly total_spend_limit_micros: number | null; + /** + * CurrentSpendMicros is the group's spend over the current budget period. */ readonly current_spend_micros: number; } @@ -9745,13 +9802,16 @@ export interface UpdateUserQuietHoursScheduleRequest { * UpdateUserSecretRequest is the payload for partially updating a * user secret. At least one field must be non-nil. Pointer fields * distinguish "not sent" (nil) from "set to empty string" (pointer - * to empty string). + * to empty string). If the post-update row is enabled it must still + * have at least one of EnvName or FilePath non-empty; clearing both + * targets is only allowed when the secret is (or becomes) disabled. */ export interface UpdateUserSecretRequest { readonly value?: string; readonly description?: string; readonly env_name?: string; readonly file_path?: string; + readonly enabled?: boolean; } // From codersdk/userskills.go @@ -9896,6 +9956,9 @@ export interface UpsertChatUsageLimitOverrideRequest { // From codersdk/aibridge.go export interface UpsertGroupAIBudgetRequest { + /** + * SpendLimitMicros must not exceed MaxAISpendLimitMicros. + */ readonly spend_limit_micros: number; } @@ -9906,6 +9969,9 @@ export interface UpsertUserAIBudgetOverrideRequest { * be a member of this group. */ readonly group_id: string; + /** + * SpendLimitMicros must not exceed MaxAISpendLimitMicros. + */ readonly spend_limit_micros: number; } @@ -9966,7 +10032,7 @@ export interface UserAIBudgetOverride { /** * UserAIBudgetSummary is the effective AI budget for a user. When no budget * applies, the effective group falls back to the Everyone group with a null - * limit and source. + * budget. */ export interface UserAIBudgetSummary { readonly user_id: string; @@ -9977,15 +10043,11 @@ export interface UserAIBudgetSummary { */ readonly effective_group_id: string | null; /** - * SpendLimitMicros is the effective spend limit in micro-units. - * Null when no budget applies to the user (unlimited). - */ - readonly spend_limit_micros: number | null; - /** - * LimitSource identifies which tier produced the limit. Null when no - * budget applies. + * EffectiveBudget is the spend limit that applies to the user, whether it + * came from a group budget or a user override. Null when no budget + * applies, leaving the user's spend unlimited. */ - readonly limit_source: AIBudgetLimitSource | null; + readonly effective_budget: AIBudgetLimit | null; } // From codersdk/chats.go @@ -10255,6 +10317,13 @@ export interface UserSecret { readonly description: string; readonly env_name: string; readonly file_path: string; + /** + * Enabled controls whether the secret is injected into workspaces. + * Disabled secrets remain visible and editable, but are not added + * to the agent manifest, so they are not exposed as environment + * variables or written to secret files. + */ + readonly enabled: boolean; readonly created_at: string; readonly updated_at: string; } @@ -10275,6 +10344,16 @@ export const UserSecretEnvNameField = "env_name"; */ export const UserSecretFilePathField = "file_path"; +// From codersdk/usersecretvalidation.go +/** + * UserSecretInjectionTargetRequiredDetail explains the injection-target + * invariant. It is shared by the create validator above and the PATCH + * handler's post-state check in coderd. The value is a user-facing + * validation message, not a credential. + */ +export const UserSecretInjectionTargetRequiredDetail = + "An enabled secret must have at least one of env_name or file_path set. To keep a secret without injecting it, set enabled to false instead of clearing both targets."; //nolint:gosec // G101: message text, not a hardcoded credential. + // From codersdk/usersecretvalidation.go /** * UserSecret*Field constants are the canonical ValidationError.Field values diff --git a/site/src/components/FileUpload/FileUpload.tsx b/site/src/components/FileUpload/FileUpload.tsx index b35a89e76d396..2e6cd3b25293a 100644 --- a/site/src/components/FileUpload/FileUpload.tsx +++ b/site/src/components/FileUpload/FileUpload.tsx @@ -8,6 +8,7 @@ import { Spinner } from "../Spinner/Spinner"; interface FileUploadProps { isUploading: boolean; onUpload: (file: File) => void; + onUnsupportedFile?: (file: File) => void; onRemove?: () => void; file?: File; removeLabel: string; @@ -19,6 +20,7 @@ interface FileUploadProps { export const FileUpload: FC<FileUploadProps> = ({ isUploading, onUpload, + onUnsupportedFile, onRemove, file, removeLabel, @@ -26,7 +28,7 @@ export const FileUpload: FC<FileUploadProps> = ({ description, extensions, }) => { - const fileDrop = useFileDrop(onUpload, extensions); + const fileDrop = useFileDrop(onUpload, extensions, onUnsupportedFile); const inputRef = useRef<HTMLInputElement>(null); const clickable = useClickable<HTMLDivElement>(() => inputRef.current?.click(), @@ -87,6 +89,7 @@ export const FileUpload: FC<FileUploadProps> = ({ data-testid="file-upload" ref={inputRef} className="hidden" + disabled={isUploading} accept={extensions?.map((ext) => `.${ext}`).join(",")} onChange={(event) => { const file = event.currentTarget.files?.[0]; @@ -102,6 +105,7 @@ export const FileUpload: FC<FileUploadProps> = ({ const useFileDrop = ( callback: (file: File) => void, extensions?: string[], + onUnsupportedFile?: (file: File) => void, ): { onDragOver: (e: DragEvent<HTMLDivElement>) => void; onDrop: (e: DragEvent<HTMLDivElement>) => void; @@ -123,15 +127,14 @@ const useFileDrop = ( return; } - const extension = file.name.split(".").pop(); + const extension = file.name.split(".").pop()?.toLowerCase(); - if (!extension) { - throw new Error(`File has no extension to compare with ${extensions}`); - } - - if (extensions.includes(extension)) { + if (extension && extensions.includes(extension)) { callback(file); + return; } + + onUnsupportedFile?.(file); }; return { diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx new file mode 100644 index 0000000000000..8740b3af492c9 --- /dev/null +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type * as Monaco from "monaco-editor"; +import * as monaco from "monaco-editor"; +import { useState } from "react"; +import { expect, userEvent, waitFor } from "storybook/test"; +import { withDashboardProvider } from "#/testHelpers/storybook"; +import { SyntaxHighlighter } from "./SyntaxHighlighter"; + +const original = `resource "coder_agent" "main" { + os = "linux" + arch = "amd64" +} +`; + +const modified = `resource "coder_agent" "main" { + os = "linux" + arch = "arm64" +} +`; + +// The diff editor's gutter menu and occurrence highlighter register delayed +// disposables whose teardown throws when editors unmount in Storybook tests. +// They are irrelevant to model disposal, so we turn them off in stories to keep +// the test runner clean without changing production behavior. +const stableTeardownOptions: Monaco.editor.IStandaloneDiffEditorConstructionOptions = + { + minimap: { enabled: false }, + renderSideBySide: true, + readOnly: true, + renderGutterMenu: false, + occurrencesHighlight: "off", + }; + +const meta: Meta<typeof SyntaxHighlighter> = { + title: "components/SyntaxHighlighter", + component: SyntaxHighlighter, + decorators: [withDashboardProvider], + args: { + language: "hcl", + editorProps: { options: stableTeardownOptions }, + }, +}; + +export default meta; +type Story = StoryObj<typeof SyntaxHighlighter>; + +export const Plain: Story = { + args: { + value: original, + }, +}; + +export const Diff: Story = { + args: { + value: modified, + compareWith: original, + }, +}; + +// Reproduces the leak from DEVEX-736: a single SyntaxHighlighter instance that +// stays mounted while a file switches between diff and plain across template +// versions. Each diff editor owns two Monaco models, and they must be disposed +// when the diff goes away. Before the fix the models were only disposed on full +// unmount, so toggling diff -> plain -> diff leaked two models per cycle. +const DiffToggle = () => { + const [showDiff, setShowDiff] = useState(true); + return ( + <div> + <button type="button" onClick={() => setShowDiff((show) => !show)}> + Toggle diff + </button> + <SyntaxHighlighter + language="hcl" + value={showDiff ? modified : original} + compareWith={original} + editorProps={{ options: stableTeardownOptions }} + /> + </div> + ); +}; + +export const DisposesModelsOnDiffToggle: Story = { + render: () => <DiffToggle />, + play: async ({ canvas }) => { + const toggle = canvas.getByRole("button", { name: "Toggle diff" }); + + // Wait for the diff editor to mount its original + modified models, then + // record the total as a baseline. Every full toggle cycle must return to + // this number; growth would mean abandoned models are being retained. + let baseline = 0; + await waitFor(() => { + baseline = monaco.editor.getModels().length; + expect(baseline).toBeGreaterThanOrEqual(2); + }); + + for (let cycle = 0; cycle < 3; cycle++) { + // Switch to plain: the diff editor unmounts and must dispose its models. + await userEvent.click(toggle); + await waitFor(() => + expect(monaco.editor.getModels().length).toBeLessThan(baseline), + ); + + // Switch back to diff: a new diff editor mounts and the total must land + // back on the baseline rather than climbing. + await userEvent.click(toggle); + await waitFor(() => + expect(monaco.editor.getModels().length).toBe(baseline), + ); + } + }, +}; diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx index 68d9e140ce528..37e76006575be 100644 --- a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx +++ b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.tsx @@ -2,7 +2,13 @@ import { useTheme } from "@emotion/react"; import Editor, { DiffEditor, loader } from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; import * as monaco from "monaco-editor"; -import { type ComponentProps, type FC, useCallback } from "react"; +import { + type ComponentProps, + type FC, + useCallback, + useEffect, + useRef, +} from "react"; import { useCoderTheme } from "./coderTheme"; loader.config({ monaco }); @@ -38,40 +44,6 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({ const theme = useTheme(); const coderTheme = useCoderTheme(); - // Auto-scroll to first diff when the diff editor mounts and diffs are computed. - const handleDiffEditorMount = useCallback( - ( - editor: Monaco.editor.IStandaloneDiffEditor, - monacoInstance: typeof Monaco, - ) => { - // Call any existing onMount handler from editorProps. - editorProps?.onMount?.(editor, monacoInstance); - - // Diffs may already be computed by the time onMount fires, - // so check immediately first. If not ready yet, fall back - // to waiting for the onDidUpdateDiff event. - const scrollToFirstDiff = () => { - editor.goToDiff("next"); - }; - - const changes = editor.getLineChanges(); - if (changes && changes.length > 0) { - scrollToFirstDiff(); - return; - } - - const disposable = editor.onDidUpdateDiff(() => { - const updatedChanges = editor.getLineChanges(); - if (!updatedChanges || updatedChanges.length === 0) { - return; - } - disposable.dispose(); - scrollToFirstDiff(); - }); - }, - [editorProps], - ); - const commonProps = { language, theme: coderTheme.name, @@ -99,20 +71,102 @@ export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({ }} > {hasDiff ? ( - <DiffEditor - original={compareWith} - modified={value} - {...commonProps} - // Let the editor handle model cleanup. Without this, - // @monaco-editor/react disposes models before the - // DiffEditorWidget and throws an error. - keepCurrentOriginalModel - keepCurrentModifiedModel - onMount={handleDiffEditorMount} - /> + <DiffFile original={compareWith} modified={value} {...commonProps} /> ) : ( <Editor value={value} {...commonProps} /> )} </div> ); }; + +type DiffFileProps = CommonEditorProps & { + original: string; + modified: string; +}; + +// Renders the diff editor and owns its model cleanup. Scoping this to its own +// component means the cleanup effect runs whenever the diff editor unmounts, +// including when SyntaxHighlighter stays mounted but switches diff -> plain for +// a file that stopped changing between versions. +// +// keepCurrent{Original,Modified}Model stops @monaco-editor/react from disposing +// the models mid-teardown (which throws), so we dispose them ourselves after +// React has torn the editor down. Without this the models accumulate unbounded +// as users open template versions until the tab runs out of memory. +const DiffFile: FC<DiffFileProps> = ({ + original, + modified, + onMount, + ...editorProps +}) => { + const diffModelsRef = useRef<{ + original: Monaco.editor.ITextModel; + modified: Monaco.editor.ITextModel; + } | null>(null); + + const handleMount = useCallback( + ( + editor: Monaco.editor.IStandaloneDiffEditor, + monacoInstance: typeof Monaco, + ) => { + onMount?.(editor, monacoInstance); + + const diffModel = editor.getModel(); + diffModelsRef.current = diffModel + ? { original: diffModel.original, modified: diffModel.modified } + : null; + + // Auto-scroll to the first diff. Diffs may already be computed by the + // time onMount fires, so check immediately and otherwise wait for the + // onDidUpdateDiff event. + const scrollToFirstDiff = () => { + editor.goToDiff("next"); + }; + + const changes = editor.getLineChanges(); + if (changes && changes.length > 0) { + scrollToFirstDiff(); + return; + } + + const disposable = editor.onDidUpdateDiff(() => { + const updatedChanges = editor.getLineChanges(); + if (!updatedChanges || updatedChanges.length === 0) { + return; + } + disposable.dispose(); + scrollToFirstDiff(); + }); + }, + [onMount], + ); + + useEffect(() => { + return () => { + const models = diffModelsRef.current; + if (!models) { + return; + } + diffModelsRef.current = null; + // Defer disposal until after React's commit finishes. @monaco-editor/ + // react disposes the diff widget in its own unmount cleanup; freeing + // the models in the same synchronous teardown makes the widget throw + // "TextModel got disposed before DiffEditorWidget model got reset". + queueMicrotask(() => { + models.original.dispose(); + models.modified.dispose(); + }); + }; + }, []); + + return ( + <DiffEditor + original={original} + modified={modified} + {...editorProps} + keepCurrentOriginalModel + keepCurrentModifiedModel + onMount={handleMount} + /> + ); +}; diff --git a/site/src/modules/apps/apps.test.ts b/site/src/modules/apps/apps.test.ts index d9ac821cc8d3c..418efd9660dc7 100644 --- a/site/src/modules/apps/apps.test.ts +++ b/site/src/modules/apps/apps.test.ts @@ -96,6 +96,24 @@ describe("getAppHref", () => { expect(href).toBe("vscode://example.com?token=user-session-token"); }); + it("replaces the session token for Antigravity IDE URLs", () => { + const externalApp = { + ...MockWorkspaceApp, + external: true, + url: `antigravity-ide://coder.coder-remote/open?token=${SESSION_TOKEN_PLACEHOLDER}`, + }; + const href = getAppHref(externalApp, { + host: "*.apps-host.tld", + path: "/path-base", + agent: MockWorkspaceAgent, + workspace: MockWorkspace, + token: "user-session-token", + }); + expect(href).toBe( + "antigravity-ide://coder.coder-remote/open?token=user-session-token", + ); + }); + it("doesn't return the URL with the session token replaced when using the HTTP protocol", () => { const externalApp = { ...MockWorkspaceApp, diff --git a/site/src/modules/apps/apps.ts b/site/src/modules/apps/apps.ts index f987a992cda95..86dd2b30ff433 100644 --- a/site/src/modules/apps/apps.ts +++ b/site/src/modules/apps/apps.ts @@ -26,6 +26,7 @@ const ALLOWED_EXTERNAL_APP_PROTOCOLS = [ "kiro:", "positron:", "antigravity:", + "antigravity-ide:", ]; type GetVSCodeHrefParams = { diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 521c9086c5d25..ff48b732541e8 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -1,28 +1,25 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, screen, userEvent, waitFor, within } from "storybook/test"; import { meAISpendKey } from "#/api/queries/users"; -import type { - Experiment, - FeatureName, - UserAISpendStatus, -} from "#/api/typesGenerated"; +import type { FeatureName, UserAISpendStatus } from "#/api/typesGenerated"; import { MockBuildInfo, MockUserOwner } from "#/testHelpers/entities"; import { withDashboardProvider } from "#/testHelpers/storybook"; import { UserDropdown } from "./UserDropdown"; const mockAISpend: UserAISpendStatus = { user_id: MockUserOwner.id, - spend_limit_micros: 1_200_000_000, effective_group_id: "grp-789", - limit_source: "group", + effective_budget: { + spend_limit_micros: 1_200_000_000, + limit_source: "group", + }, current_spend_micros: 819_000_000, period_start: "2026-06-01T00:00:00Z", period_end: "2026-07-01T00:00:00Z", }; -const aiCostControl: { features: FeatureName[]; experiments: Experiment[] } = { +const aiCostControl: { features: FeatureName[] } = { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], }; const meta: Meta<typeof UserDropdown> = { @@ -58,7 +55,7 @@ const Example: Story = { queries: [{ key: meAISpendKey, data: mockAISpend }], }, play: async ({ canvasElement, step }) => { - await step("hides AI spend without cost control", async () => { + await step("hides AI spend without the aibridge feature", async () => { await openDropdown(canvasElement); expect(screen.queryByText(/AI spend/i)).not.toBeInTheDocument(); }); @@ -76,7 +73,7 @@ export const WithAISpend: Story = { await waitFor(() => expect(document.body).toHaveTextContent("$819 / $1,200 USD"), ); - expect(document.body).toHaveTextContent("(AI spend/month)"); + expect(document.body).toHaveTextContent("(Estimated AI spend/month)"); expect( screen.getByRole("progressbar", { name: "AI spend usage" }), ).toHaveAttribute("aria-valuenow", "68"); @@ -100,7 +97,7 @@ export const AISpendWarning: Story = { await waitFor(() => expect(document.body).toHaveTextContent("$1,080 / $1,200 USD"), ); - expect(document.body).toHaveTextContent("(AI spend/month)"); + expect(document.body).toHaveTextContent("(Estimated AI spend/month)"); expect( screen.getByRole("progressbar", { name: "AI spend usage" }), ).toHaveAttribute("aria-valuenow", "90"); @@ -147,7 +144,7 @@ export const AISpendExceeded: Story = { await waitFor(() => expect(document.body).toHaveTextContent("$1,500 / $1,200 USD"), ); - expect(document.body).toHaveTextContent("(AI spend/month)"); + expect(document.body).toHaveTextContent("(Estimated AI spend/month)"); expect( screen.getByRole("progressbar", { name: "AI spend usage" }), ).toHaveAttribute("aria-valuenow", "100"); @@ -159,7 +156,7 @@ export const AISpendUnlimited: Story = { parameters: { ...aiCostControl, queries: [ - { key: meAISpendKey, data: { ...mockAISpend, spend_limit_micros: null } }, + { key: meAISpendKey, data: { ...mockAISpend, effective_budget: null } }, ], }, play: async ({ canvasElement, step }) => { @@ -168,7 +165,7 @@ export const AISpendUnlimited: Story = { await waitFor(() => expect(document.body).toHaveTextContent("$819 / Unlimited USD"), ); - expect(document.body).toHaveTextContent("(AI spend/month)"); + expect(document.body).toHaveTextContent("(Estimated AI spend/month)"); expect( screen.queryByRole("progressbar", { name: "AI spend usage" }), ).not.toBeInTheDocument(); @@ -205,7 +202,10 @@ export const AISpendZeroLimit: Story = { data: { ...mockAISpend, current_spend_micros: 0, - spend_limit_micros: 0, + effective_budget: { + spend_limit_micros: 0, + limit_source: "group", + }, }, }, ], @@ -272,7 +272,7 @@ export const AISpendHiddenOnInvalidData: Story = { play: async ({ canvasElement, step }) => { await step("hides AI spend on invalid data", async () => { await openDropdown(canvasElement); - expect(screen.queryByText("(AI spend/month)")).not.toBeInTheDocument(); + expect(screen.queryByText(/spend\/month/)).not.toBeInTheDocument(); }); }, }; @@ -281,13 +281,19 @@ export const AISpendHiddenOnNegativeLimit: Story = { parameters: { ...aiCostControl, queries: [ - { key: meAISpendKey, data: { ...mockAISpend, spend_limit_micros: -1 } }, + { + key: meAISpendKey, + data: { + ...mockAISpend, + effective_budget: { spend_limit_micros: -1, limit_source: "group" }, + }, + }, ], }, play: async ({ canvasElement, step }) => { await step("hides AI spend on a negative limit", async () => { await openDropdown(canvasElement); - expect(screen.queryByText("(AI spend/month)")).not.toBeInTheDocument(); + expect(screen.queryByText(/spend\/month/)).not.toBeInTheDocument(); }); }, }; diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index a7470d826efed..2cc33a8fa03b3 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -9,7 +9,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { getSeverity, type UsageSeverity } from "#/utils/budget"; import { UserDropdownAISpend } from "./UserDropdownAISpend"; @@ -34,26 +33,23 @@ export const UserDropdown: FC<UserDropdownProps> = ({ supportLinks, onSignOut, }) => { - const { experiments } = useDashboard(); - // TODO(AIGOV-443): drop the experiment gate once cost control is stable. - const aibridgeVisible = - Boolean(useFeatureVisibility().aibridge) && - experiments.includes("ai-gateway-cost-control"); + const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); const { data, isError } = useQuery({ ...meAISpend(), enabled: aibridgeVisible, }); - // A null limit is unlimited and still shown. + // A null budget is unlimited and still shown. const hasValidSpend = data !== undefined && data.current_spend_micros >= 0 && - (data.spend_limit_micros === null || data.spend_limit_micros >= 0); + (data.effective_budget === null || + data.effective_budget.spend_limit_micros >= 0); const spend = aibridgeVisible && !isError && hasValidSpend ? { currentSpend: data.current_spend_micros, - spendLimit: data.spend_limit_micros, + spendLimit: data.effective_budget?.spend_limit_micros ?? null, } : null; const severity = diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownAISpend.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownAISpend.tsx index 2cff36afa4519..17e9816b7bcab 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownAISpend.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownAISpend.tsx @@ -31,7 +31,7 @@ export const UserDropdownAISpend: FC<UserDropdownAISpendProps> = ({ /> )} <div className="mt-1 text-xs text-content-secondary"> - (AI spend/month) + (Estimated AI spend/month) </div> </div> ); diff --git a/site/src/modules/groups.ts b/site/src/modules/groups.ts index ef58ea459b23e..673850e4390d2 100644 --- a/site/src/modules/groups.ts +++ b/site/src/modules/groups.ts @@ -1,10 +1,18 @@ -import type { - Group, - OrganizationMemberWithUserData, - ReducedUser, - User, - WorkspaceUser, +import { + type Group, + MaxAISpendLimitMicros, + type OrganizationMemberWithUserData, + type ReducedUser, + type User, + type WorkspaceUser, } from "#/api/typesGenerated"; +import { MICROS_PER_DOLLAR, usdBudgetFormatter } from "#/utils/currency"; + +/** Highest AI budget that can be configured for a group or member, in dollars. */ +export const maxAIBudgetDollars = MaxAISpendLimitMicros / MICROS_PER_DOLLAR; + +/** Shown when an entered AI budget falls outside the configurable range. */ +export const aiBudgetRangeError = `Enter an amount between 0 and ${usdBudgetFormatter.format(maxAIBudgetDollars)}.`; /** * Union of all user-like types that can be distinguished from Group. diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx index d0709b8ba8f84..6f36dd8b8e367 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx @@ -105,6 +105,7 @@ export const ChangeWorkspaceVersionDialog: FC< /> </ComboboxTrigger> <ComboboxContent + disablePortal className="max-w-none min-w-[min(100%,320px)]" align="start" > diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx index bdc65d47e895c..21299d54c36ae 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx @@ -86,7 +86,7 @@ export const ListSessionsPageView: FC<ListSessionsPageViewProps> = ({ <TableHead className="text-nowrap">Provider</TableHead> <TableHead className="text-nowrap">Client</TableHead> <TableHead className="text-nowrap">In/Out Tokens</TableHead> - <TableHead className="text-nowrap">Network Calls</TableHead> + <TableHead className="text-nowrap">Network Requests</TableHead> <TableHead className="flex items-center flex-nowrap gap-1"> Threads <ThreadTooltip> diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx index 62d9af8f4a887..f391d02042873 100644 --- a/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx @@ -49,7 +49,7 @@ export const TotalAndBlockedKeyboard: Story = { await userEvent.tab(); await waitFor(() => { const tooltip = screen.getByRole("tooltip"); - expect(tooltip).toHaveTextContent("Total calls"); + expect(tooltip).toHaveTextContent("Total requests"); expect(tooltip).toHaveTextContent("Blocked"); }); }, @@ -66,7 +66,7 @@ export const DisabledKeyboard: Story = { await userEvent.keyboard("{Enter}"); await waitFor(() => expect(screen.getByRole("dialog")).toHaveTextContent( - "Network call monitoring was not active for this session.", + "Network request monitoring was not active for this session.", ), ); }, diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx index a4e621a94d09f..2da53ba97cd45 100644 --- a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx @@ -2,36 +2,30 @@ import { BanIcon } from "lucide-react"; import type { FC } from "react"; import type { AIBridgeSessionNetworkCallSummary } from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; -import { InfoTooltip } from "#/components/InfoTooltip/InfoTooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { + NetworkMonitoringDisabled, + NetworkNoActivity, +} from "./NetworkRequestStates"; interface NetworkCallBadgesProps { - // summary is undefined when network call monitoring was not active for the - // session, which renders as "Disabled". + // summary is undefined when network request monitoring was not active for + // the session, which renders as "Disabled". summary: AIBridgeSessionNetworkCallSummary | undefined; } export const NetworkCallBadges: FC<NetworkCallBadgesProps> = ({ summary }) => { if (!summary) { - return ( - <span className="inline-flex items-center gap-1 whitespace-nowrap text-content-secondary"> - Disabled - <InfoTooltip message="Network call monitoring was not active for this session." /> - </span> - ); + return <NetworkMonitoringDisabled />; } if (summary.total === 0) { - return ( - <span className="whitespace-nowrap text-content-secondary"> - No activity - </span> - ); + return <NetworkNoActivity />; } return ( @@ -63,7 +57,7 @@ export const NetworkCallBadges: FC<NetworkCallBadgesProps> = ({ summary }) => { > <div className="flex flex-col gap-1"> <div className="flex items-center justify-between gap-4"> - <span className="text-content-secondary">Total calls</span> + <span className="text-content-secondary">Total requests</span> <span>{summary.total.toLocaleString("en-US")}</span> </div> <div className="flex items-center justify-between gap-4"> diff --git a/site/src/pages/AIBridgePage/NetworkRequestStates.tsx b/site/src/pages/AIBridgePage/NetworkRequestStates.tsx new file mode 100644 index 0000000000000..2e436003e82ad --- /dev/null +++ b/site/src/pages/AIBridgePage/NetworkRequestStates.tsx @@ -0,0 +1,17 @@ +import type { FC } from "react"; +import { InfoTooltip } from "#/components/InfoTooltip/InfoTooltip"; + +// Shared by the sessions list badges and the session detail summary card, which +// render the same two non-numeric states for a session's network requests but +// differ in how they present a live count. + +export const NetworkMonitoringDisabled: FC = () => ( + <span className="inline-flex items-center gap-1 whitespace-nowrap text-content-secondary"> + Disabled + <InfoTooltip message="Network request monitoring was not active for this session." /> + </span> +); + +export const NetworkNoActivity: FC = () => ( + <span className="whitespace-nowrap text-content-secondary">No activity</span> +); diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx index 10bc86d21b340..b3e851ee4d37d 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor } from "storybook/test"; import { MockSession } from "#/testHelpers/entities"; import { SessionSummaryTable } from "./SessionSummaryTable"; @@ -56,3 +57,83 @@ export const LargeTokenCounts: Story = { outputTokens: 32_000, }, }; + +// Session did not pass through Agent Firewall: monitoring was not active. +export const NetworkDisabled: Story = { + args: { + ...Default.args, + networkCalls: undefined, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Disabled")).toBeInTheDocument(); + await expect(canvas.queryByText("Blocked network requests")).toBeNull(); + await expect(canvas.queryByText("Top domains")).toBeNull(); + }, +}; + +// Tabbing to the disabled indicator's info button and pressing Enter reveals +// the reason without a mouse. +export const NetworkDisabledKeyboard: Story = { + args: { + ...Default.args, + networkCalls: undefined, + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => + expect(screen.getByRole("dialog")).toHaveTextContent( + "Network request monitoring was not active for this session.", + ), + ); + }, +}; + +// Firewall active but no egress recorded. +export const NetworkNoActivity: Story = { + args: { + ...Default.args, + networkCalls: { total: 0, blocked: 0 }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("No activity")).toBeInTheDocument(); + await expect(canvas.queryByText("Blocked network requests")).toBeNull(); + }, +}; + +// Egress recorded, some blocked, across several domains. +export const NetworkActivity: Story = { + args: { + ...Default.args, + networkCalls: { total: 7, blocked: 2 }, + networkDomains: { + topDomain: { domain: "api.github.com", count: 4 }, + totalCount: 14, + }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Network requests")).toBeInTheDocument(); + await expect(canvas.getByText("7")).toBeInTheDocument(); + await expect( + canvas.getByText("Blocked network requests"), + ).toBeInTheDocument(); + await expect(canvas.getByText("api.github.com")).toBeInTheDocument(); + await expect(canvas.getByText("+13 more")).toBeInTheDocument(); + }, +}; + +// A single domain contacted: no "+N more" overflow. +export const NetworkSingleDomain: Story = { + args: { + ...Default.args, + networkCalls: { total: 3, blocked: 0 }, + networkDomains: { + topDomain: { domain: "api.github.com", count: 3 }, + totalCount: 1, + }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("api.github.com")).toBeInTheDocument(); + await expect(canvas.queryByText(/more$/)).toBeNull(); + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx index 7d6feaf93dc20..389afad00c100 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx @@ -1,9 +1,19 @@ -import type { MinimalUser } from "#/api/typesGenerated"; +import { BanIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import type { + AIBridgeSessionNetworkCallSummary, + AIBridgeSessionNetworkDomain, + MinimalUser, +} from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { Badge } from "#/components/Badge/Badge"; import { AIBridgeClientIcon } from "#/pages/AIBridgePage/icons/AIBridgeClientIcon"; import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/icons/AIBridgeProviderIcon"; import { formatDateTime } from "#/utils/time"; +import { + NetworkMonitoringDisabled, + NetworkNoActivity, +} from "../NetworkRequestStates"; import { TokenBadges } from "../TokenBadges"; import { getProviderDisplayName } from "../utils"; @@ -21,6 +31,16 @@ interface SessionSummaryTableProps { threadCount: number; toolCallCount: number; tokenUsageMetadata?: Record<string, unknown>; + // networkCalls is undefined when the session did not pass through Agent + // Firewall, which renders as "Disabled". + networkCalls?: AIBridgeSessionNetworkCallSummary; + // networkDomains is undefined when the session contacted no destination + // hosts. totalCount is the number of distinct domains contacted, which + // renders as a "+N more" overflow beyond topDomain. + networkDomains?: { + readonly topDomain: AIBridgeSessionNetworkDomain; + readonly totalCount: number; + }; } export const SessionSummaryTable = ({ @@ -35,12 +55,25 @@ export const SessionSummaryTable = ({ threadCount, toolCallCount, tokenUsageMetadata, + networkCalls, + networkDomains, }: SessionSummaryTableProps) => { const durationInMs = endTime !== undefined ? new Date(endTime).getTime() - new Date(startTime).getTime() : undefined; + let networkCallsValue: ReactNode; + if (networkCalls === undefined) { + networkCallsValue = <NetworkMonitoringDisabled />; + } else if (networkCalls.total === 0) { + networkCallsValue = <NetworkNoActivity />; + } else { + networkCallsValue = ( + <Badge>{networkCalls.total.toLocaleString("en-US")}</Badge> + ); + } + return ( <dl className="text-sm text-content-secondary m-0 flex flex-col gap-y-2"> <div className="flex items-center justify-between"> @@ -163,6 +196,53 @@ export const SessionSummaryTable = ({ <Badge>{toolCallCount}</Badge> </dd> </div> + + <Separator /> + + <div className="flex items-center justify-between"> + <dt className="shrink-0 font-normal whitespace-nowrap"> + Network requests + </dt> + <dd className="ml-4 min-w-0 truncate text-content-primary"> + {networkCallsValue} + </dd> + </div> + + {networkCalls !== undefined && networkCalls.total > 0 && ( + <div className="flex items-center justify-between"> + <dt className="shrink-0 font-normal whitespace-nowrap"> + Blocked network requests + </dt> + <dd className="ml-4 min-w-0 truncate text-content-primary"> + {networkCalls.blocked > 0 ? ( + <Badge svgSize="xs" className="gap-1 text-content-warning"> + <BanIcon className="flex-shrink-0" /> + {networkCalls.blocked.toLocaleString("en-US")} + </Badge> + ) : ( + <Badge>{networkCalls.blocked.toLocaleString("en-US")}</Badge> + )} + </dd> + </div> + )} + + {networkDomains !== undefined && ( + <div className="flex items-start justify-between"> + <dt className="shrink-0 font-normal whitespace-nowrap mt-px"> + Top domains + </dt> + <dd className="ml-4 min-w-0 text-content-primary text-right"> + <div className="truncate" title={networkDomains.topDomain.domain}> + {networkDomains.topDomain.domain} + </div> + {networkDomains.totalCount > 1 && ( + <div className="text-content-secondary text-xs"> + +{(networkDomains.totalCount - 1).toLocaleString("en-US")} more + </div> + )} + </dd> + </div> + )} </dl> ); }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx index 4157c12d88786..ba12f4eca139c 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx @@ -75,6 +75,10 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({ 0, ); + // The API returns only the single most contacted host, alongside the total + // distinct domain count that drives the "+N more" overflow. + const topDomain = session?.network_top_domains?.[0]; + return ( <> <nav className="mb-6"> @@ -115,6 +119,13 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({ threadCount={threads.length} toolCallCount={toolCallCount} tokenUsageMetadata={session.token_usage_summary.metadata} + networkCalls={session.network_calls} + networkDomains={ + topDomain && { + topDomain, + totalCount: session.network_domain_count ?? 1, + } + } /> )} </aside> @@ -123,6 +134,8 @@ export const SessionThreadsPageView: FC<SessionThreadsPageViewProps> = ({ <SessionTimeline initiator={session.initiator} threads={threads} + networkCallSummary={session.network_calls} + networkCalls={session.network_call_logs ?? []} hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onFetchNextPage={onFetchNextPage} diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx new file mode 100644 index 0000000000000..de612d7036cd7 --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx @@ -0,0 +1,127 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent } from "storybook/test"; +import { MockAIBridgeSessionNetworkCalls } from "#/testHelpers/entities"; +import { NetworkCallsTable } from "./NetworkCallsTable"; + +const meta: Meta<typeof NetworkCallsTable> = { + title: "pages/AIBridgePage/NetworkCallsTable", + component: NetworkCallsTable, + args: { + summary: { total: 4, blocked: 2 }, + calls: MockAIBridgeSessionNetworkCalls, + }, +}; + +export default meta; +type Story = StoryObj<typeof NetworkCallsTable>; + +export const Default: Story = { + play: async ({ canvas }) => { + await canvas.findByText("Network calls (4)"); + await expect(canvas.getAllByText("Allowed")).toHaveLength(2); + await expect(canvas.getAllByText("Blocked")).toHaveLength(2); + await expect( + canvas.getByText("https://registry.npmjs.org/lodash"), + ).toBeInTheDocument(); + }, +}; + +// The header badge counts every blocked call in the session, so it can exceed +// the number of blocked rows on screen once the list is capped. +export const BlockedBadge: Story = { + args: { + summary: { total: 4, blocked: 9 }, + }, + play: async ({ canvas }) => { + await expect( + canvas.getByText("Blocked network calls: 9"), + ).toBeInTheDocument(); + await expect(canvas.getAllByText("Blocked")).toHaveLength(2); + }, +}; + +export const NoBlockedCalls: Story = { + args: { + summary: { total: 1, blocked: 0 }, + calls: [MockAIBridgeSessionNetworkCalls[0]], + }, + play: async ({ canvas }) => { + await canvas.findByText("Network calls (1)"); + await expect(canvas.queryByText("Blocked")).not.toBeInTheDocument(); + }, +}; + +export const ExpandRow: Story = { + play: async ({ canvas }) => { + await expect(canvas.queryByText("Protocol")).not.toBeInTheDocument(); + + await userEvent.click( + canvas.getByRole("button", { + name: /https:\/\/api\.github\.com\/repos\/coder\/coder/, + }), + ); + + await expect(canvas.getByText("Protocol")).toBeInTheDocument(); + await expect(canvas.getByText("Matched rule")).toBeInTheDocument(); + }, +}; + +export const CollapsePanel: Story = { + play: async ({ canvas }) => { + await expect( + canvas.getByText("https://api.github.com/repos/coder/coder"), + ).toBeVisible(); + + await userEvent.click(canvas.getByText("Network calls (4)")); + + await expect( + canvas.queryByText("https://api.github.com/repos/coder/coder"), + ).not.toBeInTheDocument(); + }, +}; + +export const Empty: Story = { + args: { + summary: { total: 0, blocked: 0 }, + calls: [], + }, + play: async ({ canvas }) => { + await canvas.findByText("Network calls (0)"); + await expect( + canvas.getByText("No network calls were recorded for this session."), + ).toBeInTheDocument(); + }, +}; + +// A summary total with no rows to show is not a state the server produces. The +// panel still shows a single message rather than claiming both that nothing was +// recorded and that the list was capped. +export const EmptyListWithSummaryTotal: Story = { + args: { + summary: { total: 4, blocked: 2 }, + calls: [], + }, + play: async ({ canvas }) => { + await expect( + canvas.getByText("No network calls were recorded for this session."), + ).toBeInTheDocument(); + await expect( + canvas.queryByText(/Showing the first/), + ).not.toBeInTheDocument(); + }, +}; + +// When the session has more calls than the server returns, the panel notes +// how many are shown. +export const Truncated: Story = { + args: { + summary: { total: 150, blocked: 2 }, + calls: MockAIBridgeSessionNetworkCalls, + }, + play: async ({ canvas }) => { + await canvas.findByText("Network calls (150)"); + await expect( + canvas.getByText(/Showing the first 4 of 150 network calls\./), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx new file mode 100644 index 0000000000000..d6a9cdc38b05a --- /dev/null +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx @@ -0,0 +1,189 @@ +import { BanIcon, CheckIcon, ChevronRightIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import type { + AgentFirewallLog, + AIBridgeSessionNetworkCallSummary, +} from "#/api/typesGenerated"; +import { Badge } from "#/components/Badge/Badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "#/components/Collapsible/Collapsible"; +import { CopyButton } from "#/components/CopyButton/CopyButton"; +import { formatDateTime } from "#/utils/time"; + +interface NetworkCallsTableProps { + /** + * Drives the header count and blocked badge. Reflects the whole session, so + * its total can exceed the number of rows in `calls`, which is capped + * server-side. + */ + summary: AIBridgeSessionNetworkCallSummary; + calls: readonly AgentFirewallLog[]; +} + +export const NetworkCallsTable: FC<NetworkCallsTableProps> = ({ + summary, + calls, +}) => ( + <Collapsible defaultOpen className="border border-solid rounded-md"> + <div className="flex items-center justify-between gap-2 px-2 py-1"> + <CollapsibleTrigger asChild> + <button + type="button" + className="group flex items-center gap-4 p-1 bg-transparent border-none cursor-pointer text-sm font-normal text-content-secondary" + > + <ChevronRightIcon className="size-3.5 transition-transform group-data-[state=open]:rotate-90" /> + <span>Network calls ({summary.total.toLocaleString("en-US")})</span> + </button> + </CollapsibleTrigger> + {summary.blocked > 0 && ( + <Badge svgSize="xs" className="gap-1 text-content-warning"> + <BanIcon className="flex-shrink-0" /> + <span className="sr-only">Blocked network calls: </span> + {summary.blocked.toLocaleString("en-US")} + </Badge> + )} + </div> + + <CollapsibleContent className="border-0 border-t border-solid"> + <NetworkCallsList summary={summary} calls={calls} /> + </CollapsibleContent> + </Collapsible> +); + +const NetworkCallsList: FC<NetworkCallsTableProps> = ({ summary, calls }) => { + if (calls.length === 0) { + return ( + <p className="m-0 px-4 py-3 text-sm font-normal text-content-secondary"> + No network calls were recorded for this session. + </p> + ); + } + + const hiddenCount = summary.total - calls.length; + + return ( + <> + <ul className="m-0 p-0 list-none"> + {calls.map((call) => ( + <NetworkCallRow key={call.id} call={call} /> + ))} + </ul> + {hiddenCount > 0 && ( + <p className="m-0 px-4 py-2 text-xs font-normal text-content-secondary border-0 border-t border-solid"> + Showing the first {calls.length.toLocaleString("en-US")} of{" "} + {summary.total.toLocaleString("en-US")} network calls. + </p> + )} + </> + ); +}; + +interface NetworkCallRowProps { + call: AgentFirewallLog; +} + +const NetworkCallRow: FC<NetworkCallRowProps> = ({ call }) => { + const timestamp = formatDateTime(new Date(call.created_at)); + + return ( + <li className="border-0 border-t border-solid first:border-t-0"> + <Collapsible> + <CollapsibleTrigger asChild> + <button + type="button" + className="group flex items-center gap-3 w-full px-2 py-2 text-left bg-transparent border-none cursor-pointer hover:bg-surface-secondary" + > + <ChevronRightIcon className="size-3.5 flex-shrink-0 text-content-secondary transition-transform group-data-[state=open]:rotate-90" /> + {call.method && ( + <Badge size="sm" className="flex-shrink-0 font-mono"> + {call.method} + </Badge> + )} + <NetworkCallStatusBadge allowed={call.allowed} /> + <span + className="flex-1 min-w-0 truncate font-mono text-xs text-content-primary" + title={call.detail} + > + {call.detail || "N/A"} + </span> + <span className="hidden md:flex items-center gap-2 flex-shrink-0 text-sm font-normal text-content-secondary"> + Timestamp + <span className="font-mono text-xs text-content-primary"> + {timestamp} + </span> + </span> + </button> + </CollapsibleTrigger> + + <CollapsibleContent> + <dl className="flex flex-col gap-2 m-0 px-9 pb-3 text-sm font-normal text-content-secondary"> + <NetworkCallDetailRow label="URL"> + <span + className="min-w-0 truncate font-mono text-xs text-content-primary" + title={call.detail} + > + {call.detail || "N/A"} + </span> + {call.detail && ( + <CopyButton text={call.detail} label="Copy network call URL" /> + )} + </NetworkCallDetailRow> + <NetworkCallDetailRow label="Protocol"> + <span className="font-mono text-xs text-content-primary"> + {call.proto || "N/A"} + </span> + </NetworkCallDetailRow> + <NetworkCallDetailRow label="Matched rule"> + <span + className="min-w-0 truncate font-mono text-xs text-content-primary" + title={call.matched_rule ?? undefined} + > + {call.matched_rule ?? "None"} + </span> + </NetworkCallDetailRow> + <NetworkCallDetailRow label="Timestamp"> + <span className="font-mono text-xs text-content-primary"> + {timestamp} + </span> + </NetworkCallDetailRow> + </dl> + </CollapsibleContent> + </Collapsible> + </li> + ); +}; + +const NetworkCallStatusBadge: FC<{ allowed: boolean }> = ({ allowed }) => + allowed ? ( + <Badge size="sm" svgSize="xs" className="flex-shrink-0 gap-1"> + <CheckIcon className="flex-shrink-0" /> + Allowed + </Badge> + ) : ( + <Badge + size="sm" + svgSize="xs" + className="flex-shrink-0 gap-1 text-content-warning" + > + <BanIcon className="flex-shrink-0" /> + Blocked + </Badge> + ); + +interface NetworkCallDetailRowProps { + label: string; + children: ReactNode; +} + +const NetworkCallDetailRow: FC<NetworkCallDetailRowProps> = ({ + label, + children, +}) => ( + <div className="flex items-center justify-between gap-4"> + <dt className="shrink-0 whitespace-nowrap">{label}</dt> + <dd className="flex items-center gap-2 m-0 min-w-0">{children}</dd> + </div> +); diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx index 7beb04b4c6728..8b6a6836a10b9 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; import type { AIBridgeThread } from "#/api/typesGenerated"; -import { MockSession } from "#/testHelpers/entities"; +import { + MockAIBridgeSessionNetworkCalls, + MockSession, +} from "#/testHelpers/entities"; import { SessionTimeline } from "./SessionTimeline"; // A thread with one thinking block and one tool call. @@ -124,6 +128,7 @@ const meta: Meta<typeof SessionTimeline> = { args: { initiator: MockSession.initiator, threads: [mockThread], + networkCalls: [], hasNextPage: false, isFetchingNextPage: false, onFetchNextPage: noop, @@ -135,6 +140,22 @@ type Story = StoryObj<typeof SessionTimeline>; export const OneThread: Story = {}; +// A summary is present only for sessions that passed through Agent Firewall. +// The panel sits above the threads because its counts are session-scoped +// rather than tied to any one thread. +export const WithNetworkCalls: Story = { + args: { + networkCallSummary: { total: 4, blocked: 2 }, + networkCalls: MockAIBridgeSessionNetworkCalls, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Network calls (4)")).toBeInTheDocument(); + await expect( + canvas.getByText("https://api.github.com/repos/coder/coder"), + ).toBeInTheDocument(); + }, +}; + export const MultipleThreads: Story = { args: { threads: [mockThread, mockThreadLong] }, }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx index 7154e1ca7a567..b1214fc84e4ed 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/SessionTimeline.tsx @@ -1,7 +1,9 @@ import { ChevronRightIcon, InfoIcon, LoaderIcon } from "lucide-react"; import { type FC, useEffect, useRef, useState } from "react"; import type { + AgentFirewallLog, AIBridgeAgenticAction, + AIBridgeSessionNetworkCallSummary, AIBridgeThread, MinimalUser, } from "#/api/typesGenerated"; @@ -21,6 +23,7 @@ import { cn } from "#/utils/cn"; import { docs } from "#/utils/docs"; import { JsonPrettyPrinter } from "../../JsonPrettyPrinter"; import { AgenticLoopTable } from "./AgenticLoopTable"; +import { NetworkCallsTable } from "./NetworkCallsTable"; import { PromptTable } from "./PromptTable"; import { ToolCallTable } from "./ToolCallTable"; @@ -408,6 +411,12 @@ const ThreadItem: FC<ThreadItemProps> = ({ thread, initiator }) => { interface SessionTimelineProps { initiator: MinimalUser; threads: readonly AIBridgeThread[]; + /** + * Undefined when the session did not pass through Agent Firewall, in which + * case the network calls panel is not rendered. + */ + networkCallSummary?: AIBridgeSessionNetworkCallSummary; + networkCalls: readonly AgentFirewallLog[]; hasNextPage: boolean; isFetchingNextPage: boolean; onFetchNextPage: () => void; @@ -416,6 +425,8 @@ interface SessionTimelineProps { export const SessionTimeline: FC<SessionTimelineProps> = ({ initiator, threads, + networkCallSummary, + networkCalls, hasNextPage, isFetchingNextPage, onFetchNextPage, @@ -524,6 +535,14 @@ export const SessionTimeline: FC<SessionTimelineProps> = ({ {/* left vertical line */} </div> <div className="row-start-5 col-start-2 col-span-4"> + {networkCallSummary && ( + <div className="mb-4"> + <NetworkCallsTable + summary={networkCallSummary} + calls={networkCalls} + /> + </div> + )} {/* threads */} <div className="[&>.thread-gap:last-child]:hidden"> {threads.map((thread) => ( diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx index 19306a7cc98df..1acc4dade0fd2 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx @@ -1,18 +1,8 @@ import dayjs from "dayjs"; import { type FC, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useQuery } from "react-query"; import { useSearchParams } from "react-router"; -import { - chatCostSummary, - chatUsageLimitConfig, - deleteChatUsageLimitGroupOverride, - deleteChatUsageLimitOverride, - paginatedChatCostUsers, - updateChatUsageLimitConfig, - upsertChatUsageLimitGroupOverride, - upsertChatUsageLimitOverride, -} from "#/api/queries/chats"; -import { groups } from "#/api/queries/groups"; +import { chatCostSummary, paginatedChatCostUsers } from "#/api/queries/chats"; import { user } from "#/api/queries/users"; import type { ChatCostUserRollup } from "#/api/typesGenerated"; import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker"; @@ -25,7 +15,6 @@ import { toExclusiveEndOfDayDateRange } from "./utils/dateRange"; const startDateSearchParam = "startDate"; const endDateSearchParam = "endDate"; -const tabSearchParam = "tab"; const DEFAULT_DATE_RANGE_DAYS = 30; const SEARCH_DEBOUNCE_MS = 300; const USAGE_USERS_PAGE_SIZE = 10; @@ -44,33 +33,10 @@ interface SpendPageProps { const SpendPage: FC<SpendPageProps> = ({ now }) => { const { permissions } = useAuthenticated(); - const queryClient = useQueryClient(); - - const configQuery = useQuery(chatUsageLimitConfig()); - const groupsQuery = useQuery(groups()); - - const updateConfigMutation = useMutation( - updateChatUsageLimitConfig(queryClient), - ); - const upsertOverrideMutation = useMutation( - upsertChatUsageLimitOverride(queryClient), - ); - const deleteOverrideMutation = useMutation( - deleteChatUsageLimitOverride(queryClient), - ); - const upsertGroupOverrideMutation = useMutation( - upsertChatUsageLimitGroupOverride(queryClient), - ); - const deleteGroupOverrideMutation = useMutation( - deleteChatUsageLimitGroupOverride(queryClient), - ); - const [searchParams, setSearchParams] = useSearchParams(); const searchFilter = searchParams.get("search") ?? ""; const debouncedSearch = useDebouncedValue(searchFilter, SEARCH_DEBOUNCE_MS); - const tabParam = searchParams.get(tabSearchParam); - const activeTab = tabParam === "usage" ? "usage" : "limits"; const setSearchFilter = (value: string) => { setSearchParams( @@ -117,21 +83,6 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => { end_date: dateRange.endDate.toISOString(), }; - const onActiveTabChange = (tab: "limits" | "usage") => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - if (tab === "usage") { - next.set(tabSearchParam, tab); - } else { - next.delete(tabSearchParam); - } - return next; - }, - { replace: true }, - ); - }; - const onDateRangeChange = (value: DateRangeValue) => { const nextDateRange = toExclusiveEndOfDayDateRange(value); @@ -170,51 +121,6 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => { return ( <RequirePermission isFeatureVisible={permissions.editDeploymentConfig}> <SpendPageView - configData={configQuery.data} - isLoadingConfig={configQuery.isLoading} - configError={configQuery.isError ? configQuery.error : null} - refetchConfig={() => void configQuery.refetch()} - groupsData={groupsQuery.data} - isLoadingGroups={groupsQuery.isLoading} - groupsError={groupsQuery.isError ? groupsQuery.error : null} - onUpdateConfig={(req, options) => { - updateConfigMutation.mutate(req, { - onSuccess: options?.onSuccess, - }); - }} - isUpdatingConfig={updateConfigMutation.isPending} - updateConfigError={ - updateConfigMutation.isError ? updateConfigMutation.error : null - } - resetUpdateConfig={updateConfigMutation.reset} - onUpsertOverride={({ userID, req, onSuccess }) => - upsertOverrideMutation.mutate({ userID, req }, { onSuccess }) - } - isUpsertingOverride={upsertOverrideMutation.isPending} - upsertOverrideError={ - upsertOverrideMutation.isError ? upsertOverrideMutation.error : null - } - onDeleteOverride={deleteOverrideMutation.mutate} - isDeletingOverride={deleteOverrideMutation.isPending} - deleteOverrideError={ - deleteOverrideMutation.isError ? deleteOverrideMutation.error : null - } - onUpsertGroupOverride={({ groupID, req, onSuccess }) => - upsertGroupOverrideMutation.mutate({ groupID, req }, { onSuccess }) - } - isUpsertingGroupOverride={upsertGroupOverrideMutation.isPending} - upsertGroupOverrideError={ - upsertGroupOverrideMutation.isError - ? upsertGroupOverrideMutation.error - : null - } - onDeleteGroupOverride={deleteGroupOverrideMutation.mutate} - isDeletingGroupOverride={deleteGroupOverrideMutation.isPending} - deleteGroupOverrideError={ - deleteGroupOverrideMutation.isError - ? deleteGroupOverrideMutation.error - : null - } dateRange={dateRange} endDateIsExclusive={endDateIsExclusive} onDateRangeChange={onDateRangeChange} @@ -245,8 +151,6 @@ const SpendPage: FC<SpendPageProps> = ({ now }) => { isSummaryLoading={summaryQuery.isLoading} summaryError={summaryQuery.error} onSummaryRetry={() => void summaryQuery.refetch()} - activeTab={activeTab} - onActiveTabChange={onActiveTabChange} /> </RequirePermission> ); diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx index 47dd6ffa61182..83345076a61f4 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useState } from "react"; import { expect, fn, userEvent, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer"; @@ -100,68 +99,6 @@ const mockCostSummary = { ], } as TypesGen.ChatCostSummary; -const mockConfigData = { - spend_limit_micros: 50_000_000, - period: "month", - updated_at: "2026-03-01T00:00:00Z", - unpriced_model_count: 0, - overrides: [ - { - user_id: "user-3", - username: "dave", - name: "Dave Grohl", - avatar_url: "", - spend_limit_micros: 100_000_000, - }, - { - user_id: "user-4", - username: "charlie", - name: "Charlie Chaplin", - avatar_url: "", - spend_limit_micros: 25_000_000, - }, - ], - group_overrides: [ - { - group_id: "group-1", - group_name: "engineering", - group_display_name: "Engineering", - group_avatar_url: "", - member_count: 12, - spend_limit_micros: 75_000_000, - }, - ], -} as TypesGen.ChatUsageLimitConfigResponse; - -const mockGroups = [ - { - id: "group-1", - name: "engineering", - display_name: "Engineering", - organization_id: "org-1", - members: [], - total_member_count: 12, - avatar_url: "", - quota_allowance: 0, - source: "user", - organization_name: "default", - organization_display_name: "Default", - }, - { - id: "group-2", - name: "design", - display_name: "Design", - organization_id: "org-1", - members: [], - total_member_count: 5, - avatar_url: "", - quota_allowance: 0, - source: "user", - organization_name: "default", - organization_display_name: "Default", - }, -] as TypesGen.Group[]; - const defaultDateRange = { startDate: new Date("2026-02-10T00:00:00Z"), endDate: new Date("2026-03-12T00:00:00Z"), @@ -219,22 +156,6 @@ function mockUsersQuery( } const baseProps = { - configData: undefined as TypesGen.ChatUsageLimitConfigResponse | undefined, - isLoadingConfig: false, - configError: null as Error | null, - groupsData: undefined as TypesGen.Group[] | undefined, - isLoadingGroups: false, - groupsError: null as Error | null, - isUpdatingConfig: false, - updateConfigError: null as Error | null, - isUpsertingOverride: false, - upsertOverrideError: null as Error | null, - isDeletingOverride: false, - deleteOverrideError: null as Error | null, - isUpsertingGroupOverride: false, - upsertGroupOverrideError: null as Error | null, - isDeletingGroupOverride: false, - deleteGroupOverrideError: null as Error | null, dateRange: defaultDateRange, endDateIsExclusive: false, searchFilter: "", @@ -254,73 +175,22 @@ const meta = { component: SpendPageView, // TODO: Stories in this file fail when pixel runs their play functions. Fix them and remove the exclude. parameters: { pixel: { exclude: true } }, - render: (args) => { - const [activeTab, setActiveTab] = useState(args.activeTab); - return ( - <SpendPageView - {...args} - activeTab={activeTab} - onActiveTabChange={(tab) => { - setActiveTab(tab); - args.onActiveTabChange(tab); - }} - /> - ); - }, args: { ...baseProps, - refetchConfig: fn(), - onUpdateConfig: fn(), - resetUpdateConfig: fn(), - onUpsertOverride: fn(), - onDeleteOverride: fn(), - onUpsertGroupOverride: fn(), - onDeleteGroupOverride: fn(), onDateRangeChange: fn(), onSearchFilterChange: fn(), onDrillInUserRetry: fn(), onClearSelectedUser: fn(), onSelectUser: fn(), onSummaryRetry: fn(), - activeTab: "limits", - onActiveTabChange: fn(), }, } satisfies Meta<typeof SpendPageView>; export default meta; type Story = StoryObj<typeof SpendPageView>; -export const SpendWithLimitsAndUsers: Story = { - args: { - configData: mockConfigData, - groupsData: mockGroups, - usersQuery: mockUsersQuery({ data: mockUsersResponse }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText("Spend limits and usage"); - await expect( - canvas.getByRole("switch", { name: "Spend limit" }), - ).toBeInTheDocument(); - await expect(canvas.getByText("Group limits")).toBeInTheDocument(); - await expect(canvas.getByText("Usage")).toBeInTheDocument(); - - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - - await expect(await canvas.findByText("Alice Liddell")).toBeInTheDocument(); - await expect(canvas.getByText("Bob Builder")).toBeInTheDocument(); - - await expect( - canvas.getByPlaceholderText("Search by name or username"), - ).toBeInTheDocument(); - }, -}; - export const SpendUsersEmpty: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ data: { start_date: "2026-02-10T00:00:00Z", @@ -333,8 +203,7 @@ export const SpendUsersEmpty: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByText("Spend limits and usage"); - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); + await canvas.findByText("AI spend usage"); await expect( await canvas.findByText("No usage data for this period."), ).toBeInTheDocument(); @@ -343,8 +212,6 @@ export const SpendUsersEmpty: Story = { export const SpendUserDrillIn: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: mockUserProfile, summaryData: mockCostSummary, @@ -362,8 +229,6 @@ export const SpendUserDrillIn: Story = { export const SpendUserDrillInAndBack: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: mockUserProfile, summaryData: mockCostSummary, @@ -381,8 +246,6 @@ export const SpendUserDrillInAndBack: Story = { export const SpendDrillInLoading: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: null, isDrillInUserLoading: true, @@ -397,8 +260,6 @@ export const SpendDrillInLoading: Story = { export const SpendDrillInError: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: null, isDrillInUserError: true, @@ -413,8 +274,6 @@ export const SpendDrillInError: Story = { export const SpendRefetchOverlay: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ data: mockUsersResponse, isFetching: true, @@ -423,8 +282,6 @@ export const SpendRefetchOverlay: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - await canvas.findByText("Alice Liddell"); await expect( @@ -433,43 +290,13 @@ export const SpendRefetchOverlay: Story = { }, }; -export const SpendConfigLoading: Story = { - args: { - isLoadingConfig: true, - }, -}; - -export const SpendConfigError: Story = { - args: { - configError: new Error("Network error: failed to fetch config"), - usersQuery: mockUsersQuery({ data: mockUsersResponse }), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText("Network error: failed to fetch config"); - await expect(canvas.getByText("Retry")).toBeInTheDocument(); - - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - - await expect(canvas.getByText("Alice Liddell")).toBeInTheDocument(); - await expect(canvas.getByText("Bob Builder")).toBeInTheDocument(); - }, -}; - export const SpendUsersLoading: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ isLoading: true }), }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByRole("switch", { name: "Spend limit" }); - - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - await expect( await canvas.findByRole("status", { name: "Loading usage" }), ).toBeInTheDocument(); @@ -478,8 +305,6 @@ export const SpendUsersLoading: Story = { export const SpendUsersError: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ error: new Error("Failed to load usage data"), }), @@ -487,10 +312,6 @@ export const SpendUsersError: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await canvas.findByRole("switch", { name: "Spend limit" }); - - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - await expect( canvas.getByText("Failed to load usage data"), ).toBeInTheDocument(); @@ -500,15 +321,11 @@ export const SpendUsersError: Story = { export const SpendUserClickToDrillIn: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ data: mockUsersResponse }), }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("tab", { name: "Usage" })); - const row = await canvas.findByRole("button", { name: /^View details for Alice Liddell/, }); diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx index eb044738786ee..773af71f2c5cf 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx @@ -1,8 +1,5 @@ import type { FC } from "react"; -import { getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Button } from "#/components/Button/Button"; import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker"; import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer"; import { @@ -10,68 +7,11 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "#/components/Tabs/Tabs"; -import { - TemporarySavedState, - useTemporarySavedState, -} from "#/components/TemporarySavedState/TemporarySavedState"; -import { dollarsToMicros, microsToDollars } from "#/utils/currency"; -import { - DefaultLimitController, - type DefaultLimitFormValues, -} from "./components/LimitsTab/DefaultLimitController"; -import { DefaultLimitSection } from "./components/LimitsTab/DefaultLimitSection"; -import { GroupLimitsSection } from "./components/LimitsTab/GroupLimitsSection"; -import { GroupOverrideController } from "./components/LimitsTab/GroupOverrideController"; -import { normalizeChatUsageLimitPeriod } from "./components/LimitsTab/limitsFormLogic"; -import { UserOverrideController } from "./components/LimitsTab/UserOverrideController"; -import { UserOverridesSection } from "./components/LimitsTab/UserOverridesSection"; import { SpendDrillInView } from "./components/SpendDrillInView"; -import { SpendSectionHeader } from "./components/SpendSectionHeader"; import { UsageTab } from "./components/UsageTab/UsageTab"; import { formatUsageDateRange, toInclusiveDateRange } from "./utils/dateRange"; interface SpendPageViewProps { - configData: TypesGen.ChatUsageLimitConfigResponse | undefined; - isLoadingConfig: boolean; - configError: Error | null; - refetchConfig: () => void; - groupsData: TypesGen.Group[] | undefined; - isLoadingGroups: boolean; - groupsError: Error | null; - onUpdateConfig: ( - req: TypesGen.ChatUsageLimitConfig, - options?: { onSuccess?: () => void }, - ) => void; - isUpdatingConfig: boolean; - updateConfigError: Error | null; - resetUpdateConfig: () => void; - onUpsertOverride: (args: { - userID: string; - req: TypesGen.UpsertChatUsageLimitOverrideRequest; - onSuccess: () => void; - }) => void; - isUpsertingOverride: boolean; - upsertOverrideError: Error | null; - onDeleteOverride: (userID: string) => void; - isDeletingOverride: boolean; - deleteOverrideError: Error | null; - onUpsertGroupOverride: (args: { - groupID: string; - req: TypesGen.UpsertChatUsageLimitGroupOverrideRequest; - onSuccess: () => void; - }) => void; - isUpsertingGroupOverride: boolean; - upsertGroupOverrideError: Error | null; - onDeleteGroupOverride: (groupID: string) => void; - isDeletingGroupOverride: boolean; - deleteGroupOverrideError: Error | null; dateRange: DateRangeValue; endDateIsExclusive: boolean; onDateRangeChange: (value: DateRangeValue) => void; @@ -96,34 +36,9 @@ interface SpendPageViewProps { isSummaryLoading: boolean; summaryError: unknown; onSummaryRetry: () => void; - activeTab: "limits" | "usage"; - onActiveTabChange: (tab: "limits" | "usage") => void; } export const SpendPageView: FC<SpendPageViewProps> = ({ - configData, - isLoadingConfig, - configError, - refetchConfig, - groupsData, - isLoadingGroups, - groupsError, - onUpdateConfig, - isUpdatingConfig, - updateConfigError, - resetUpdateConfig, - onUpsertOverride, - isUpsertingOverride, - upsertOverrideError, - onDeleteOverride, - isDeletingOverride, - deleteOverrideError, - onUpsertGroupOverride, - isUpsertingGroupOverride, - upsertGroupOverrideError, - onDeleteGroupOverride, - isDeletingGroupOverride, - deleteGroupOverrideError, dateRange, endDateIsExclusive, onDateRangeChange, @@ -142,63 +57,12 @@ export const SpendPageView: FC<SpendPageViewProps> = ({ isSummaryLoading, summaryError, onSummaryRetry, - activeTab, - onActiveTabChange, }) => { - const defaultLimitValues: DefaultLimitFormValues = (() => { - const spendLimitMicros = configData?.spend_limit_micros; - const enabled = spendLimitMicros !== null && spendLimitMicros !== undefined; - - return { - enabled, - period: normalizeChatUsageLimitPeriod(configData?.period), - amountDollars: enabled - ? microsToDollars(spendLimitMicros).toString() - : "", - }; - })(); - const defaultLimitKey = JSON.stringify({ - spend_limit_micros: configData?.spend_limit_micros ?? null, - period: defaultLimitValues.period, - }); - const displayDateRange = toInclusiveDateRange(dateRange, endDateIsExclusive); const dateRangeLabel = formatUsageDateRange(dateRange, { endDateIsExclusive, }); - const handleResetUpdateConfig = () => { - if (!isUpdatingConfig) { - resetUpdateConfig(); - } - }; - - const handleSaveDefault = ({ - enabled, - period, - amountDollars, - }: DefaultLimitFormValues) => { - const spendLimitMicros = enabled ? dollarsToMicros(amountDollars) : null; - onUpdateConfig( - { - spend_limit_micros: spendLimitMicros, - period, - updated_at: new Date().toISOString(), - }, - { onSuccess: showSavedState }, - ); - }; - - const groupOverrides = configData?.group_overrides ?? []; - const overrides = configData?.overrides ?? []; - const unpricedModelCount = configData?.unpriced_model_count ?? 0; - const groupOrganizationNames = - groupsData?.reduce<Record<string, string | undefined>>((acc, group) => { - acc[group.id] = group.organization_name; - return acc; - }, {}) ?? {}; - const { isSavedVisible, showSavedState } = useTemporarySavedState(); - if (drillInUserId) { return ( <SpendDrillInView @@ -218,224 +82,24 @@ export const SpendPageView: FC<SpendPageViewProps> = ({ /> ); } - return ( - <GroupOverrideController - groupOverrides={groupOverrides} - groups={groupsData ?? []} - isLoadingGroups={isLoadingGroups} - onUpsertGroupOverride={onUpsertGroupOverride} - > - {(groupCtrl) => ( - <UserOverrideController - overrides={overrides} - onUpsertOverride={onUpsertOverride} - > - {(userCtrl) => ( - <div className="flex max-w-[1100px] flex-col gap-8"> - <SettingsHeader> - <SettingsHeaderTitle> - Spend limits and usage - </SettingsHeaderTitle> - <SettingsHeaderDescription> - Configure spend limits and monitor AI usage across your - deployment. - </SettingsHeaderDescription> - </SettingsHeader> - - <Tabs - value={activeTab} - onValueChange={(tab) => { - if (tab === "limits" || tab === "usage") { - onActiveTabChange(tab); - } - }} - > - <TabsList> - <TabsTrigger value="limits">Spend limits</TabsTrigger> - <TabsTrigger value="usage">Usage</TabsTrigger> - </TabsList> - - <TabsContent - value="limits" - className="pt-8 data-[state=inactive]:hidden" - forceMount - > - <div className="space-y-8"> - {isLoadingConfig ? ( - <div className="flex items-center justify-center rounded-lg border border-border-default px-6 py-10"> - <Spinner loading className="size-6" /> - </div> - ) : configError ? ( - <div className="space-y-4"> - <ErrorAlert error={configError} /> - <Button - variant="outline" - size="sm" - type="button" - onClick={() => void refetchConfig()} - > - Retry - </Button> - </div> - ) : ( - <> - <DefaultLimitController - key={defaultLimitKey} - initialValues={defaultLimitValues} - onSave={handleSaveDefault} - > - {({ - enabled, - onEnabledChange, - period, - onPeriodChange, - amountDollars, - onAmountDollarsChange, - isAmountValid, - isDirty, - saveDefault, - }) => ( - <DefaultLimitSection - enabled={enabled} - onEnabledChange={(v) => { - handleResetUpdateConfig(); - onEnabledChange(v); - }} - period={period} - onPeriodChange={(v) => { - handleResetUpdateConfig(); - onPeriodChange(v); - }} - amountDollars={amountDollars} - onAmountDollarsChange={(v) => { - handleResetUpdateConfig(); - onAmountDollarsChange(v); - }} - unpricedModelCount={unpricedModelCount} - isSaving={isUpdatingConfig} - isSavedVisible={isSavedVisible} - saveDisabled={ - isUpdatingConfig || !isAmountValid || !isDirty - } - onSave={isDirty ? saveDefault : undefined} - saveStatus={ - isSavedVisible ? ( - <TemporarySavedState /> - ) : updateConfigError ? ( - getErrorMessage( - updateConfigError, - "Failed to save the default spend limit.", - ) - ) : null - } - /> - )} - </DefaultLimitController> - <section className="space-y-6"> - <SpendSectionHeader - title="Group limits" - description="Override the default limit for specific groups. The lowest group limit applies." - /> - <GroupLimitsSection - hideHeader - groupOverrides={groupOverrides} - groupOrganizationNames={groupOrganizationNames} - showGroupForm={groupCtrl.showGroupForm} - onShowGroupFormChange={ - groupCtrl.handleShowGroupFormChange - } - selectedGroup={groupCtrl.selectedGroup} - onSelectedGroupChange={groupCtrl.setSelectedGroup} - groupAmount={groupCtrl.groupAmount} - onGroupAmountChange={groupCtrl.setGroupAmount} - availableGroups={groupCtrl.availableGroups} - groupAutocompleteNoOptionsText={ - groupCtrl.groupAutocompleteNoOptionsText - } - groupsLoading={isLoadingGroups} - editingGroupOverride={ - groupCtrl.editingGroupOverride - } - onEditGroupOverride={(override) => { - userCtrl.handleShowUserFormChange(false); - groupCtrl.handleEditGroupOverride(override); - }} - onAddGroupOverride={ - groupCtrl.handleAddGroupOverride - } - onDeleteGroupOverride={onDeleteGroupOverride} - upsertPending={isUpsertingGroupOverride} - upsertError={upsertGroupOverrideError} - deletePending={isDeletingGroupOverride} - deleteError={deleteGroupOverrideError} - groupsError={groupsError} - /> - </section> - - <section className="space-y-6"> - <SpendSectionHeader - title="User overrides" - description="Set user-specific limits. User overrides take highest priority, followed by group limits, then the default." - /> - <UserOverridesSection - hideHeader - overrides={overrides} - showUserForm={userCtrl.showUserForm} - onShowUserFormChange={ - userCtrl.handleShowUserFormChange - } - selectedUser={userCtrl.selectedUserOverride} - onSelectedUserChange={ - userCtrl.setSelectedUserOverride - } - userOverrideAmount={userCtrl.userOverrideAmount} - onUserOverrideAmountChange={ - userCtrl.setUserOverrideAmount - } - selectedUserAlreadyOverridden={ - userCtrl.editingUserOverride - ? false - : userCtrl.selectedUserAlreadyOverridden - } - editingUserOverride={userCtrl.editingUserOverride} - onEditUserOverride={(override) => { - groupCtrl.handleShowGroupFormChange(false); - userCtrl.handleEditUserOverride(override); - }} - onAddOverride={userCtrl.handleAddOverride} - onDeleteOverride={onDeleteOverride} - upsertPending={isUpsertingOverride} - upsertError={upsertOverrideError} - deletePending={isDeletingOverride} - deleteError={deleteOverrideError} - /> - </section> - </> - )} - </div> - </TabsContent> + return ( + <div className="flex max-w-[1100px] flex-col gap-8"> + <SettingsHeader> + <SettingsHeaderTitle>AI spend usage</SettingsHeaderTitle> + <SettingsHeaderDescription> + Monitor AI usage across your deployment. + </SettingsHeaderDescription> + </SettingsHeader> - <TabsContent value="usage" className="pt-8"> - <UsageTab - displayDateRange={displayDateRange} - onDateRangeChange={onDateRangeChange} - searchFilter={searchFilter} - onSearchFilterChange={onSearchFilterChange} - usersQuery={usersQuery} - overrides={overrides} - onSelectUser={onSelectUser} - onEditBudget={(override) => { - groupCtrl.handleShowGroupFormChange(false); - userCtrl.handleEditUserOverride(override); - }} - /> - </TabsContent> - </Tabs> - </div> - )} - </UserOverrideController> - )} - </GroupOverrideController> + <UsageTab + displayDateRange={displayDateRange} + onDateRangeChange={onDateRangeChange} + searchFilter={searchFilter} + onSearchFilterChange={onSearchFilterChange} + usersQuery={usersQuery} + onSelectUser={onSelectUser} + /> + </div> ); }; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx index 4f1e954f550cd..bc1e60ecf0b56 100644 --- a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import { InfoIcon, TriangleAlertIcon } from "lucide-react"; import { type FC, useState } from "react"; import { getErrorMessage } from "#/api/errors"; @@ -32,25 +31,6 @@ interface ChatCostSummaryViewProps { emptyMessage: string; } -export const getUsageLimitPeriodLabel = ( - period: TypesGen.ChatUsageLimitPeriod | undefined, -): string => { - if (!period) { - return ""; - } - - switch (period) { - case "day": - return "Daily"; - case "week": - return "Weekly"; - case "month": - return "Monthly"; - default: - return ""; - } -}; - export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({ summary, isLoading, @@ -110,41 +90,6 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({ hasNextPage: hasChatNext, } = paginateItems(summary.by_chat, chatPageSize, chatPage); - const usageLimit = summary.usage_limit; - const showUsageLimitCard = usageLimit?.is_limited === true; - const usageLimitCurrentSpend = usageLimit?.current_spend ?? 0; - const usageLimitSpendMicros = usageLimit?.spend_limit_micros ?? 0; - const usageLimitPeriodLabel = usageLimit - ? getUsageLimitPeriodLabel(usageLimit.period) - : ""; - const usageProgressPercentage = - showUsageLimitCard && usageLimitSpendMicros > 0 - ? Math.min((usageLimitCurrentSpend / usageLimitSpendMicros) * 100, 100) - : 0; - const usageProgressBarClass = - usageProgressPercentage > 90 - ? "bg-content-destructive" - : usageProgressPercentage >= 75 - ? "bg-content-warning" - : "bg-content-success"; - const usageLimitExceeded = - showUsageLimitCard && usageLimitCurrentSpend >= usageLimitSpendMicros; - const usageLimitStatusText = usageLimitExceeded - ? "Limit exceeded" - : `${formatCostMicros( - Math.max(usageLimitSpendMicros - usageLimitCurrentSpend, 0), - )} remaining`; - const usageLimitCurrentPeriod = - showUsageLimitCard && usageLimit?.period_start && usageLimit?.period_end - ? `Current period: ${dayjs(usageLimit.period_start).format("MMM D")} to ${dayjs( - usageLimit.period_end, - ).format("MMM D")}` - : ""; - const usageLimitResetAt = - showUsageLimitCard && usageLimit?.period_end - ? dayjs(usageLimit.period_end).format("MMM D, YYYY h:mm A") - : ""; - return ( <div className="space-y-6"> <div className="grid grid-cols-2 gap-4 md:grid-cols-3"> @@ -201,59 +146,6 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({ </div> </div> - {showUsageLimitCard && usageLimit && ( - <div className="rounded-lg border border-border-default bg-surface-secondary p-4"> - <div className="flex flex-col gap-4"> - <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between"> - <div> - <p className="text-xs font-medium uppercase tracking-wide text-content-secondary"> - {usageLimitPeriodLabel} spend limit - </p> - {usageLimitCurrentPeriod && ( - <p className="mt-1 text-sm text-content-secondary"> - {usageLimitCurrentPeriod} - </p> - )} - <p className="mt-1 text-2xl font-semibold text-content-primary"> - {formatCostMicros(usageLimitCurrentSpend)} /{" "} - {formatCostMicros(usageLimitSpendMicros)} - </p> - </div> - <p className="text-sm text-content-secondary"> - {Math.round(usageProgressPercentage)}% used - </p> - </div> - <div - role="progressbar" - aria-label={`${usageLimitPeriodLabel} spend usage`} - aria-valuemin={0} - aria-valuemax={100} - aria-valuenow={Math.round(usageProgressPercentage)} - className="h-2 overflow-hidden rounded-full bg-surface-tertiary" - > - <div - className={`h-full rounded-full ${usageProgressBarClass}`} - style={{ width: `${usageProgressPercentage}%` }} - /> - </div> - <div className="flex flex-col gap-1 text-sm md:flex-row md:items-center md:justify-between"> - <p - className={ - usageLimitExceeded - ? "text-content-destructive" - : "text-content-secondary" - } - > - {usageLimitStatusText} - </p> - <p className="text-content-secondary"> - Resets {usageLimitResetAt} - </p> - </div> - </div> - </div> - )} - {summary.unpriced_messages_having_usage_count > 0 && ( <div className="flex items-start gap-3 rounded-lg border border-border-warning bg-surface-warning p-4 text-sm text-content-primary"> <TriangleAlertIcon className="size-5 shrink-0 text-content-warning" /> @@ -269,8 +161,8 @@ export const ChatCostSummaryView: FC<ChatCostSummaryViewProps> = ({ <div className="flex items-start gap-3 p-4 text-sm text-content-secondary"> <InfoIcon className="size-5 shrink-0" /> <span> - Automatic title generation uses lightweight models and is not counted - towards usage limits. + Automatic title generation uses lightweight models and is not included + in usage reporting. </span> </div> diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx deleted file mode 100644 index 855a25e0dac03..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { type FC, type ReactNode, useState } from "react"; - -import type { ChatUsageLimitPeriod } from "#/api/typesGenerated"; -import { isPositiveFiniteDollarAmount } from "#/utils/currency"; - -export interface DefaultLimitFormValues { - enabled: boolean; - period: ChatUsageLimitPeriod; - amountDollars: string; -} - -interface DefaultLimitControllerProps { - initialValues: DefaultLimitFormValues; - onSave: (values: DefaultLimitFormValues) => void; - children: (props: { - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - period: ChatUsageLimitPeriod; - onPeriodChange: (period: ChatUsageLimitPeriod) => void; - amountDollars: string; - onAmountDollarsChange: (amount: string) => void; - isAmountValid: boolean; - isDirty: boolean; - saveDefault: () => void; - }) => ReactNode; -} - -export const DefaultLimitController: FC<DefaultLimitControllerProps> = ({ - initialValues, - onSave, - children, -}) => { - const [enabled, setEnabled] = useState(initialValues.enabled); - const [period, setPeriod] = useState<ChatUsageLimitPeriod>( - initialValues.period, - ); - const [amountDollars, setAmountDollars] = useState( - initialValues.amountDollars, - ); - const isAmountValid = !enabled || isPositiveFiniteDollarAmount(amountDollars); - const isDirty = - enabled !== initialValues.enabled || - period !== initialValues.period || - amountDollars !== initialValues.amountDollars; - - const handleSave = () => { - if (enabled && !isPositiveFiniteDollarAmount(amountDollars)) { - return; - } - - onSave({ enabled, period, amountDollars }); - }; - - return children({ - enabled, - onEnabledChange: setEnabled, - period, - onPeriodChange: setPeriod, - amountDollars, - onAmountDollarsChange: setAmountDollars, - isAmountValid, - isDirty, - saveDefault: handleSave, - }); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx deleted file mode 100644 index 3e837469e9d48..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { TriangleAlertIcon } from "lucide-react"; -import type { FC, ReactNode } from "react"; -import type { ChatUsageLimitPeriod } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, -} from "#/components/InputGroup/InputGroup"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "#/components/Select/Select"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { Switch } from "#/components/Switch/Switch"; - -interface DefaultLimitSectionProps { - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - period: ChatUsageLimitPeriod; - onPeriodChange: (period: ChatUsageLimitPeriod) => void; - amountDollars: string; - onAmountDollarsChange: (amount: string) => void; - unpricedModelCount: number; - isSaving?: boolean; - isSavedVisible?: boolean; - saveDisabled?: boolean; - onSave?: () => void; - saveStatus?: ReactNode; -} - -export const DefaultLimitSection: FC<DefaultLimitSectionProps> = ({ - enabled, - onEnabledChange, - period, - onPeriodChange, - amountDollars, - onAmountDollarsChange, - unpricedModelCount, - isSaving = false, - isSavedVisible = false, - saveDisabled = false, - onSave, - saveStatus, -}) => { - return ( - <section className="space-y-4"> - <div className="flex items-start gap-3"> - <Switch - checked={enabled} - onCheckedChange={onEnabledChange} - aria-label="Spend limit" - className="mt-0.5" - /> - <div className="flex max-w-[980px] flex-1 flex-col"> - <h3 className="m-0 text-sm font-normal leading-6 text-content-primary"> - Spend limit - </h3> - <p className="mt-1 mb-0 text-sm font-normal leading-6 text-content-secondary"> - Set a deployment-wide spend cap that applies to all users by - default. When disabled, users have unlimited spending. - </p> - <div className="mt-4 flex flex-wrap items-start gap-3"> - <InputGroup className="w-32"> - <InputGroupAddon>$</InputGroupAddon> - <InputGroupInput - type="number" - step="0.01" - min="0" - className="text-sm" - value={amountDollars} - onChange={(event) => onAmountDollarsChange(event.target.value)} - placeholder="0.00" - aria-label="Spend limit amount in dollars" - disabled={!enabled} - /> - </InputGroup> - <Select - value={period} - onValueChange={(value) => - onPeriodChange(value as ChatUsageLimitPeriod) - } - disabled={!enabled} - > - <SelectTrigger - className="h-10 w-32 text-sm" - aria-label="Spend limit period" - > - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="day">Day</SelectItem> - <SelectItem value="week">Week</SelectItem> - <SelectItem value="month">Month</SelectItem> - </SelectContent> - </Select> - <div className="flex min-h-10 items-center"> - {(onSave || isSavedVisible || isSaving) && - (isSavedVisible ? ( - saveStatus - ) : ( - <Button - size="lg" - type="button" - disabled={saveDisabled} - onClick={onSave} - className="h-10 min-w-[88px]" - > - {isSaving && <Spinner loading className="size-4" />} - Save - </Button> - ))} - </div> - </div> - {saveStatus && !isSavedVisible && ( - <div className="text-xs text-content-destructive">{saveStatus}</div> - )} - </div> - </div> - - {enabled && unpricedModelCount > 0 && ( - <div className="ml-12 flex items-start gap-3 rounded-lg border border-border-warning bg-surface-warning p-4 text-sm text-content-primary"> - <TriangleAlertIcon className="size-5 shrink-0 text-content-warning" /> - <div> - {unpricedModelCount === 1 - ? "1 enabled model does not have pricing configured." - : `${unpricedModelCount} enabled models do not have pricing configured.`}{" "} - Usage of unpriced models cannot be tracked against the spend limit. - </div> - </div> - )} - </section> - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx deleted file mode 100644 index b3c8e555219af..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import type { FC } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { Group } from "#/api/typesGenerated"; -import { Autocomplete } from "#/components/Autocomplete/Autocomplete"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; -import { Input } from "#/components/Input/Input"; -import { Label } from "#/components/Label/Label"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { getGroupSubtitle } from "#/modules/groups"; -import { isPositiveFiniteDollarAmount } from "#/utils/currency"; -import type { GroupLimitOverrideGroup } from "./GroupLimitsSection"; - -interface GroupLimitDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedGroup: Group | null; - onSelectedGroupChange: (group: Group | null) => void; - groupAmount: string; - onGroupAmountChange: (amount: string) => void; - availableGroups: Group[]; - groupAutocompleteNoOptionsText: string; - groupsLoading: boolean; - editingGroupOverride: GroupLimitOverrideGroup | null; - upsertPending: boolean; - upsertError: Error | null; - onSave: () => void; - groupAutocompleteId: string; - groupAmountId: string; -} - -export const GroupLimitDialog: FC<GroupLimitDialogProps> = ({ - open, - onOpenChange, - selectedGroup, - onSelectedGroupChange, - groupAmount, - onGroupAmountChange, - availableGroups, - groupAutocompleteNoOptionsText, - groupsLoading, - editingGroupOverride, - upsertPending, - upsertError, - onSave, - groupAutocompleteId, - groupAmountId, -}) => { - const isEditing = editingGroupOverride !== null; - const saveDisabled = isEditing - ? upsertPending || !isPositiveFiniteDollarAmount(groupAmount) - : upsertPending || - selectedGroup === null || - !isPositiveFiniteDollarAmount(groupAmount); - - return ( - <Dialog - open={open} - onOpenChange={(nextOpen) => { - if (upsertPending) { - return; - } - - onOpenChange(nextOpen); - if (!nextOpen) { - onSelectedGroupChange(null); - onGroupAmountChange(""); - } - }} - > - <DialogContent> - <DialogHeader> - <DialogTitle> - {isEditing ? "Update group budget" : "Add group budget"} - </DialogTitle> - <DialogDescription> - {isEditing - ? "Update this group's spend limit override." - : "Set a spend limit override for a specific group."} - </DialogDescription> - </DialogHeader> - - <div className="space-y-5"> - <div className="space-y-1.5"> - {editingGroupOverride ? ( - <> - <Label>Group</Label> - <div className="rounded-md border border-border bg-surface-secondary/40 p-2"> - <AvatarData - title={ - editingGroupOverride.group_display_name || - editingGroupOverride.group_name - } - subtitle={editingGroupOverride.group_name} - src={editingGroupOverride.group_avatar_url} - imgFallbackText={editingGroupOverride.group_name} - /> - </div> - </> - ) : ( - <> - <Label htmlFor={groupAutocompleteId}>Group</Label> - <Autocomplete - id={groupAutocompleteId} - value={selectedGroup} - onChange={onSelectedGroupChange} - options={availableGroups} - getOptionValue={(group) => group.id} - getOptionLabel={(group) => group.display_name || group.name} - isOptionEqualToValue={(option, optionValue) => - option.id === optionValue.id - } - renderOption={(option, isSelected) => ( - <div className="flex w-full items-center justify-between gap-2"> - <AvatarData - title={option.display_name || option.name} - subtitle={getGroupSubtitle(option)} - src={option.avatar_url} - imgFallbackText={option.name} - /> - {isSelected && <CheckIcon className="size-4 shrink-0" />} - </div> - )} - placeholder="Search groups..." - noOptionsText={groupAutocompleteNoOptionsText} - loading={groupsLoading} - disabled={groupsLoading} - className="w-full" - /> - </> - )} - </div> - <div className="space-y-1.5"> - <Label htmlFor={groupAmountId}>Spend limit ($)</Label> - <Input - id={groupAmountId} - type="number" - step="0.01" - min="0.01" - disabled={upsertPending} - value={groupAmount} - onChange={(event) => onGroupAmountChange(event.target.value)} - placeholder="0.00" - /> - </div> - {upsertError && ( - <p className="text-xs text-content-destructive"> - {getErrorMessage(upsertError, "Failed to save group override.")} - </p> - )} - </div> - - <DialogFooter> - <Button - variant="outline" - type="button" - onClick={() => { - onOpenChange(false); - onSelectedGroupChange(null); - onGroupAmountChange(""); - }} - disabled={upsertPending} - > - Cancel - </Button> - <Button type="button" onClick={onSave} disabled={saveDisabled}> - {upsertPending ? <Spinner loading className="size-4" /> : null} - {isEditing ? "Update budget" : "Add group"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx deleted file mode 100644 index eb1963020ac41..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import type { Group } from "#/api/typesGenerated"; -import { GroupLimitsSection } from "./GroupLimitsSection"; - -const mockGroupOverrides = [ - { - group_id: "group-1", - group_display_name: "Engineering", - group_name: "engineering", - group_avatar_url: "", - member_count: 15, - spend_limit_micros: 10_000_000, - }, - { - group_id: "group-2", - group_display_name: "Design", - group_name: "design", - group_avatar_url: "", - member_count: 8, - spend_limit_micros: 5_000_000, - }, - { - group_id: "group-4", - group_display_name: "Support", - group_name: "support", - group_avatar_url: "", - member_count: 11, - spend_limit_micros: null, - }, -]; - -const mockAvailableGroups: Group[] = [ - { - id: "group-3", - name: "marketing", - display_name: "Marketing", - avatar_url: "", - organization_id: "org-1", - organization_name: "Acme", - organization_display_name: "Acme", - members: [], - quota_allowance: 0, - source: "user", - total_member_count: 5, - }, - { - id: "group-5", - name: "sales", - display_name: "Sales", - avatar_url: "", - organization_id: "org-1", - organization_name: "Acme", - organization_display_name: "Acme", - members: [], - quota_allowance: 0, - source: "user", - total_member_count: 12, - }, -]; - -const editingGroupOverride = { - group_id: mockGroupOverrides[0].group_id, - group_display_name: mockGroupOverrides[0].group_display_name, - group_name: mockGroupOverrides[0].group_name, - group_avatar_url: mockGroupOverrides[0].group_avatar_url, - member_count: mockGroupOverrides[0].member_count, -}; - -const meta: Meta<typeof GroupLimitsSection> = { - title: "pages/AISettingsPage/SpendPage/LimitsTab/GroupLimitsSection", - component: GroupLimitsSection, - args: { - groupOverrides: mockGroupOverrides, - groupOrganizationNames: { - "group-1": "Acme", - "group-2": "Acme", - }, - showGroupForm: false, - onShowGroupFormChange: fn(), - selectedGroup: null, - onSelectedGroupChange: fn(), - groupAmount: "", - onGroupAmountChange: fn(), - availableGroups: [], - groupAutocompleteNoOptionsText: "No groups available", - groupsLoading: false, - editingGroupOverride: null, - onEditGroupOverride: fn(), - onAddGroupOverride: fn(), - onDeleteGroupOverride: fn(), - upsertPending: false, - upsertError: null, - deletePending: false, - deleteError: null, - groupsError: null, - }, -}; - -export default meta; -type Story = StoryObj<typeof GroupLimitsSection>; - -export const Default: Story = {}; - -export const EmptyState: Story = { - args: { - groupOverrides: [], - }, -}; - -export const AddForm: Story = { - args: { - showGroupForm: true, - availableGroups: mockAvailableGroups, - }, -}; - -export const EditForm: Story = { - args: { - showGroupForm: true, - editingGroupOverride, - groupAmount: "10.00", - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - // Update budget button confirms edit mode is active. - await expect( - body.getByRole("button", { name: /update budget/i }), - ).toBeInTheDocument(); - // The editing group name appears in both the table row and the - // read-only edit form identity, confirming it was populated. - const nameElements = body.getAllByText( - editingGroupOverride.group_display_name, - ); - expect(nameElements.length).toBeGreaterThanOrEqual(2); - }, -}; - -export const DeleteGroupOverride: Story = { - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - const actionButtons = await body.findAllByRole("button", { - name: /actions for/i, - }); - await userEvent.click(actionButtons[0]); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove group limit/i }), - ); - - // The confirmation dialog should appear. - const dialog = await body.findByRole("dialog"); - await expect(dialog).toBeInTheDocument(); - await expect( - body.getByText(/Are you sure you want to delete this group override/i), - ).toBeInTheDocument(); - }, -}; - -export const DeleteGroupOverrideCancelled: Story = { - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - const actionButtons = await body.findAllByRole("button", { - name: /actions for/i, - }); - await userEvent.click(actionButtons[0]); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove group limit/i }), - ); - - // Cancel the dialog. - await body.findByRole("dialog"); - await userEvent.click(body.getByRole("button", { name: "Cancel" })); - - // The dialog should be closed and the callback should not have been called. - await waitFor(() => { - expect(body.queryByRole("dialog")).not.toBeInTheDocument(); - }); - expect(args.onDeleteGroupOverride).not.toHaveBeenCalled(); - }, -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx deleted file mode 100644 index b0914b6e6bf85..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { PlusIcon } from "lucide-react"; -import { type FC, useId, useState } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { ChatUsageLimitGroupOverride, Group } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { ConfirmDeleteDialog } from "#/components/Dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog"; -import { SearchField } from "#/components/SearchField/SearchField"; -import { paginateItems } from "#/utils/paginateItems"; -import { SpendSectionHeader } from "../SpendSectionHeader"; -import { GroupLimitDialog } from "./GroupLimitDialog"; -import { GROUP_LIMITS_PAGE_SIZE, GroupLimitsTable } from "./GroupLimitsTable"; - -export interface GroupLimitOverrideGroup { - group_id: string; - group_display_name: string; - group_name: string; - group_avatar_url: string; - member_count: number; -} - -export type GroupLimitOverride = ChatUsageLimitGroupOverride; - -interface GroupLimitsSectionProps { - hideHeader?: boolean; - groupOverrides: readonly GroupLimitOverride[]; - groupOrganizationNames?: Record<string, string | undefined>; - showGroupForm: boolean; - onShowGroupFormChange: (show: boolean) => void; - selectedGroup: Group | null; - onSelectedGroupChange: (group: Group | null) => void; - groupAmount: string; - onGroupAmountChange: (amount: string) => void; - availableGroups: Group[]; - groupAutocompleteNoOptionsText: string; - groupsLoading: boolean; - editingGroupOverride: GroupLimitOverrideGroup | null; - onEditGroupOverride: (override: GroupLimitOverride) => void; - onAddGroupOverride: () => void; - onDeleteGroupOverride: (groupID: string) => void; - upsertPending: boolean; - upsertError: Error | null; - deletePending: boolean; - deleteError: Error | null; - groupsError: Error | null; -} - -export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({ - hideHeader, - groupOverrides, - groupOrganizationNames, - showGroupForm, - onShowGroupFormChange, - selectedGroup, - onSelectedGroupChange, - groupAmount, - onGroupAmountChange, - availableGroups, - groupAutocompleteNoOptionsText, - groupsLoading, - editingGroupOverride, - onEditGroupOverride, - onAddGroupOverride, - onDeleteGroupOverride, - upsertPending, - upsertError, - deletePending, - deleteError, - groupsError, -}) => { - const groupAutocompleteId = useId(); - const groupAmountId = useId(); - const isEditing = editingGroupOverride !== null; - const [pendingDeleteGroupId, setPendingDeleteGroupId] = useState< - string | null - >(null); - const [page, setPage] = useState(1); - const [searchQuery, setSearchQuery] = useState(""); - const normalizedSearchQuery = searchQuery.trim().toLowerCase(); - const filteredGroupOverrides = normalizedSearchQuery - ? groupOverrides.filter((override) => - [override.group_display_name, override.group_name].some((value) => - value.toLowerCase().includes(normalizedSearchQuery), - ), - ) - : groupOverrides; - const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = - paginateItems(filteredGroupOverrides, GROUP_LIMITS_PAGE_SIZE, page); - - return ( - <section className="space-y-6"> - {!hideHeader && ( - <SpendSectionHeader - title="Group limits" - description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies." - /> - )} - <div className="space-y-6"> - <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> - <SearchField - value={searchQuery} - onChange={(value) => { - setSearchQuery(value); - setPage(1); - }} - placeholder="Search groups..." - aria-label="Search group limits" - className="w-full sm:max-w-md" - /> - <Button - variant="outline" - size="lg" - type="button" - onClick={() => onShowGroupFormChange(true)} - disabled={ - isEditing || - showGroupForm || - groupsLoading || - availableGroups.length === 0 - } - > - <PlusIcon /> - Add group - </Button> - </div> - - <GroupLimitsTable - pagedOverrides={pagedItems} - totalOverrides={filteredGroupOverrides.length} - clampedPage={clampedPage} - hasPreviousPage={hasPreviousPage} - hasNextPage={hasNextPage} - onPageChange={setPage} - onEditGroupOverride={onEditGroupOverride} - onRequestDelete={setPendingDeleteGroupId} - groupOrganizationNames={groupOrganizationNames} - deletePending={deletePending} - upsertPending={upsertPending} - isEditing={isEditing} - emptyMessage={ - groupOverrides.length === 0 - ? "No group overrides configured." - : "No group limits match your search." - } - /> - - {deleteError && ( - <p className="text-xs text-content-destructive"> - {getErrorMessage(deleteError, "Failed to delete group override.")} - </p> - )} - - <GroupLimitDialog - open={showGroupForm} - onOpenChange={onShowGroupFormChange} - selectedGroup={selectedGroup} - onSelectedGroupChange={onSelectedGroupChange} - groupAmount={groupAmount} - onGroupAmountChange={onGroupAmountChange} - availableGroups={availableGroups} - groupAutocompleteNoOptionsText={groupAutocompleteNoOptionsText} - groupsLoading={groupsLoading} - editingGroupOverride={editingGroupOverride} - upsertPending={upsertPending} - upsertError={upsertError} - onSave={onAddGroupOverride} - groupAutocompleteId={groupAutocompleteId} - groupAmountId={groupAmountId} - /> - {groupsError && ( - <p className="text-xs text-content-destructive"> - {getErrorMessage(groupsError, "Failed to load groups.")} - </p> - )} - </div> - {pendingDeleteGroupId && ( - <ConfirmDeleteDialog - entity="group override" - onConfirm={() => { - void onDeleteGroupOverride(pendingDeleteGroupId); - setPendingDeleteGroupId(null); - }} - isPending={deletePending} - open - onOpenChange={(open) => !open && setPendingDeleteGroupId(null)} - /> - )} - </section> - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx deleted file mode 100644 index 56ff6084346f4..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { EllipsisVerticalIcon, TrashIcon } from "lucide-react"; -import type { FC } from "react"; -import { Link } from "react-router"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount"; -import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; -import { formatCostMicros } from "#/utils/currency"; -import type { GroupLimitOverride } from "./GroupLimitsSection"; - -export const GROUP_LIMITS_PAGE_SIZE = 10; - -interface GroupLimitsTableProps { - pagedOverrides: readonly GroupLimitOverride[]; - totalOverrides: number; - clampedPage: number; - hasPreviousPage: boolean; - hasNextPage: boolean; - onPageChange: (page: number) => void; - onEditGroupOverride: (override: GroupLimitOverride) => void; - onRequestDelete: (groupID: string) => void; - groupOrganizationNames?: Record<string, string | undefined>; - deletePending: boolean; - upsertPending: boolean; - isEditing: boolean; - emptyMessage: string; -} - -export const GroupLimitsTable: FC<GroupLimitsTableProps> = ({ - pagedOverrides, - totalOverrides, - clampedPage, - hasPreviousPage, - hasNextPage, - onPageChange, - onEditGroupOverride, - onRequestDelete, - groupOrganizationNames, - deletePending, - upsertPending, - isEditing, - emptyMessage, -}) => { - if (totalOverrides === 0) { - return ( - <div className="rounded-lg border border-border bg-surface-secondary px-4 py-6 text-center text-sm text-content-secondary"> - {emptyMessage} - </div> - ); - } - - return ( - <> - <div className="space-y-4"> - <Table> - <TableHeader> - <TableRow> - <TableHead>Group</TableHead> - <TableHead>Members</TableHead> - <TableHead>Spend limit</TableHead> - <TableHead className="w-1">Actions</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {pagedOverrides.map((override) => { - const orgName = groupOrganizationNames?.[override.group_id]; - const groupAvatar = ( - <AvatarData - title={override.group_display_name || override.group_name} - subtitle={override.group_name} - src={override.group_avatar_url} - imgFallbackText={override.group_name} - /> - ); - - return ( - <TableRow key={override.group_id}> - <TableCell> - {orgName ? ( - <Link - to={`/organizations/${orgName}/groups/${override.group_name}`} - className="inline-block" - > - {groupAvatar} - </Link> - ) : ( - groupAvatar - )} - </TableCell> - <TableCell>{override.member_count}</TableCell> - <TableCell> - {override.spend_limit_micros !== null - ? formatCostMicros(override.spend_limit_micros) - : "Unlimited"} - </TableCell> - <TableCell className="w-1 whitespace-nowrap text-right"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="subtle" - size="icon" - type="button" - disabled={deletePending || upsertPending} - aria-label={`Actions for ${ - override.group_display_name || override.group_name - }`} - > - <EllipsisVerticalIcon /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onSelect={() => onEditGroupOverride(override)} - > - Update budget - </DropdownMenuItem> - <DropdownMenuItem - className="text-content-destructive focus:text-content-destructive" - disabled={isEditing} - onSelect={() => onRequestDelete(override.group_id)} - > - <TrashIcon /> - Remove group limit - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </TableCell> - </TableRow> - ); - })} - </TableBody> - </Table> - <PaginationAmount - limit={GROUP_LIMITS_PAGE_SIZE} - totalRecords={totalOverrides} - currentOffsetStart={(clampedPage - 1) * GROUP_LIMITS_PAGE_SIZE + 1} - paginationUnitLabel="groups" - className="justify-end" - /> - </div> - {totalOverrides > GROUP_LIMITS_PAGE_SIZE && ( - <PaginationWidgetBase - currentPage={clampedPage} - pageSize={GROUP_LIMITS_PAGE_SIZE} - totalRecords={totalOverrides} - onPageChange={onPageChange} - hasPreviousPage={hasPreviousPage} - hasNextPage={hasNextPage} - /> - )} - </> - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx deleted file mode 100644 index 2ccd1ae44fa5a..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { type FC, type ReactNode, useState } from "react"; - -import type { - Group, - UpsertChatUsageLimitGroupOverrideRequest, -} from "#/api/typesGenerated"; -import { - dollarsToMicros, - isPositiveFiniteDollarAmount, - microsToDollars, -} from "#/utils/currency"; - -interface EditingGroupOverride { - group_id: string; - group_display_name: string; - group_name: string; - group_avatar_url: string; - member_count: number; -} - -type GroupOverrideChildProps = { - showGroupForm: boolean; - setShowGroupForm: (show: boolean) => void; - selectedGroup: Group | null; - setSelectedGroup: (group: Group | null) => void; - groupAmount: string; - setGroupAmount: (amount: string) => void; - editingGroupOverride: EditingGroupOverride | null; - setEditingGroupOverride: (override: EditingGroupOverride | null) => void; - handleShowGroupFormChange: (show: boolean) => void; - handleEditGroupOverride: ( - override: EditingGroupOverride & { - spend_limit_micros: number | null; - }, - ) => void; - handleAddGroupOverride: () => void; - existingGroupIds: Set<string>; - availableGroups: Group[]; - groupAutocompleteNoOptionsText: string; -}; - -interface GroupOverrideControllerProps { - groupOverrides: ReadonlyArray<{ group_id: string }>; - groups: ReadonlyArray<Group>; - isLoadingGroups: boolean; - onUpsertGroupOverride: (args: { - groupID: string; - req: UpsertChatUsageLimitGroupOverrideRequest; - onSuccess: () => void; - }) => void; - children: (props: GroupOverrideChildProps) => ReactNode; -} - -export const GroupOverrideController: FC<GroupOverrideControllerProps> = ({ - groupOverrides, - groups, - isLoadingGroups, - onUpsertGroupOverride, - children, -}) => { - const [showGroupForm, setShowGroupForm] = useState(false); - const [selectedGroup, setSelectedGroup] = useState<Group | null>(null); - const [groupAmount, setGroupAmount] = useState(""); - const [editingGroupOverride, setEditingGroupOverride] = - useState<EditingGroupOverride | null>(null); - - // Derived values. - const existingGroupIds = new Set(groupOverrides.map((g) => g.group_id)); - const availableGroups = groups.filter((g) => !existingGroupIds.has(g.id)); - const groupAutocompleteNoOptionsText = isLoadingGroups - ? "Loading groups..." - : groups.length === 0 - ? "No groups configured" - : availableGroups.length === 0 - ? "All groups already have overrides" - : "No groups available"; - - // Handlers. - const handleShowGroupFormChange = (show: boolean) => { - setShowGroupForm(show); - if (!show) { - setEditingGroupOverride(null); - } - }; - - const handleEditGroupOverride = ( - override: EditingGroupOverride & { - spend_limit_micros: number | null; - }, - ) => { - setEditingGroupOverride({ - group_id: override.group_id, - group_display_name: override.group_display_name, - group_name: override.group_name, - group_avatar_url: override.group_avatar_url, - member_count: override.member_count, - }); - setSelectedGroup(null); - setGroupAmount( - override.spend_limit_micros !== null - ? microsToDollars(override.spend_limit_micros).toString() - : "", - ); - setShowGroupForm(true); - }; - - const handleAddGroupOverride = () => { - const targetGroupID = editingGroupOverride?.group_id ?? selectedGroup?.id; - - if (!targetGroupID || !isPositiveFiniteDollarAmount(groupAmount)) { - return; - } - onUpsertGroupOverride({ - groupID: targetGroupID, - req: { spend_limit_micros: dollarsToMicros(groupAmount) }, - onSuccess: () => { - setEditingGroupOverride(null); - setSelectedGroup(null); - setGroupAmount(""); - setShowGroupForm(false); - }, - }); - }; - - return children({ - showGroupForm, - setShowGroupForm, - selectedGroup, - setSelectedGroup, - groupAmount, - setGroupAmount, - editingGroupOverride, - setEditingGroupOverride, - handleShowGroupFormChange, - handleEditGroupOverride, - handleAddGroupOverride, - existingGroupIds, - availableGroups, - groupAutocompleteNoOptionsText, - }); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx deleted file mode 100644 index 99f406592a1eb..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { type FC, type ReactNode, useState } from "react"; - -import type { - UpsertChatUsageLimitOverrideRequest, - User, -} from "#/api/typesGenerated"; -import { - dollarsToMicros, - isPositiveFiniteDollarAmount, - microsToDollars, -} from "#/utils/currency"; - -interface EditingUserOverride { - user_id: string; - name: string; - username: string; - avatar_url: string; -} - -type UserOverrideChildProps = { - showUserForm: boolean; - setShowUserForm: (show: boolean) => void; - selectedUserOverride: User | null; - setSelectedUserOverride: (user: User | null) => void; - userOverrideAmount: string; - setUserOverrideAmount: (amount: string) => void; - editingUserOverride: EditingUserOverride | null; - setEditingUserOverride: (override: EditingUserOverride | null) => void; - handleShowUserFormChange: (show: boolean) => void; - handleEditUserOverride: ( - override: EditingUserOverride & { - spend_limit_micros: number | null; - }, - ) => void; - handleAddOverride: () => void; - existingUserIds: Set<string>; - selectedUserAlreadyOverridden: boolean; -}; - -interface UserOverrideControllerProps { - overrides: ReadonlyArray<{ user_id: string }>; - onUpsertOverride: (args: { - userID: string; - req: UpsertChatUsageLimitOverrideRequest; - onSuccess: () => void; - }) => void; - children: (props: UserOverrideChildProps) => ReactNode; -} - -export const UserOverrideController: FC<UserOverrideControllerProps> = ({ - overrides, - onUpsertOverride, - children, -}) => { - const [showUserForm, setShowUserForm] = useState(false); - const [selectedUserOverride, setSelectedUserOverride] = useState<User | null>( - null, - ); - const [userOverrideAmount, setUserOverrideAmount] = useState(""); - const [editingUserOverride, setEditingUserOverride] = - useState<EditingUserOverride | null>(null); - - // Derived values. - const existingUserIds = new Set(overrides.map((o) => o.user_id)); - const selectedUserAlreadyOverridden = selectedUserOverride - ? existingUserIds.has(selectedUserOverride.id) - : false; - - // Handlers. - const handleShowUserFormChange = (show: boolean) => { - setShowUserForm(show); - if (!show) { - setEditingUserOverride(null); - } - }; - - const handleEditUserOverride = ( - override: EditingUserOverride & { - spend_limit_micros: number | null; - }, - ) => { - setEditingUserOverride({ - user_id: override.user_id, - name: override.name, - username: override.username, - avatar_url: override.avatar_url, - }); - setSelectedUserOverride(null); - setUserOverrideAmount( - override.spend_limit_micros !== null - ? microsToDollars(override.spend_limit_micros).toString() - : "", - ); - setShowUserForm(true); - }; - - const handleAddOverride = () => { - const targetUserID = - editingUserOverride?.user_id ?? selectedUserOverride?.id; - - if (!targetUserID || !isPositiveFiniteDollarAmount(userOverrideAmount)) { - return; - } - onUpsertOverride({ - userID: targetUserID, - req: { spend_limit_micros: dollarsToMicros(userOverrideAmount) }, - onSuccess: () => { - setEditingUserOverride(null); - setSelectedUserOverride(null); - setUserOverrideAmount(""); - setShowUserForm(false); - }, - }); - }; - - return children({ - showUserForm, - setShowUserForm, - selectedUserOverride, - setSelectedUserOverride, - userOverrideAmount, - setUserOverrideAmount, - editingUserOverride, - setEditingUserOverride, - handleShowUserFormChange, - handleEditUserOverride, - handleAddOverride, - existingUserIds, - selectedUserAlreadyOverridden, - }); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx deleted file mode 100644 index 5226f10687e39..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import type { FC } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { User } from "#/api/typesGenerated"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; -import { Input } from "#/components/Input/Input"; -import { Label } from "#/components/Label/Label"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { UserAutocomplete } from "#/components/UserAutocomplete/UserAutocomplete"; -import { isPositiveFiniteDollarAmount } from "#/utils/currency"; -import type { UserOverrideUser } from "./UserOverridesSection"; - -interface UserOverrideDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedUser: User | null; - onSelectedUserChange: (user: User | null) => void; - userOverrideAmount: string; - onUserOverrideAmountChange: (amount: string) => void; - selectedUserAlreadyOverridden: boolean; - editingUserOverride: UserOverrideUser | null; - upsertPending: boolean; - upsertError: Error | null; - onSave: () => void; - amountInputId: string; -} - -export const UserOverrideDialog: FC<UserOverrideDialogProps> = ({ - open, - onOpenChange, - selectedUser, - onSelectedUserChange, - userOverrideAmount, - onUserOverrideAmountChange, - selectedUserAlreadyOverridden, - editingUserOverride, - upsertPending, - upsertError, - onSave, - amountInputId, -}) => { - const isEditing = editingUserOverride !== null; - const saveDisabled = isEditing - ? upsertPending || !isPositiveFiniteDollarAmount(userOverrideAmount) - : upsertPending || - !selectedUser || - selectedUserAlreadyOverridden || - !isPositiveFiniteDollarAmount(userOverrideAmount); - - return ( - <Dialog - open={open} - onOpenChange={(nextOpen) => { - if (upsertPending) { - return; - } - - onOpenChange(nextOpen); - if (!nextOpen) { - onSelectedUserChange(null); - onUserOverrideAmountChange(""); - } - }} - > - <DialogContent> - <DialogHeader> - <DialogTitle> - {isEditing ? "Update user budget" : "Add user budget"} - </DialogTitle> - <DialogDescription> - {isEditing - ? "Update this user's spend limit override." - : "Set a spend limit override for a specific user."} - </DialogDescription> - </DialogHeader> - - <div className="space-y-5"> - <div className="space-y-1.5"> - {editingUserOverride ? ( - <> - <Label>User</Label> - <div className="rounded-md border border-border bg-surface-secondary/40 p-2"> - <AvatarData - title={ - editingUserOverride.name || editingUserOverride.username - } - subtitle={`@${editingUserOverride.username}`} - src={editingUserOverride.avatar_url} - imgFallbackText={editingUserOverride.username} - /> - </div> - </> - ) : ( - <UserAutocomplete - value={selectedUser} - onChange={onSelectedUserChange} - label="User" - /> - )} - </div> - <div className="space-y-1.5"> - <Label htmlFor={amountInputId}>Spend limit ($)</Label> - <Input - id={amountInputId} - type="number" - step="0.01" - min="0.01" - disabled={upsertPending} - value={userOverrideAmount} - onChange={(event) => - onUserOverrideAmountChange(event.target.value) - } - placeholder="0.00" - /> - </div> - {!isEditing && selectedUserAlreadyOverridden && ( - <p className="text-xs text-content-warning"> - This user already has an override. - </p> - )} - {upsertError && ( - <p className="text-xs text-content-destructive"> - {getErrorMessage(upsertError, "Failed to save the override.")} - </p> - )} - </div> - - <DialogFooter> - <Button - variant="outline" - type="button" - onClick={() => { - onOpenChange(false); - onSelectedUserChange(null); - onUserOverrideAmountChange(""); - }} - disabled={upsertPending} - > - Cancel - </Button> - <Button type="button" onClick={onSave} disabled={saveDisabled}> - {upsertPending ? <Spinner loading className="size-4" /> : null} - {isEditing ? "Update budget" : "Add user"} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx deleted file mode 100644 index 7486a749c9ae2..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { QueryClient, QueryClientProvider } from "react-query"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import { UserOverridesSection } from "./UserOverridesSection"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - refetchOnWindowFocus: false, - }, - }, -}); - -const mockOverrides = [ - { - user_id: "user-1", - name: "Alice Johnson", - username: "alice", - avatar_url: "", - spend_limit_micros: 5_000_000, - }, - { - user_id: "user-2", - name: "Bob Smith", - username: "bob", - avatar_url: "", - spend_limit_micros: 10_000_000, - }, - { - user_id: "user-3", - name: "Charlie Davis", - username: "charlie", - avatar_url: "", - spend_limit_micros: null, - }, -]; - -const meta: Meta<typeof UserOverridesSection> = { - title: "pages/AISettingsPage/SpendPage/LimitsTab/UserOverridesSection", - component: UserOverridesSection, - args: { - overrides: mockOverrides, - showUserForm: false, - onShowUserFormChange: fn(), - selectedUser: null, - onSelectedUserChange: fn(), - userOverrideAmount: "", - onUserOverrideAmountChange: fn(), - selectedUserAlreadyOverridden: false, - editingUserOverride: null, - onEditUserOverride: fn(), - onAddOverride: fn(), - onDeleteOverride: fn(), - upsertPending: false, - upsertError: null, - deletePending: false, - deleteError: null, - }, - decorators: [ - (Story) => ( - <QueryClientProvider client={queryClient}> - <Story /> - </QueryClientProvider> - ), - ], -}; - -export default meta; -type Story = StoryObj<typeof UserOverridesSection>; - -export const Default: Story = {}; - -export const EmptyState: Story = { - args: { - overrides: [], - }, -}; - -export const AddForm: Story = { - args: { - showUserForm: true, - overrides: mockOverrides, - }, -}; - -export const EditForm: Story = { - args: { - showUserForm: true, - editingUserOverride: { - user_id: mockOverrides[0].user_id, - name: mockOverrides[0].name, - username: mockOverrides[0].username, - avatar_url: mockOverrides[0].avatar_url, - }, - userOverrideAmount: "5.00", - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - // Update budget button confirms edit mode is active. - await expect( - body.getByRole("button", { name: /update budget/i }), - ).toBeInTheDocument(); - // The editing user name appears in both the table row and the - // read-only edit form identity, confirming it was populated. - const nameElements = body.getAllByText("Alice Johnson"); - expect(nameElements.length).toBeGreaterThanOrEqual(2); - }, -}; - -export const DeleteUserOverride: Story = { - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - - const actionButtons = await body.findAllByRole("button", { - name: /actions for/i, - }); - await userEvent.click(actionButtons[0]); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove override/i }), - ); - - // The confirmation dialog should appear. - const dialog = await body.findByRole("dialog"); - await expect(dialog).toBeInTheDocument(); - await expect( - body.getByText(/Are you sure you want to delete this user override/i), - ).toBeInTheDocument(); - }, -}; - -export const DeleteUserOverrideCancelled: Story = { - play: async ({ canvasElement, args }) => { - const body = within(canvasElement.ownerDocument.body); - - const actionButtons = await body.findAllByRole("button", { - name: /actions for/i, - }); - await userEvent.click(actionButtons[0]); - await userEvent.click( - await body.findByRole("menuitem", { name: /remove override/i }), - ); - - // Cancel the dialog. - await body.findByRole("dialog"); - await userEvent.click(body.getByRole("button", { name: "Cancel" })); - - // The dialog should be closed and the callback should not have been called. - await waitFor(() => { - expect(body.queryByRole("dialog")).not.toBeInTheDocument(); - }); - expect(args.onDeleteOverride).not.toHaveBeenCalled(); - }, -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx deleted file mode 100644 index 15b8c4832e981..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { PlusIcon } from "lucide-react"; -import { type FC, useId, useState } from "react"; -import { getErrorMessage } from "#/api/errors"; -import type { User } from "#/api/typesGenerated"; -import { Button } from "#/components/Button/Button"; -import { ConfirmDeleteDialog } from "#/components/Dialogs/ConfirmDeleteDialog/ConfirmDeleteDialog"; -import { SearchField } from "#/components/SearchField/SearchField"; -import { paginateItems } from "#/utils/paginateItems"; -import { SpendSectionHeader } from "../SpendSectionHeader"; -import { UserOverrideDialog } from "./UserOverrideDialog"; -import { - USER_OVERRIDES_PAGE_SIZE, - UserOverridesTable, -} from "./UserOverridesTable"; - -export interface UserOverrideUser { - user_id: string; - name: string; - username: string; - avatar_url: string; -} - -export interface UserOverride extends UserOverrideUser { - spend_limit_micros: number | null; -} - -interface UserOverridesSectionProps { - hideHeader?: boolean; - overrides: readonly UserOverride[]; - showUserForm: boolean; - onShowUserFormChange: (show: boolean) => void; - selectedUser: User | null; - onSelectedUserChange: (user: User | null) => void; - userOverrideAmount: string; - onUserOverrideAmountChange: (amount: string) => void; - selectedUserAlreadyOverridden: boolean; - editingUserOverride: UserOverrideUser | null; - onEditUserOverride: (override: UserOverride) => void; - onAddOverride: () => void; - onDeleteOverride: (userID: string) => void; - upsertPending: boolean; - upsertError: Error | null; - deletePending: boolean; - deleteError: Error | null; -} - -export const UserOverridesSection: FC<UserOverridesSectionProps> = ({ - hideHeader, - overrides, - showUserForm, - onShowUserFormChange, - selectedUser, - onSelectedUserChange, - userOverrideAmount, - onUserOverrideAmountChange, - selectedUserAlreadyOverridden, - editingUserOverride, - onEditUserOverride, - onAddOverride, - onDeleteOverride, - upsertPending, - upsertError, - deletePending, - deleteError, -}) => { - const userOverrideAmountId = useId(); - const isEditing = editingUserOverride !== null; - const [pendingDeleteUserId, setPendingDeleteUserId] = useState<string | null>( - null, - ); - const [page, setPage] = useState(1); - const [searchQuery, setSearchQuery] = useState(""); - const normalizedSearchQuery = searchQuery.trim().toLowerCase(); - const filteredOverrides = normalizedSearchQuery - ? overrides.filter((override) => - [override.name, override.username].some((value) => - value.toLowerCase().includes(normalizedSearchQuery), - ), - ) - : overrides; - const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = - paginateItems(filteredOverrides, USER_OVERRIDES_PAGE_SIZE, page); - - return ( - <section className="space-y-6"> - {!hideHeader && ( - <SpendSectionHeader - title="Per-user overrides" - description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default." - /> - )} - <div className="space-y-6"> - <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> - <SearchField - value={searchQuery} - onChange={(value) => { - setSearchQuery(value); - setPage(1); - }} - placeholder="Search users..." - aria-label="Search user overrides" - className="w-full sm:max-w-md" - /> - <Button - variant="outline" - size="lg" - type="button" - onClick={() => onShowUserFormChange(true)} - disabled={isEditing || showUserForm} - > - <PlusIcon /> - Add user - </Button> - </div> - - <UserOverridesTable - pagedOverrides={pagedItems} - totalOverrides={filteredOverrides.length} - clampedPage={clampedPage} - hasPreviousPage={hasPreviousPage} - hasNextPage={hasNextPage} - onPageChange={setPage} - onEditUserOverride={onEditUserOverride} - onRequestDelete={setPendingDeleteUserId} - deletePending={deletePending} - upsertPending={upsertPending} - isEditing={isEditing} - emptyMessage={ - overrides.length === 0 - ? "No overrides configured." - : "No user overrides match your search." - } - /> - - {deleteError && ( - <p className="text-xs text-content-destructive"> - {getErrorMessage(deleteError, "Failed to delete override.")} - </p> - )} - - <UserOverrideDialog - open={showUserForm} - onOpenChange={onShowUserFormChange} - selectedUser={selectedUser} - onSelectedUserChange={onSelectedUserChange} - userOverrideAmount={userOverrideAmount} - onUserOverrideAmountChange={onUserOverrideAmountChange} - selectedUserAlreadyOverridden={selectedUserAlreadyOverridden} - editingUserOverride={editingUserOverride} - upsertPending={upsertPending} - upsertError={upsertError} - onSave={onAddOverride} - amountInputId={userOverrideAmountId} - /> - </div> - {pendingDeleteUserId && ( - <ConfirmDeleteDialog - entity="user override" - onConfirm={() => { - void onDeleteOverride(pendingDeleteUserId); - setPendingDeleteUserId(null); - }} - isPending={deletePending} - open - onOpenChange={(open) => !open && setPendingDeleteUserId(null)} - /> - )} - </section> - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx deleted file mode 100644 index 1ab125f2c45ea..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { EllipsisVerticalIcon, TrashIcon } from "lucide-react"; -import type { FC } from "react"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; -import { PaginationAmount } from "#/components/PaginationWidget/PaginationAmount"; -import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; -import { formatCostMicros } from "#/utils/currency"; -import type { UserOverride } from "./UserOverridesSection"; - -export const USER_OVERRIDES_PAGE_SIZE = 10; - -interface UserOverridesTableProps { - pagedOverrides: readonly UserOverride[]; - totalOverrides: number; - clampedPage: number; - hasPreviousPage: boolean; - hasNextPage: boolean; - onPageChange: (page: number) => void; - onEditUserOverride: (override: UserOverride) => void; - onRequestDelete: (userID: string) => void; - deletePending: boolean; - upsertPending: boolean; - isEditing: boolean; - emptyMessage: string; -} - -export const UserOverridesTable: FC<UserOverridesTableProps> = ({ - pagedOverrides, - totalOverrides, - clampedPage, - hasPreviousPage, - hasNextPage, - onPageChange, - onEditUserOverride, - onRequestDelete, - deletePending, - upsertPending, - isEditing, - emptyMessage, -}) => { - if (totalOverrides === 0) { - return ( - <div className="rounded-lg border border-border bg-surface-secondary px-4 py-6 text-center text-sm text-content-secondary"> - {emptyMessage} - </div> - ); - } - - return ( - <> - <div className="space-y-4"> - <Table> - <TableHeader> - <TableRow> - <TableHead>User</TableHead> - <TableHead>Spend limit</TableHead> - <TableHead className="w-1">Actions</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {pagedOverrides.map((override) => ( - <TableRow key={override.user_id}> - <TableCell> - <AvatarData - title={override.name || override.username} - subtitle={`@${override.username}`} - src={override.avatar_url} - imgFallbackText={override.username} - /> - </TableCell> - <TableCell> - {override.spend_limit_micros !== null - ? formatCostMicros(override.spend_limit_micros) - : "Unlimited"} - </TableCell> - <TableCell className="w-1 whitespace-nowrap text-right"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="subtle" - size="icon" - type="button" - disabled={deletePending || upsertPending} - aria-label={`Actions for ${override.name || override.username}`} - > - <EllipsisVerticalIcon /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onSelect={() => onEditUserOverride(override)} - > - Update budget - </DropdownMenuItem> - <DropdownMenuItem - className="text-content-destructive focus:text-content-destructive" - disabled={isEditing} - onSelect={() => onRequestDelete(override.user_id)} - > - <TrashIcon /> - Remove override - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - <PaginationAmount - limit={USER_OVERRIDES_PAGE_SIZE} - totalRecords={totalOverrides} - currentOffsetStart={(clampedPage - 1) * USER_OVERRIDES_PAGE_SIZE + 1} - paginationUnitLabel="users" - className="justify-end" - /> - </div> - {totalOverrides > USER_OVERRIDES_PAGE_SIZE && ( - <PaginationWidgetBase - currentPage={clampedPage} - pageSize={USER_OVERRIDES_PAGE_SIZE} - totalRecords={totalOverrides} - onPageChange={onPageChange} - hasPreviousPage={hasPreviousPage} - hasNextPage={hasNextPage} - /> - )} - </> - ); -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts deleted file mode 100644 index 97307cee08668..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { normalizeChatUsageLimitPeriod } from "./limitsFormLogic"; - -describe("limitsFormLogic", () => { - describe("normalizeChatUsageLimitPeriod", () => { - it("defaults invalid periods to month", () => { - expect(normalizeChatUsageLimitPeriod("year")).toBe("month"); - expect(normalizeChatUsageLimitPeriod(undefined)).toBe("month"); - }); - }); -}); diff --git a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts b/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts deleted file mode 100644 index 63da616d9cc8c..0000000000000 --- a/site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ChatUsageLimitPeriod } from "#/api/typesGenerated"; - -export const normalizeChatUsageLimitPeriod = ( - period: string | null | undefined, -): ChatUsageLimitPeriod => { - switch (period) { - case "day": - case "week": - case "month": - return period; - default: - return "month"; - } -}; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx b/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx index c3d5f634605dd..676a89614e3a8 100644 --- a/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/UsageTab/UsageTab.tsx @@ -1,4 +1,3 @@ -import { EllipsisVerticalIcon } from "lucide-react"; import type { FC } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; @@ -8,12 +7,6 @@ import { DateRangePicker, type DateRangeValue, } from "#/components/DateRangePicker/DateRangePicker"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "#/components/DropdownMenu/DropdownMenu"; import { PaginationContainer, type PaginationResult, @@ -38,14 +31,6 @@ import { formatTokenCount } from "#/utils/analytics"; import { formatCostMicros } from "#/utils/currency"; import { SpendSectionHeader } from "../SpendSectionHeader"; -type UsageUserOverride = { - user_id: string; - name: string; - username: string; - avatar_url: string; - spend_limit_micros: number | null; -}; - interface UsageTabProps { displayDateRange: DateRangeValue; onDateRangeChange: (value: DateRangeValue) => void; @@ -58,9 +43,7 @@ interface UsageTabProps { error: unknown; refetch: () => unknown; }; - overrides: readonly UsageUserOverride[]; onSelectUser: (user: TypesGen.ChatCostUserRollup) => void; - onEditBudget: (override: UsageUserOverride) => void; } export const UsageTab: FC<UsageTabProps> = ({ @@ -69,9 +52,7 @@ export const UsageTab: FC<UsageTabProps> = ({ searchFilter, onSearchFilterChange, usersQuery, - overrides, onSelectUser, - onEditBudget, }) => { return ( <section className="space-y-6"> @@ -146,7 +127,6 @@ export const UsageTab: FC<UsageTabProps> = ({ <TableHead className="text-right">Output</TableHead> <TableHead className="text-right">Cache Read</TableHead> <TableHead className="text-right">Cache Write</TableHead> - <TableHead className="w-1">Actions</TableHead> </TableRow> </TableHeader> <TableBody> @@ -155,18 +135,6 @@ export const UsageTab: FC<UsageTabProps> = ({ key={user.user_id} user={user} onSelect={onSelectUser} - onEditBudget={(selectedUser) => { - const override = overrides.find( - (o) => o.user_id === selectedUser.user_id, - ) ?? { - user_id: selectedUser.user_id, - name: selectedUser.name, - username: selectedUser.username, - avatar_url: selectedUser.avatar_url, - spend_limit_micros: null, - }; - onEditBudget(override); - }} /> ))} </TableBody> @@ -183,8 +151,7 @@ export const UsageTab: FC<UsageTabProps> = ({ const UserRow: FC<{ user: TypesGen.ChatCostUserRollup; onSelect: (user: TypesGen.ChatCostUserRollup) => void; - onEditBudget: (user: TypesGen.ChatCostUserRollup) => void; -}> = ({ user, onSelect, onEditBudget }) => { +}> = ({ user, onSelect }) => { const clickableRowProps = useClickableTableRow({ onClick: () => onSelect(user), }); @@ -237,28 +204,6 @@ const UserRow: FC<{ <TableCell className="text-right tabular-nums"> {formatTokenCount(user.total_cache_creation_tokens)} </TableCell> - <TableCell className="w-1" onClick={(event) => event.stopPropagation()}> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - type="button" - size="icon" - variant="subtle" - aria-label={`Open spend actions for ${user.name || user.username}`} - > - <EllipsisVerticalIcon aria-hidden="true" className="size-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => onEditBudget(user)}> - Update budget - </DropdownMenuItem> - <DropdownMenuItem onClick={() => onSelect(user)}> - View spend details - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - </TableCell> </TableRow> ); }; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index b95f88258cdfd..8bb6602fff98e 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -500,14 +500,6 @@ const meta: Meta<typeof AgentsPageLayout> = { retention_days: 30, }); spyOn(API.experimental, "updateChatRetentionDays").mockResolvedValue(); - spyOn(API.experimental, "getChatUsageLimitConfig").mockResolvedValue({ - spend_limit_micros: null, - period: "month", - updated_at: "2026-02-18T00:00:00.000Z", - unpriced_model_count: 0, - overrides: [], - group_overrides: [], - }); spyOn(API, "getGroups").mockResolvedValue([]); spyOn(API.experimental, "getChatCostUsers").mockResolvedValue({ start_date: "2026-02-10T00:00:00Z", diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 5e72778eb5000..cbbe9acd016d1 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1,4 +1,7 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; +import { delay } from "msw"; +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; import { expect, fn, @@ -9,6 +12,7 @@ import { within, } from "storybook/test"; import { API } from "#/api/api"; +import { permittedOrganizationsKey } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { MockChatModelConfig } from "#/testHelpers/chatModels"; @@ -18,18 +22,17 @@ import { MockWorkspace, } from "#/testHelpers/entities"; import { withDashboardProvider } from "#/testHelpers/storybook"; +import { persistedAttachmentsStorageKey } from "../hooks/useFileAttachments"; import { getReasoningEffortForModel, saveReasoningEffortForModel, } from "../utils/reasoningEffort"; -import { AgentCreateForm } from "./AgentCreateForm"; +import { AgentCreateForm, emptyInputStorageKey } from "./AgentCreateForm"; -// Query key used by permittedOrganizations() in the form. -const permittedOrgsKey = [ - "organizations", - "permitted", - { object: { resource_type: "chat" }, action: "create" }, -]; +const permittedOrgsKey = permittedOrganizationsKey({ + object: { resource_type: "chat", owner_id: "me" }, + action: "create", +}); const modelConfigID = "model-config-1"; const claudeModelConfigID = "model-config-claude"; @@ -132,19 +135,27 @@ type Story = StoryObj<typeof AgentCreateForm>; const defaultArgs = meta.args; -const mockPermittedOrganizations = (permissions: Record<string, boolean>) => { +const mockPermittedOrganizations = ( + permissions: Record<string, boolean>, + delayMs = 0, +) => { spyOn(API, "getOrganizations").mockResolvedValue([ MockDefaultOrganization, MockOrganization2, ]); - spyOn(API, "checkAuthorization").mockResolvedValue(permissions); + spyOn(API, "checkAuthorization").mockImplementation(async () => { + if (delayMs > 0) { + await delay(delayMs); + } + return permissions; + }); }; export const Default: Story = {}; const submitMessage = async (canvasElement: HTMLElement, message: string) => { const canvas = within(canvasElement); - const input = canvas.getByTestId("chat-message-input"); + const input = canvas.getByRole("textbox", { name: "Chat message" }); await userEvent.click(input); await userEvent.keyboard(message); await userEvent.click(canvas.getByRole("button", { name: "Send" })); @@ -814,6 +825,15 @@ export const UsageLimitExceeded: Story = { }, ), }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/^You've used \$0\.90 of your \$0\.50 limit\./), + ).toBeVisible(); + await expect( + canvas.queryByRole("link", { name: /view usage/i }), + ).not.toBeInTheDocument(); + }, }; export const ForbiddenErrorWithRole: Story = { @@ -844,24 +864,496 @@ export const WithOrganizationPicker: Story = { queries: [ { key: permittedOrgsKey, - data: [MockDefaultOrganization, MockOrganization2], + data: [MockOrganization2, MockDefaultOrganization], }, ], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Verify the org picker rendered (component didn't crash). - await waitFor(() => { - expect(canvas.getByTestId("compact-org-selector")).toBeInTheDocument(); + const organizationPicker = canvas.getByRole("button", { + name: "Organization: My Organization", }); - // Type into the chat input to trigger re-renders. If the - // permittedOrgs fallback is referentially unstable, this - // causes a render cascade that hits React's update limit. - const input = canvas.getByTestId("chat-message-input"); + await expect(organizationPicker).toBeVisible(); + + const input = canvas.getByRole("textbox", { name: "Chat message" }); await userEvent.click(input); await userEvent.keyboard("hello world"); - // The org picker should still be present after typing. - expect(canvas.getByTestId("compact-org-selector")).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { + name: "Organization: My Organization", + }), + ).toBeVisible(); + }, +}; + +export const RestrictedMultiOrganizationUser: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + // Model agents-access: "me" supplies the owner for member-scoped chat:create. + spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => + Object.fromEntries( + Object.entries(checks).map(([id, check]) => [ + id, + check.object.owner_id === "me" && + check.object.organization_id === MockOrganization2.id, + ]), + ), + ); + }, + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + }, + play: async ({ canvasElement, args }) => { + await submitMessage(canvasElement, "test message"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: MockOrganization2.id, + }), + ); + }); + }, +}; + +export const RestrictedUserKeepsPersistedWorkspace: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + workspaceOptions: [ + { + ...MockWorkspace, + id: "ws-permitted-org", + name: "permitted-workspace", + organization_id: MockOrganization2.id, + }, + ], + workspaceCount: 1, + }, + beforeEach: () => { + localStorage.setItem("agents.selected-workspace-id", "ws-permitted-org"); + mockPermittedOrganizations({ + [MockDefaultOrganization.id]: false, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement, args }) => { + await submitMessage(canvasElement, "test message"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: MockOrganization2.id, + workspaceId: "ws-permitted-org", + }), + ); + }); + }, +}; + +export const RestrictedUserKeepsPersistedAttachments: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + localStorage.clear(); + localStorage.setItem( + "agents.persisted-attachments", + JSON.stringify([ + { + fileId: "file-permitted-org", + fileName: "notes.txt", + fileType: "text/plain", + lastModified: 1700000000000, + organizationId: MockOrganization2.id, + }, + ]), + ); + mockPermittedOrganizations({ + [MockDefaultOrganization.id]: false, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByLabelText("Remove notes.txt")).toBeInTheDocument(); + }); + const stored = localStorage.getItem("agents.persisted-attachments"); + expect(stored).toContain("file-permitted-org"); + }, +}; + +export const OrganizationAuthorizationFailure: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + localStorage.clear(); + localStorage.setItem(emptyInputStorageKey, "draft message"); + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + spyOn(API, "checkAuthorization").mockRejectedValue( + new Error("authorization check failed"), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findAllByText(/authorization check failed/i); + expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + }, +}; + +export const LoadingWorkspacesBlocksSendUntilValidated: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + args: { + ...defaultArgs, + workspaceOptions: [], + isWorkspacesLoading: true, + }, + beforeEach: () => { + localStorage.setItem(emptyInputStorageKey, "draft message"); + localStorage.setItem("agents.selected-workspace-id", "ws-default-org"); + mockPermittedOrganizations({ + [MockDefaultOrganization.id]: true, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Wait for permissions to settle before checking workspace validation. + await canvas.findByRole("button", { + name: "Organization: My Organization", + }); + await expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + }, +}; + +export const DelayedOrganizationAuthorization: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + localStorage.setItem(emptyInputStorageKey, "draft message"); + mockPermittedOrganizations( + { + [MockDefaultOrganization.id]: true, + [MockOrganization2.id]: true, + }, + 1_500, + ); + }, + args: { + ...defaultArgs, + workspaceOptions: [ + { + ...MockWorkspace, + id: "ws-provisional", + name: "provisional-workspace", + organization_id: MockDefaultOrganization.id, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const sendButton = canvas.getByRole("button", { name: "Send" }); + await expect(sendButton).toBeDisabled(); + await expect( + canvas.getByRole("button", { name: "More options" }), + ).toBeDisabled(); + // dispatchEvent returns false when a handler accepted the drop + // via preventDefault, giving a race-free accepted/ignored signal. + const dropFile = (name: string): boolean => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File(["hello"], name, { type: "text/plain" })); + return canvas.getByTestId("chat-composer").dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + }; + // Pending authorization leaves attachments without a valid org, so drops + // must be ignored. + expect(dropFile("drop.txt")).toBe(true); + expect(canvas.queryByLabelText("Remove drop.txt")).not.toBeInTheDocument(); + // The pending option list is unfiltered, so the picker must stay hidden. + expect( + canvas.queryByRole("button", { name: /organization/i }), + ).not.toBeInTheDocument(); + await waitFor(() => expect(sendButton).toBeEnabled(), { timeout: 3_000 }); + await canvas.findByRole("button", { name: /organization/i }); + // Positive control: once settled the same drop is accepted and + // attaches, so the pending-state assertions exercised a real path. + expect(dropFile("after.txt")).toBe(false); + await waitFor(() => + expect(canvas.getByLabelText("Remove after.txt")).toBeInTheDocument(), + ); + }, +}; + +// Mutable permissions let play functions change authorization across refetches. +// The story-local QueryClient exposes those refetches; the preview client's +// instance is inaccessible and uses infinite stale time. +const revocablePermissions: Record<string, boolean> = {}; +let revocableQueryClient: QueryClient | undefined; + +const withRevocableQueryClient: Decorator = (Story) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: Number.POSITIVE_INFINITY, + retry: false, + }, + }, + }), + ); + revocableQueryClient = queryClient; + return ( + <QueryClientProvider client={queryClient}> + <Story /> + </QueryClientProvider> + ); +}; + +const mockRevocablePermissions = (permissions: Record<string, boolean>) => { + for (const key of Object.keys(revocablePermissions)) { + delete revocablePermissions[key]; + } + Object.assign(revocablePermissions, permissions); + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + spyOn(API, "checkAuthorization").mockImplementation(async () => ({ + ...revocablePermissions, + })); +}; + +const revocableStoryContext = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + decorators: [withRevocableQueryClient], +}; + +const allOrganizationsPermitted = { + [MockDefaultOrganization.id]: true, + [MockOrganization2.id]: true, +}; + +export const RevokedSelectionDoesNotResurrect: Story = { + ...revocableStoryContext, + beforeEach: () => { + mockRevocablePermissions(allOrganizationsPermitted); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole("button", { + name: "Organization: My Organization", + }); + await userEvent.click(trigger); + await userEvent.click( + await screen.findByRole("option", { name: /My Organization 2/ }), + ); + await canvas.findByRole("button", { + name: "Organization: My Organization 2", + }); + + revocablePermissions[MockOrganization2.id] = false; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect( + canvas.queryByRole("button", { name: /organization/i }), + ).not.toBeInTheDocument(), + ); + + revocablePermissions[MockOrganization2.id] = true; + await revocableQueryClient?.invalidateQueries(); + await canvas.findByRole("button", { + name: "Organization: My Organization", + }); + }, +}; + +export const RevokedOrgChangeClearsStoredWorkspace: Story = { + ...revocableStoryContext, + args: { + ...defaultArgs, + workspaceOptions: [ + { + ...MockWorkspace, + id: "ws-default-org", + name: "default-workspace", + organization_id: MockDefaultOrganization.id, + }, + ], + workspaceCount: 1, + }, + beforeEach: () => { + localStorage.setItem("agents.selected-workspace-id", "ws-default-org"); + mockRevocablePermissions(allOrganizationsPermitted); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => + expect( + canvas.getByLabelText("Remove workspace default-workspace"), + ).toBeInTheDocument(), + ); + + revocablePermissions[MockDefaultOrganization.id] = false; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect( + canvas.queryByLabelText("Remove workspace default-workspace"), + ).not.toBeInTheDocument(), + ); + + revocablePermissions[MockDefaultOrganization.id] = true; + await revocableQueryClient?.invalidateQueries(); + await canvas.findByRole("button", { + name: "Organization: My Organization 2", + }); + expect( + canvas.queryByLabelText("Remove workspace default-workspace"), + ).not.toBeInTheDocument(); + expect(localStorage.getItem("agents.selected-workspace-id")).toBeNull(); + }, +}; + +export const EmptyPermittedSetPreservesStoredWorkspace: Story = { + ...revocableStoryContext, + args: { + ...defaultArgs, + workspaceOptions: [ + { + ...MockWorkspace, + id: "ws-org-2", + name: "org2-workspace", + organization_id: MockOrganization2.id, + }, + ], + }, + beforeEach: () => { + localStorage.setItem("agents.selected-workspace-id", "ws-org-2"); + mockRevocablePermissions({ + [MockDefaultOrganization.id]: false, + [MockOrganization2.id]: true, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByLabelText("Remove workspace org2-workspace"); + + revocablePermissions[MockOrganization2.id] = false; + await revocableQueryClient?.invalidateQueries(); + await canvas.findByText(/don't have permission/i); + + revocablePermissions[MockOrganization2.id] = true; + await revocableQueryClient?.invalidateQueries(); + await canvas.findByLabelText("Remove workspace org2-workspace"); + expect(localStorage.getItem("agents.selected-workspace-id")).toBe( + "ws-org-2", + ); + }, +}; + +export const SingleOrgIgnoresStalePermittedCache: Story = { + parameters: { + showOrganizations: false, + organizations: [MockDefaultOrganization], + queries: [ + { + key: permittedOrgsKey, + data: [MockOrganization2], + }, + ], + }, + args: { + ...defaultArgs, + onCreateChat: fn().mockResolvedValue(undefined), + }, + play: async ({ canvasElement, args }) => { + await submitMessage(canvasElement, "test message"); + await waitFor(() => { + expect(args.onCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: MockDefaultOrganization.id, + }), + ); + }); + }, +}; + +export const RevokedPendingOrgClosesConfirmDialog: Story = { + ...revocableStoryContext, + beforeEach: () => { + localStorage.clear(); + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([ + { + fileId: "file-default-org", + fileName: "notes.txt", + fileType: "text/plain", + lastModified: 1700000000000, + organizationId: MockDefaultOrganization.id, + }, + ]), + ); + mockRevocablePermissions(allOrganizationsPermitted); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await waitFor(() => + expect(canvas.getByLabelText("Remove notes.txt")).toBeInTheDocument(), + ); + await userEvent.click( + await canvas.findByRole("button", { + name: "Organization: My Organization", + }), + ); + await userEvent.click( + await screen.findByRole("option", { name: /My Organization 2/ }), + ); + await body.findByText( + "Changing organization will remove your current attachments.", + ); + + revocablePermissions[MockOrganization2.id] = false; + await revocableQueryClient?.invalidateQueries(); + await waitFor(() => + expect( + body.queryByText( + "Changing organization will remove your current attachments.", + ), + ).not.toBeInTheDocument(), + ); + expect(canvas.getByLabelText("Remove notes.txt")).toBeInTheDocument(); }, }; @@ -957,14 +1449,27 @@ export const PermittedOrgsResolvesToEmpty: Story = { parameters: { showOrganizations: true, organizations: [MockDefaultOrganization, MockOrganization2], - // Deliberately do not pre-seed permittedOrgsKey. Let the - // mocked API calls drive the async permission resolution. }, args: { ...defaultArgs, onCreateChat: fn().mockResolvedValue(undefined), }, beforeEach: () => { + localStorage.clear(); + // Another org's persisted attachment must survive a visit while + // the user has no chat permission anywhere. + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([ + { + fileId: "file-other-org", + fileName: "keep.txt", + fileType: "text/plain", + lastModified: 1000, + organizationId: MockOrganization2.id, + }, + ]), + ); mockPermittedOrganizations({ [MockDefaultOrganization.id]: false, [MockOrganization2.id]: false, @@ -972,36 +1477,17 @@ export const PermittedOrgsResolvesToEmpty: Story = { }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - - // Wait for the permitted orgs query to resolve. The org picker - // should disappear since no org is permitted. await waitFor( () => { - expect( - canvas.queryByTestId("compact-org-selector"), - ).not.toBeInTheDocument(); + expect(canvas.getByText(/don't have permission/i)).toBeInTheDocument(); }, { timeout: 3000 }, ); - - // Type a message and submit the form. - const input = canvas.getByTestId("chat-message-input"); - await userEvent.click(input); - await userEvent.keyboard("test message"); - await userEvent.click(canvas.getByRole("button", { name: "Send" })); - - // Verify onCreateChat was called with a non-empty organizationId. - await waitFor(() => { - expect(args.onCreateChat).toHaveBeenCalled(); - }); - const options = (args.onCreateChat as ReturnType<typeof fn>).mock - .calls[0]?.[0] as { organizationId: string } | undefined; - if (!options) { - throw new Error("Expected onCreateChat to receive options"); - } - expect(options.organizationId).not.toBe(""); - // It should fall back to the default org from the dashboard. - expect(options.organizationId).toBe(MockDefaultOrganization.id); + expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(args.onCreateChat).not.toHaveBeenCalled(); + expect( + localStorage.getItem(persistedAttachmentsStorageKey) ?? "", + ).toContain("file-other-org"); }, }; @@ -1052,3 +1538,49 @@ export const PermittedOrgsResolvesToSubset: Story = { expect(options.organizationId).toBe(MockOrganization2.id); }, }; + +/** + * Member-scoped roles like agents-access grant chat:create only on + * chats the user owns, so the per-org check must carry owner context + * for the picker to render. + */ +export const MemberScopedPermissionsShowOrgPicker: Story = { + parameters: { + showOrganizations: true, + organizations: [MockDefaultOrganization, MockOrganization2], + }, + beforeEach: () => { + spyOn(API, "getOrganizations").mockResolvedValue([ + MockDefaultOrganization, + MockOrganization2, + ]); + spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => + Object.fromEntries( + Object.entries(checks).map(([id, check]) => [ + id, + check.object.owner_id === "me", + ]), + ), + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const picker = await canvas.findByRole( + "button", + { name: /^Organization:/ }, + { timeout: 3000 }, + ); + await userEvent.click(picker); + await screen.findByRole("option", { + name: MockDefaultOrganization.display_name, + }); + await userEvent.click( + screen.getByRole("option", { name: MockOrganization2.display_name }), + ); + expect( + canvas.getByRole("button", { + name: `Organization: ${MockOrganization2.display_name}`, + }), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 358a1d70b3c1a..b81dbe083ebd2 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -1,6 +1,5 @@ -import { type FC, useEffect, useEffectEvent, useRef, useState } from "react"; +import { type FC, useEffect, useRef, useState } from "react"; import { useQuery } from "react-query"; -import { Link } from "react-router"; import { toast } from "sonner"; import { isApiError } from "#/api/errors"; import { permittedOrganizations } from "#/api/queries/organizations"; @@ -8,7 +7,6 @@ import type * as TypesGen from "#/api/typesGenerated"; import type { AgentChatSendShortcut } from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { docs } from "#/utils/docs"; @@ -273,42 +271,96 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ : undefined; const initialOrg = organizations.find((o) => o.is_default) ?? organizations[0]; + // effectiveWorkspaceId nulls a stored selection outside the effective org's + // filtered workspace list without deleting it. Preserve the stored value + // because the permitted-organizations query may resolve after mount and + // change the effective org. const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>( - () => { - const stored = localStorage.getItem(selectedWorkspaceIdStorageKey); - if (!stored) return null; - - // The stored value is kept optimistically until workspaces - // load. effectiveWorkspaceId (computed after render) drops - // it if it doesn't match the current org's workspaces. - if (workspaceOptions.length === 0) return stored; - - // Validate the stored workspace still exists and belongs - // to the initial org. Without this, a workspace from a - // previously selected org persists across sessions and - // gets submitted even though it's hidden from the picker. - const workspace = workspaceOptions.find((ws) => ws.id === stored); - if (!workspace) { - localStorage.removeItem(selectedWorkspaceIdStorageKey); - return null; - } - if ( - showOrganizations && - initialOrg && - workspace.organization_id !== initialOrg.id - ) { - localStorage.removeItem(selectedWorkspaceIdStorageKey); - return null; - } - return stored; - }, + () => localStorage.getItem(selectedWorkspaceIdStorageKey), ); const [selectedOrg, setSelectedOrg] = useState<TypesGen.Organization | null>( - initialOrg ?? null, + null, ); const [pendingOrgChange, setPendingOrgChange] = useState<TypesGen.Organization | null>(null); - const organizationId = selectedOrg?.id ?? ""; + const permittedOrgsQuery = useQuery({ + ...permittedOrganizations({ + // agents-access grants chat:create only at member scope. "me" is + // replaced with the caller ID so that permission can match. + object: { resource_type: "chat", owner_id: "me" }, + action: "create", + }), + enabled: showOrganizations, + }); + // Disabled queries retain cached data. When the dashboard hides organization + // selection, its organization list is authoritative so a removed org cannot + // remain selected for submission. + const permittedOrgs = showOrganizations + ? (permittedOrgsQuery.data ?? organizations) + : organizations; + // Treat the dashboard org as provisional until permissions resolve so + // sends and persisted attachments cannot use an unpermitted org. + const orgSelectionSettled = + !showOrganizations || permittedOrgsQuery.data !== undefined; + // Prevent effectiveOrg's dashboard fallback from bypassing an empty + // permitted set. + const noPermittedOrgs = + showOrganizations && permittedOrgsQuery.data?.length === 0; + const selectedOrgIsPermitted = + selectedOrg !== null && + permittedOrgs.some((org) => org.id === selectedOrg.id); + // Clear invalid selections during render so re-permission cannot silently + // restore them and switch attachment state. effectiveOrg already ignores + // the invalid selection in this render. + if (selectedOrg && orgSelectionSettled && !selectedOrgIsPermitted) { + setSelectedOrg(null); + } + // Same rule for a pending change awaiting confirmation: closing the + // dialog prevents confirming into an org that was just revoked. + if ( + pendingOrgChange && + orgSelectionSettled && + !permittedOrgs.some((org) => org.id === pendingOrgChange.id) + ) { + setPendingOrgChange(null); + } + const effectiveOrg = + selectedOrg && selectedOrgIsPermitted + ? selectedOrg + : (permittedOrgs.find((org) => org.is_default) ?? + permittedOrgs[0] ?? + initialOrg ?? + null); + const organizationId = effectiveOrg?.id ?? ""; + // Adopt a permitted fallback so later refetches cannot switch the form to a + // re-permitted default. The permission guard also avoids a render loop. + if ( + orgSelectionSettled && + !selectedOrg && + effectiveOrg && + permittedOrgs.some((org) => org.id === effectiveOrg.id) + ) { + setSelectedOrg(effectiveOrg); + } + // Clear a workspace after a settled org change, before its localStorage value + // is cleared post-commit. An empty permission set has no selectable org, so + // preserve the workspace until its org is re-permitted. + const [lastSettledOrgId, setLastSettledOrgId] = useState<string | null>(null); + if ( + orgSelectionSettled && + !noPermittedOrgs && + organizationId !== lastSettledOrgId + ) { + setLastSettledOrgId(organizationId); + if (lastSettledOrgId !== null) { + setSelectedWorkspaceId(null); + } + } + useEffect(() => { + if (selectedWorkspaceId === null) { + localStorage.removeItem(selectedWorkspaceIdStorageKey); + } + }, [selectedWorkspaceId]); const [planModeEnabled, setPlanModeEnabled] = useState(false); const hasModelOptions = modelOptions.length > 0; const hasConfiguredModels = hasConfiguredModelsInCatalog(modelCatalog); @@ -379,7 +431,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ saveReasoningEffortForModel(selectedModel, value); }; - const isForbidden = !canCreateChat; + const isForbidden = !canCreateChat || noPermittedOrgs; // Filter workspaces by the selected organization. We use // client-side filtering of the full "owner:me" fetch rather @@ -390,8 +442,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ // enough to warrant pagination, this should switch to a // server-side organization:<name> query filter. const filteredWorkspaces = - showOrganizations && selectedOrg - ? workspaceOptions.filter((ws) => ws.organization_id === selectedOrg.id) + showOrganizations && effectiveOrg + ? workspaceOptions.filter((ws) => ws.organization_id === effectiveOrg.id) : workspaceOptions; const effectiveWorkspaceId = @@ -400,6 +452,11 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ filteredWorkspaces.some((ws) => ws.id === selectedWorkspaceId)) ? selectedWorkspaceId : null; + // A stored workspace cannot be validated against the effective org + // until the list loads; sending then would silently drop the + // association, so Send stays disabled instead. + const workspaceValidationPending = + selectedWorkspaceId !== null && isWorkspacesLoading; const handleSend = async (message: string, fileIDs?: string[]) => { submitDraft(); @@ -422,6 +479,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ }; const { + organizationAdopted, attachments, textContents, uploadStates, @@ -429,10 +487,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ handleAttach, handleRemoveAttachment, resetAttachments, - } = useFileAttachments(organizationId || undefined, { - persist: true, - provider: getProviderForModelOption(modelOptions, selectedModel), - }); + } = useFileAttachments( + // Avoid restoring against effectiveOrg's fallback when no org is permitted; + // that would prune attachments persisted for other orgs. + orgSelectionSettled && !noPermittedOrgs + ? organizationId || undefined + : undefined, + { + persist: true, + provider: getProviderForModelOption(modelOptions, selectedModel), + }, + ); const handleSendWithAttachments = async (message: string) => { const fileIds: string[] = []; @@ -461,50 +526,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ } }; - const permittedOrgsQuery = useQuery({ - ...permittedOrganizations({ - object: { resource_type: "chat" }, - action: "create", - }), - enabled: showOrganizations, - }); - const permittedOrgs = permittedOrgsQuery.data ?? organizations; - - // Reconcile selectedOrg when permission filtering removes it. - // Only pure state setters run during render; side effects - // (localStorage, blob URL cleanup) run in the effect below. - const [prevPermittedOrgs, setPrevPermittedOrgs] = useState(permittedOrgs); - const [orgWasAdjusted, setOrgWasAdjusted] = useState(false); - if (permittedOrgs !== prevPermittedOrgs) { - setPrevPermittedOrgs(permittedOrgs); - if (selectedOrg && !permittedOrgs.some((o) => o.id === selectedOrg.id)) { - // Fall back through: first permitted org, then the - // dashboard default. Never null out selectedOrg. - // organizationId must always be a valid UUID for the - // create-chat request. - const nextOrg = permittedOrgs[0] ?? initialOrg ?? null; - setSelectedOrg(nextOrg); - if (nextOrg?.id !== selectedOrg.id) { - setOrgWasAdjusted(true); - } - } - } - - // Clean up workspace and attachment state after a programmatic - // org change from permission filtering. These calls have side - // effects (localStorage, blob URL revocation) that must not - // run during render. - const onOrgAdjusted = useEffectEvent(() => { - handleWorkspaceChange(null); - resetAttachments(); - }); - useEffect(() => { - if (orgWasAdjusted) { - setOrgWasAdjusted(false); - onOrgAdjusted(); - } - }, [orgWasAdjusted]); - return ( <> <div className="order-last flex min-h-0 flex-none items-end justify-center overflow-auto px-4 pb-4 sm:order-none sm:h-full sm:flex-1 sm:items-center"> @@ -515,14 +536,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ isApiError(createError) && createError.response?.status === 409 && isChatUsageLimitExceededResponse(createError.response.data) ? ( - <Alert - severity="info" - actions={ - <Button asChild size="sm"> - <Link to="/agents/analytics">View usage</Link> - </Button> - } - > + <Alert severity="info"> <AlertDescription> {formatUsageLimitMessage(createError.response.data)} </AlertDescription> @@ -535,23 +549,27 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ {permittedOrgsQuery.error != null && ( <ErrorAlert error={permittedOrgsQuery.error} /> )} - {showOrganizations && permittedOrgs.length > 1 && ( - <CompactOrgSelector - value={selectedOrg} - options={permittedOrgs} - onChange={(newOrg) => { - const orgChanged = newOrg.id !== selectedOrg?.id; - if (orgChanged && attachments.length > 0) { - setPendingOrgChange(newOrg); - return; - } - if (orgChanged) { - handleWorkspaceChange(null); - } - setSelectedOrg(newOrg); - }} - /> - )} + {/* The pre-settlement list is the unfiltered dashboard fallback; + selecting from it could destroy existing workspace state. */} + {showOrganizations && + orgSelectionSettled && + permittedOrgs.length > 1 && ( + <CompactOrgSelector + value={effectiveOrg} + options={permittedOrgs} + onChange={(newOrg) => { + const orgChanged = newOrg.id !== effectiveOrg?.id; + if (orgChanged && attachments.length > 0) { + setPendingOrgChange(newOrg); + return; + } + if (orgChanged) { + handleWorkspaceChange(null); + } + setSelectedOrg(newOrg); + }} + /> + )} <AgentChatInput onSend={handleSendWithAttachments} sendShortcut={sendShortcut} @@ -559,6 +577,10 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ isDisabled={ isCreating || isForbidden || + !orgSelectionSettled || + // Sending before adoption would omit persisted files not yet restored. + !organizationAdopted || + workspaceValidationPending || isPersonalModelOverridesLoading || !hasModelOptions || Boolean(aiGatewayDisabled) @@ -578,7 +600,9 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ planModeEnabled={planModeEnabled} onPlanModeToggle={setPlanModeEnabled} attachments={attachments} - onAttach={handleAttach} + // Files attached before org adoption cannot upload and would be discarded + // when restoration completes. + onAttach={organizationAdopted ? handleAttach : undefined} onRemoveAttachment={handleRemoveAttachment} uploadStates={uploadStates} previewUrls={previewUrls} @@ -592,7 +616,12 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ onMCPAuthComplete={onMCPAuthComplete} workspaceOptions={filteredWorkspaces} selectedWorkspaceId={effectiveWorkspaceId} - onWorkspaceChange={handleWorkspaceChange} + // Do not persist a workspace until its organization is authorized. + onWorkspaceChange={ + orgSelectionSettled && !noPermittedOrgs + ? handleWorkspaceChange + : undefined + } isWorkspaceLoading={isWorkspacesLoading} canConfigureAgentSetup={canConfigureAgentSetup} providerCount={providerCount} @@ -626,10 +655,18 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({ hideCancel={false} confirmText="Continue" onConfirm={() => { + if (!pendingOrgChange) { + return; + } + setPendingOrgChange(null); + // Recheck authorization because a refetch may revoke the pending org + // after this render created the closure. + if (!permittedOrgs.some((org) => org.id === pendingOrgChange.id)) { + return; + } resetAttachments(); handleWorkspaceChange(null); setSelectedOrg(pendingOrgChange); - setPendingOrgChange(null); }} onClose={() => setPendingOrgChange(null)} /> diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index 0cf96976d4a1a..748f650fb91db 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -40,7 +40,6 @@ export const EmptyConversationPrompt: Story = { }, }; -/** Usage-limit failures replace the idle prompt with the analytics CTA. */ export const UsageLimitExceeded: Story = { args: { ...defaultArgs, @@ -55,17 +54,12 @@ export const UsageLimitExceeded: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getByText(/spend limit/i)).toBeVisible(); - const link = canvas.getByRole("link", { name: /view usage/i }); - expect(link).toBeVisible(); - expect(link).toHaveAttribute("href", "/agents/analytics"); + expect( + canvas.queryByRole("link", { name: /view usage/i }), + ).not.toBeInTheDocument(); }, }; -/** - * Provider quota errors use the standard ChatStatusCallout instead of the - * "View usage" CTA (which links to Coder's analytics, not the provider's - * billing page). - */ export const ProviderQuotaExceeded: Story = { args: { ...defaultArgs, @@ -87,7 +81,6 @@ export const ProviderQuotaExceeded: Story = { expect( canvas.queryByRole("link", { name: /view usage/i }), ).not.toBeInTheDocument(); - // Should render ChatStatusCallout instead. expect( canvas.getByRole("heading", { name: /usage limit reached/i }), ).toBeVisible(); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index b4b732a21b939..48efbbd58eeb5 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx @@ -1,8 +1,6 @@ -import { Link } from "react-router"; import type { UrlTransform } from "streamdown"; import type * as TypesGen from "#/api/typesGenerated"; import { Alert, AlertDescription } from "#/components/Alert/Alert"; -import { Button } from "#/components/Button/Button"; import type { ChatDetailError } from "../../utils/usageLimitMessage"; import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor"; import { ChatStatusCallout } from "./ChatStatusCallout"; @@ -94,14 +92,7 @@ export const LiveStreamTailContent = ({ /> )} {usageLimitStatus && !usageLimitStatus.provider ? ( - <Alert - severity="info" - actions={ - <Button asChild size="sm"> - <Link to="/agents/analytics">View usage</Link> - </Button> - } - > + <Alert severity="info"> <AlertDescription>{usageLimitStatus.message}</AlertDescription> </Alert> ) : terminalStatus ? ( diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/index.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/index.test.ts index 53c9fa3a3b9ef..80a7ecc47a81a 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/index.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/index.test.ts @@ -50,7 +50,7 @@ describe("formatContextBadge", () => { describe("getKnownModelsForProvider", () => { it("returns unsupported provider as an empty list", () => { - expect(getKnownModelsForProvider("azure")).toEqual([]); + expect(getKnownModelsForProvider("openai-compat")).toEqual([]); }); it("returns empty provider as an empty list", () => { diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json index bfcb8c0a23aff..5ae75467a2813 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json @@ -118,6 +118,234 @@ "cacheWriteCost": 3.75 } ], + "azure": [ + { + "provider": "azure", + "modelIdentifier": "claude-fable-5", + "displayName": "Claude Fable 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 10, + "outputCost": 50, + "cacheReadCost": 1, + "cacheWriteCost": 12.5 + }, + { + "provider": "azure", + "modelIdentifier": "claude-opus-5", + "displayName": "Claude Opus 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 25, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "azure", + "modelIdentifier": "claude-sonnet-5", + "displayName": "Claude Sonnet 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 2, + "outputCost": 10, + "cacheReadCost": 0.2, + "cacheWriteCost": 2.5 + }, + { + "provider": "azure", + "modelIdentifier": "claude-opus-4-8", + "displayName": "Claude Opus 4.8", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 25, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "azure", + "modelIdentifier": "claude-sonnet-4-6", + "displayName": "Claude Sonnet 4.6", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 64000, + "inputCost": 3, + "outputCost": 15, + "cacheReadCost": 0.3, + "cacheWriteCost": 3.75 + }, + { + "provider": "azure", + "modelIdentifier": "claude-haiku-4-5", + "displayName": "Claude Haiku 4.5", + "aliases": [], + "contextLimit": 200000, + "maxOutputTokens": 64000, + "inputCost": 1, + "outputCost": 5, + "cacheReadCost": 0.1, + "cacheWriteCost": 1.25 + }, + { + "provider": "azure", + "modelIdentifier": "gpt-5.6-sol", + "displayName": "GPT-5.6 Sol", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 30, + "cacheReadCost": 0.5 + }, + { + "provider": "azure", + "modelIdentifier": "gpt-5.6-terra", + "displayName": "GPT-5.6 Terra", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 2.5, + "outputCost": 15, + "cacheReadCost": 0.25, + "cacheWriteCost": 3.125 + }, + { + "provider": "azure", + "modelIdentifier": "gpt-5.6-luna", + "displayName": "GPT-5.6 Luna", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 1, + "outputCost": 6, + "cacheReadCost": 0.1, + "cacheWriteCost": 1.25 + }, + { + "provider": "azure", + "modelIdentifier": "gpt-5.5", + "displayName": "GPT-5.5", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 30, + "cacheReadCost": 0.5 + }, + { + "provider": "azure", + "modelIdentifier": "deepseek-v4-flash", + "displayName": "DeepSeek-V4-Flash", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 384000, + "inputCost": 0.19, + "outputCost": 0.51 + } + ], + "bedrock": [ + { + "provider": "bedrock", + "modelIdentifier": "anthropic.claude-haiku-4-5-20251001-v1:0", + "displayName": "Claude Haiku 4.5", + "aliases": ["global.anthropic.claude-haiku-4-5-20251001-v1:0"], + "contextLimit": 200000, + "maxOutputTokens": 64000, + "inputCost": 1, + "outputCost": 5, + "cacheReadCost": 0.1, + "cacheWriteCost": 1.25 + }, + { + "provider": "bedrock", + "modelIdentifier": "anthropic.claude-sonnet-5", + "displayName": "Claude Sonnet 5", + "aliases": ["global.anthropic.claude-sonnet-5"], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 2, + "outputCost": 10, + "cacheReadCost": 0.2, + "cacheWriteCost": 2.5 + }, + { + "provider": "bedrock", + "modelIdentifier": "anthropic.claude-opus-5", + "displayName": "Claude Opus 5", + "aliases": ["global.anthropic.claude-opus-5"], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 25, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "bedrock", + "modelIdentifier": "anthropic.claude-fable-5", + "displayName": "Claude Fable 5", + "aliases": ["global.anthropic.claude-fable-5"], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 10, + "outputCost": 50, + "cacheReadCost": 1, + "cacheWriteCost": 12.5 + }, + { + "provider": "bedrock", + "modelIdentifier": "anthropic.claude-sonnet-4-6", + "displayName": "Claude Sonnet 4.6", + "aliases": ["global.anthropic.claude-sonnet-4-6"], + "contextLimit": 1000000, + "maxOutputTokens": 64000, + "inputCost": 3, + "outputCost": 15, + "cacheReadCost": 0.3, + "cacheWriteCost": 3.75 + }, + { + "provider": "bedrock", + "modelIdentifier": "anthropic.claude-opus-4-8", + "displayName": "Claude Opus 4.8", + "aliases": ["global.anthropic.claude-opus-4-8"], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 25, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + } + ], + "google": [ + { + "provider": "google", + "modelIdentifier": "gemini-flash-latest", + "displayName": "Gemini Flash Latest", + "aliases": [], + "contextLimit": 1048576, + "maxOutputTokens": 65536, + "inputCost": 1.5, + "outputCost": 9, + "cacheReadCost": 0.15 + }, + { + "provider": "google", + "modelIdentifier": "gemini-3.6-flash", + "displayName": "Gemini 3.6 Flash", + "aliases": [], + "contextLimit": 1048576, + "maxOutputTokens": 65536, + "inputCost": 1.5, + "outputCost": 7.5, + "cacheReadCost": 0.15 + } + ], "openai": [ { "provider": "openai", @@ -140,10 +368,10 @@ "contextLimit": 1050000, "maxOutputTokens": 128000, "reasoningEffort": "medium", - "inputCost": 2.5, - "outputCost": 15, - "cacheReadCost": 0.25, - "cacheWriteCost": 3.125 + "inputCost": 2, + "outputCost": 12, + "cacheReadCost": 0.2, + "cacheWriteCost": 2.5 }, { "provider": "openai", @@ -153,10 +381,10 @@ "contextLimit": 1050000, "maxOutputTokens": 128000, "reasoningEffort": "medium", - "inputCost": 1, - "outputCost": 6, - "cacheReadCost": 0.1, - "cacheWriteCost": 1.25 + "inputCost": 0.2, + "outputCost": 1.2, + "cacheReadCost": 0.02, + "cacheWriteCost": 0.25 }, { "provider": "openai", @@ -227,5 +455,356 @@ "outputCost": 14, "cacheReadCost": 0.175 } + ], + "openrouter": [ + { + "provider": "openrouter", + "modelIdentifier": "openai/gpt-5.6-sol", + "displayName": "GPT-5.6 Sol", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 30, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "openrouter", + "modelIdentifier": "openai/gpt-5.6-terra", + "displayName": "GPT-5.6 Terra", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 1, + "outputCost": 6, + "cacheReadCost": 0.1, + "cacheWriteCost": 1.25 + }, + { + "provider": "openrouter", + "modelIdentifier": "openai/gpt-5.6-luna", + "displayName": "GPT-5.6 Luna", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 0.1, + "outputCost": 0.6, + "cacheReadCost": 0.01, + "cacheWriteCost": 0.125 + }, + { + "provider": "openrouter", + "modelIdentifier": "openai/gpt-oss-120b", + "displayName": "GPT OSS 120B", + "aliases": [], + "contextLimit": 131072, + "maxOutputTokens": 131072, + "inputCost": 0.037, + "outputCost": 0.17 + }, + { + "provider": "openrouter", + "modelIdentifier": "moonshotai/kimi-k3", + "displayName": "Kimi K3", + "aliases": [], + "contextLimit": 1048576, + "maxOutputTokens": 1048576, + "inputCost": 3, + "outputCost": 15, + "cacheReadCost": 0.3 + }, + { + "provider": "openrouter", + "modelIdentifier": "z-ai/glm-5.2", + "displayName": "GLM-5.2", + "aliases": [], + "contextLimit": 1048576, + "maxOutputTokens": 262144, + "inputCost": 0.76, + "outputCost": 2.42, + "cacheReadCost": 0.14 + }, + { + "provider": "openrouter", + "modelIdentifier": "anthropic/claude-fable-5", + "displayName": "Claude Fable 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 10, + "outputCost": 50, + "cacheReadCost": 1, + "cacheWriteCost": 12.5 + }, + { + "provider": "openrouter", + "modelIdentifier": "anthropic/claude-opus-5", + "displayName": "Claude Opus 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 25, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "openrouter", + "modelIdentifier": "anthropic/claude-sonnet-5", + "displayName": "Claude Sonnet 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 2, + "outputCost": 10, + "cacheReadCost": 0.2, + "cacheWriteCost": 2.5 + }, + { + "provider": "openrouter", + "modelIdentifier": "anthropic/claude-opus-4.8", + "displayName": "Claude Opus 4.8", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 25, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "openrouter", + "modelIdentifier": "anthropic/claude-sonnet-4.6", + "displayName": "Claude Sonnet 4.6", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 3, + "outputCost": 15, + "cacheReadCost": 0.3, + "cacheWriteCost": 3.75 + }, + { + "provider": "openrouter", + "modelIdentifier": "anthropic/claude-haiku-4.5", + "displayName": "Claude Haiku 4.5 (latest)", + "aliases": [], + "contextLimit": 200000, + "maxOutputTokens": 64000, + "inputCost": 1, + "outputCost": 5, + "cacheReadCost": 0.1, + "cacheWriteCost": 1.25 + }, + { + "provider": "openrouter", + "modelIdentifier": "deepseek/deepseek-v4-flash", + "displayName": "DeepSeek V4 Flash", + "aliases": [], + "contextLimit": 1048576, + "maxOutputTokens": 393216, + "inputCost": 0.14, + "outputCost": 0.28, + "cacheReadCost": 0.028 + }, + { + "provider": "openrouter", + "modelIdentifier": "deepseek/deepseek-v4-pro", + "displayName": "DeepSeek V4 Pro", + "aliases": [], + "contextLimit": 1048576, + "maxOutputTokens": 384000, + "inputCost": 0.435, + "outputCost": 0.87, + "cacheReadCost": 0.003625 + }, + { + "provider": "openrouter", + "modelIdentifier": "qwen/qwen3.8-max", + "displayName": "Qwen3.8 Max", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 131072, + "inputCost": 2, + "outputCost": 6, + "cacheReadCost": 0.25, + "cacheWriteCost": 2.5 + }, + { + "provider": "openrouter", + "modelIdentifier": "qwen/qwen3-coder-next", + "displayName": "Qwen3 Coder Next", + "aliases": [], + "contextLimit": 262144, + "maxOutputTokens": 262144, + "inputCost": 0.12, + "outputCost": 0.8, + "cacheReadCost": 0.07 + } + ], + "vercel": [ + { + "provider": "vercel", + "modelIdentifier": "openai/gpt-5.6-sol", + "displayName": "GPT 5.6 Sol", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 30, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "vercel", + "modelIdentifier": "openai/gpt-5.6-terra", + "displayName": "GPT 5.6 Terra", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 2, + "outputCost": 12, + "cacheReadCost": 0.2, + "cacheWriteCost": 2.5 + }, + { + "provider": "vercel", + "modelIdentifier": "openai/gpt-5.6-luna", + "displayName": "GPT 5.6 Luna", + "aliases": [], + "contextLimit": 1050000, + "maxOutputTokens": 128000, + "inputCost": 0.2, + "outputCost": 1.2, + "cacheReadCost": 0.02, + "cacheWriteCost": 0.25 + }, + { + "provider": "vercel", + "modelIdentifier": "openai/gpt-oss-120b", + "displayName": "GPT OSS 120B", + "aliases": [], + "contextLimit": 131072, + "maxOutputTokens": 131072, + "inputCost": 0.1, + "outputCost": 0.5 + }, + { + "provider": "vercel", + "modelIdentifier": "moonshotai/kimi-k3", + "displayName": "Kimi K3", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 131072, + "inputCost": 3, + "outputCost": 15, + "cacheReadCost": 0.3 + }, + { + "provider": "vercel", + "modelIdentifier": "zai/glm-5.2", + "displayName": "GLM 5.2", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 1.1, + "outputCost": 3.851, + "cacheReadCost": 0.275 + }, + { + "provider": "vercel", + "modelIdentifier": "anthropic/claude-fable-5", + "displayName": "Claude Fable 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 10, + "outputCost": 50, + "cacheReadCost": 1, + "cacheWriteCost": 12.5 + }, + { + "provider": "vercel", + "modelIdentifier": "anthropic/claude-opus-5", + "displayName": "Claude Opus 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 25, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "vercel", + "modelIdentifier": "anthropic/claude-sonnet-5", + "displayName": "Claude Sonnet 5", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 2, + "outputCost": 10, + "cacheReadCost": 0.2, + "cacheWriteCost": 2.5 + }, + { + "provider": "vercel", + "modelIdentifier": "anthropic/claude-opus-4.8", + "displayName": "Claude Opus 4.8", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 5, + "outputCost": 25, + "cacheReadCost": 0.5, + "cacheWriteCost": 6.25 + }, + { + "provider": "vercel", + "modelIdentifier": "anthropic/claude-sonnet-4.6", + "displayName": "Claude Sonnet 4.6", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 128000, + "inputCost": 3, + "outputCost": 15, + "cacheReadCost": 0.3, + "cacheWriteCost": 3.75 + }, + { + "provider": "vercel", + "modelIdentifier": "anthropic/claude-haiku-4.5", + "displayName": "Claude Haiku 4.5", + "aliases": [], + "contextLimit": 200000, + "maxOutputTokens": 64000, + "inputCost": 1, + "outputCost": 5, + "cacheReadCost": 0.1, + "cacheWriteCost": 1.25 + }, + { + "provider": "vercel", + "modelIdentifier": "deepseek/deepseek-v4-flash", + "displayName": "DeepSeek V4 Flash", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 384000, + "inputCost": 0.2, + "outputCost": 0.4, + "cacheReadCost": 0.04 + }, + { + "provider": "vercel", + "modelIdentifier": "deepseek/deepseek-v4-pro", + "displayName": "DeepSeek V4 Pro", + "aliases": [], + "contextLimit": 1000000, + "maxOutputTokens": 384000, + "inputCost": 0.435, + "outputCost": 0.87, + "cacheReadCost": 0.0036 + } ] } diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx index 1d177bfbb2776..09a671e064160 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx @@ -1,12 +1,12 @@ import type { Decorator, Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; import { useQueryClient } from "react-query"; -import { expect, userEvent, within } from "storybook/test"; -import { chatUsageLimitStatusKey } from "#/api/queries/chats"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { meAISpendKey } from "#/api/queries/users"; import { getWorkspaceQuotaQueryKey } from "#/api/queries/workspaceQuota"; import { workspacesKey } from "#/api/queries/workspaces"; import type { - ChatUsageLimitStatus, + UserAISpendStatus, WorkspaceQuota, WorkspacesResponse, } from "#/api/typesGenerated"; @@ -21,9 +21,9 @@ import { } from "#/testHelpers/storybook"; import { UsageIndicator } from "./UsageIndicator"; -const withUsageLimitStatus = (status: ChatUsageLimitStatus) => (Story: FC) => { +const withAISpend = (status: UserAISpendStatus) => (Story: FC) => { const queryClient = useQueryClient(); - queryClient.setQueryData(chatUsageLimitStatusKey, status); + queryClient.setQueryData(meAISpendKey, status); return <Story />; }; @@ -79,22 +79,23 @@ const openUsageMenu = async (canvasElement: HTMLElement) => { await userEvent.click(canvas.getByRole("button")); }; -const limitedUsageStatus = ( - overrides: Partial<ChatUsageLimitStatus> = {}, -): ChatUsageLimitStatus => ({ - is_limited: true, - period: "month", - spend_limit_micros: 50_000_000, - current_spend: 12_500_000, - period_start: "2026-02-10T00:00:00Z", - period_end: "2026-03-12T00:00:00Z", +const aiSpendStatus = ( + overrides: Partial<UserAISpendStatus> = {}, +): UserAISpendStatus => ({ + user_id: MockUserOwner.id, + effective_group_id: "group-1", + effective_budget: { spend_limit_micros: 50_000_000, limit_source: "group" }, + current_spend_micros: 12_500_000, + period_start: "2026-07-01T00:00:00Z", + period_end: "2026-08-01T00:00:00Z", ...overrides, }); -const unlimitedUsageStatus = { - is_limited: false, - current_spend: 0, -} satisfies ChatUsageLimitStatus; +const noBudgetStatus = aiSpendStatus({ + effective_group_id: null, + effective_budget: null, + current_spend_micros: 0, +}); const userWorkspacesRequest = { q: `owner:me organization:${MockDefaultOrganization.name}`, @@ -120,6 +121,7 @@ const meta: Meta<typeof UsageIndicator> = { parameters: { user: MockUserOwner, permissions: MockPermissions, + features: ["aibridge"], }, }; @@ -128,18 +130,20 @@ type Story = StoryObj<typeof UsageIndicator>; export const LowUsage: Story = { decorators: [ - withUsageLimitStatus(limitedUsageStatus()), + withAISpend(aiSpendStatus()), withWorkspaceQuota(noWorkspaceQuota), ], }; export const MediumUsage: Story = { decorators: [ - withUsageLimitStatus( - limitedUsageStatus({ - period: "week", - spend_limit_micros: 20_000_000, - current_spend: 16_000_000, + withAISpend( + aiSpendStatus({ + effective_budget: { + spend_limit_micros: 20_000_000, + limit_source: "group", + }, + current_spend_micros: 16_000_000, }), ), withWorkspaceQuota(noWorkspaceQuota), @@ -148,11 +152,13 @@ export const MediumUsage: Story = { export const HighUsage: Story = { decorators: [ - withUsageLimitStatus( - limitedUsageStatus({ - period: "day", - spend_limit_micros: 10_000_000, - current_spend: 9_500_000, + withAISpend( + aiSpendStatus({ + effective_budget: { + spend_limit_micros: 10_000_000, + limit_source: "group", + }, + current_spend_micros: 9_500_000, }), ), withWorkspaceQuota(noWorkspaceQuota), @@ -161,10 +167,13 @@ export const HighUsage: Story = { export const LimitExceeded: Story = { decorators: [ - withUsageLimitStatus( - limitedUsageStatus({ - spend_limit_micros: 30_000_000, - current_spend: 32_000_000, + withAISpend( + aiSpendStatus({ + effective_budget: { + spend_limit_micros: 30_000_000, + limit_source: "group", + }, + current_spend_micros: 32_000_000, }), ), withWorkspaceQuota(noWorkspaceQuota), @@ -173,7 +182,7 @@ export const LimitExceeded: Story = { export const WorkspaceQuotaOnly: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota(defaultWorkspaceQuota), withWorkspaceCount(3), ], @@ -189,7 +198,7 @@ export const WorkspaceQuotaOnly: Story = { export const UsageAndWorkspaceQuota: Story = { decorators: [ - withUsageLimitStatus(limitedUsageStatus()), + withAISpend(aiSpendStatus()), withWorkspaceQuota(defaultWorkspaceQuota), withWorkspaceCount(3), ], @@ -199,18 +208,23 @@ export const UsageAndWorkspaceQuota: Story = { expect(canvas.getByRole("button", { name: "Usage" })).toBeVisible(); expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([ - "Monthly spend usage", + "AI spend usage", "Workspace quota usage", ]); await openUsageMenu(canvasElement); + const menu = within(await within(document.body).findByRole("menu")); + await waitFor(() => { + expect(menu.getByText("$12.50 of $50.00 used")).toBeVisible(); + expect(menu.getByText("July 1 - August 1, 2026")).toBeVisible(); + }); }, }; export const TriggerTiny: Story = { decorators: [ withUsageIndicatorFrame("w-[240px]", "usage-indicator-frame"), - withUsageLimitStatus(limitedUsageStatus()), + withAISpend(aiSpendStatus()), withWorkspaceQuota(defaultWorkspaceQuota), withWorkspaceCount(3), ], @@ -218,7 +232,7 @@ export const TriggerTiny: Story = { export const WorkspaceQuotaUnused: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota({ credits_consumed: 0, budget: 100, @@ -233,7 +247,7 @@ export const WorkspaceQuotaUnused: Story = { export const WorkspaceQuotaWithoutBudget: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota({ credits_consumed: 20, budget: 0, @@ -258,7 +272,7 @@ export const WorkspaceQuotaWithoutBudget: Story = { export const WorkspaceQuotaExceeded: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota({ credits_consumed: 125, budget: 100, @@ -272,7 +286,7 @@ export const WorkspaceQuotaExceeded: Story = { export const WorkspaceQuotaWithoutWorkspaceCount: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota(defaultWorkspaceQuota), withUnavailableWorkspaceCount, ], @@ -283,7 +297,45 @@ export const WorkspaceQuotaWithoutWorkspaceCount: Story = { export const NotLimited: Story = { decorators: [ - withUsageLimitStatus(unlimitedUsageStatus), + withAISpend(noBudgetStatus), withWorkspaceQuota(noWorkspaceQuota), ], }; + +export const ZeroBudget: Story = { + decorators: [ + withAISpend( + aiSpendStatus({ + effective_budget: { spend_limit_micros: 0, limit_source: "group" }, + current_spend_micros: 0, + }), + ), + withWorkspaceQuota(noWorkspaceQuota), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = await canvas.findByRole("button"); + await userEvent.click(trigger); + const body = within(canvasElement.ownerDocument.body); + await body.findByText(/limit exceeded/); + }, +}; + +export const GatewayUnavailable: Story = { + parameters: { features: [], experiments: [] }, + decorators: [ + withAISpend(aiSpendStatus()), + withWorkspaceQuota(defaultWorkspaceQuota), + withWorkspaceCount(3), + ], + play: async ({ canvasElement }) => { + await openUsageMenu(canvasElement); + const progressBars = within(canvasElement.ownerDocument.body).getAllByRole( + "progressbar", + ); + + expect(progressBars.map((bar) => bar.getAttribute("aria-label"))).toEqual([ + "Workspace quota usage", + ]); + }, +}; diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 3e582864daf9e..872e20b76a1ff 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -1,15 +1,12 @@ -import dayjs from "dayjs"; import { CoinsIcon, InfoIcon, ServerIcon } from "lucide-react"; import { type FC, Fragment, type ReactNode } from "react"; import { useQuery } from "react-query"; -import { Link } from "react-router"; -import { chatUsageLimitStatus } from "#/api/queries/chats"; +import { meAISpend } from "#/api/queries/users"; import { workspaceQuota } from "#/api/queries/workspaceQuota"; import { workspaces } from "#/api/queries/workspaces"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; @@ -25,9 +22,10 @@ import { getDefaultOrganizationName, useDashboard, } from "#/modules/dashboard/useDashboard"; -import { getUsageLimitPeriodLabel } from "#/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView"; +import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { clampPercentage, + formatSpendPeriodLabel, getSeverity, type UsageSeverity, usageProgressPercentage, @@ -52,11 +50,13 @@ type UsageSectionData = { const numberFormatter = new Intl.NumberFormat("en-US"); export const UsageIndicator: FC = () => { - const { data: chatUsage, isError: isChatUsageError } = useQuery( - chatUsageLimitStatus(), - ); const { user } = useAuthenticated(); const { organizations } = useDashboard(); + const aiSpendAvailable = Boolean(useFeatureVisibility().aibridge); + const { data: aiSpend, isError: isAISpendError } = useQuery({ + ...meAISpend(), + enabled: aiSpendAvailable, + }); const organizationName = getDefaultOrganizationName(organizations); const username = user.username; const { data: quota, isError: isQuotaError } = useQuery({ @@ -75,18 +75,26 @@ export const UsageIndicator: FC = () => { }); const sections: UsageSectionData[] = []; - if (!isChatUsageError && chatUsage?.is_limited) { - const spendLimit = chatUsage.spend_limit_micros ?? 0; - const currentSpend = chatUsage.current_spend; - const periodLabel = getUsageLimitPeriodLabel(chatUsage.period); - const exceeded = spendLimit > 0 && currentSpend >= spendLimit; + if ( + aiSpendAvailable && + !isAISpendError && + aiSpend && + aiSpend.effective_budget !== null + ) { + const spendLimit = aiSpend.effective_budget.spend_limit_micros; + const currentSpend = aiSpend.current_spend_micros; + // The gateway blocks once spend >= limit, so a zero budget is + // always exhausted. + const exceeded = currentSpend >= spendLimit; sections.push({ - id: "ai-usage", - title: `${periodLabel} usage`, - progressLabel: `${periodLabel} spend usage`, - percent: usageProgressPercentage(currentSpend, spendLimit), - severity: getSeverity(currentSpend, spendLimit), + id: "ai-spend", + title: "AI spend", + progressLabel: "AI spend usage", + percent: exceeded + ? 100 + : usageProgressPercentage(currentSpend, spendLimit), + severity: exceeded ? "exceeded" : getSeverity(currentSpend, spendLimit), icon: <CoinsIcon className="size-3.5" />, hoverLabel: `Spend ${formatCostMicros(currentSpend)}`, detail: ( @@ -100,9 +108,10 @@ export const UsageIndicator: FC = () => { )} </> ), - secondaryDetail: chatUsage.period_end - ? `Resets ${dayjs(chatUsage.period_end).format("MMM D, YYYY")}` - : undefined, + secondaryDetail: formatSpendPeriodLabel( + aiSpend.period_start, + aiSpend.period_end, + ), }); } @@ -167,12 +176,6 @@ const UsageMenu: FC<{ sections: readonly UsageSectionData[] }> = ({ <UsageSection section={section} /> </Fragment> ))} - - <DropdownMenuSeparator /> - - <DropdownMenuItem asChild> - <Link to="/agents/analytics">View usage</Link> - </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx new file mode 100644 index 0000000000000..38d82886b3320 --- /dev/null +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx @@ -0,0 +1,216 @@ +import { act, render, renderHook, waitFor } from "@testing-library/react"; +import { type FC, Suspense } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { API } from "#/api/api"; +import { createDeferred } from "#/testHelpers/deferred"; +import { + persistedAttachmentsStorageKey, + useFileAttachments, +} from "./useFileAttachments"; + +const persistEntry = (fileId: string, fileName: string, orgId: string) => ({ + fileId, + fileName, + fileType: "text/plain", + lastModified: 1000, + organizationId: orgId, +}); + +const uploadedFileIds = ( + result: ReturnType<typeof useFileAttachments>, +): string[] => + [...result.uploadStates.values()].flatMap((state) => + state.status === "uploaded" && state.fileId ? [state.fileId] : [], + ); + +const renderAttachments = (initialProps: { orgId: string | undefined }) => + renderHook( + ({ orgId }: { orgId: string | undefined }) => + useFileAttachments(orgId, { persist: true }), + { initialProps }, + ); + +const mockDeferredUpload = (): ((value: { id: string }) => void) => { + const deferred = createDeferred<{ id: string }>(); + vi.spyOn(API.experimental, "uploadChatFile").mockReturnValue( + deferred.promise, + ); + return deferred.resolve; +}; + +describe("useFileAttachments org scoping", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defers restoration until the org is known", () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-b", "b.txt", "org-b")]), + ); + const { result, rerender } = renderAttachments({ orgId: undefined }); + + expect(result.current.attachments).toHaveLength(0); + expect(localStorage.getItem(persistedAttachmentsStorageKey)).toContain( + "file-b", + ); + + rerender({ orgId: "org-b" }); + expect(uploadedFileIds(result.current)).toStrictEqual(["file-b"]); + }); + + it("drops another org's attachments when the org changes", async () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + const { result, rerender } = renderAttachments({ orgId: "org-a" }); + expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); + rerender({ orgId: "org-b" }); + await waitFor(() => { + expect(uploadedFileIds(result.current)).toStrictEqual([]); + }); + expect( + localStorage.getItem(persistedAttachmentsStorageKey) ?? "", + ).not.toContain("file-a"); + }); + + it("never exposes the previous org's file IDs in any render", async () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + // Log the hook output of every render, including the + // intermediate commit between the org changing and the + // adoption effect running; that window must expose nothing. + const renderLog: { orgId: string; fileIds: string[] }[] = []; + const Probe: FC<{ orgId: string }> = ({ orgId }) => { + const result = useFileAttachments(orgId, { persist: true }); + renderLog.push({ orgId, fileIds: uploadedFileIds(result) }); + return null; + }; + const { rerender } = render(<Probe orgId="org-a" />); + expect(renderLog.at(-1)).toStrictEqual({ + orgId: "org-a", + fileIds: ["file-a"], + }); + + rerender(<Probe orgId="org-b" />); + await waitFor(() => { + expect(renderLog.at(-1)?.fileIds).toStrictEqual([]); + }); + const leaked = renderLog.filter( + (entry) => entry.orgId === "org-b" && entry.fileIds.includes("file-a"), + ); + expect(leaked).toStrictEqual([]); + }); + + it("reports adoption only after the post-commit effect", () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + const log: { adopted: boolean; fileIds: string[] }[] = []; + const Probe: FC<{ orgId: string }> = ({ orgId }) => { + const result = useFileAttachments(orgId, { persist: true }); + log.push({ + adopted: result.organizationAdopted, + fileIds: uploadedFileIds(result), + }); + return null; + }; + render(<Probe orgId="org-a" />); + expect(log[0]).toStrictEqual({ adopted: false, fileIds: [] }); + expect(log.at(-1)).toStrictEqual({ adopted: true, fileIds: ["file-a"] }); + }); + + it("discards an upload that completes after another org was adopted", async () => { + const resolveUpload = mockDeferredUpload(); + const { result, rerender } = renderAttachments({ orgId: "org-a" }); + act(() => { + result.current.startUpload(new File(["x"], "x.txt")); + }); + + rerender({ orgId: "org-b" }); + await waitFor(() => { + expect(result.current.organizationAdopted).toBe(true); + }); + await act(async () => { + resolveUpload({ id: "file-x" }); + }); + + expect(uploadedFileIds(result.current)).toStrictEqual([]); + expect( + localStorage.getItem(persistedAttachmentsStorageKey) ?? "", + ).not.toContain("file-x"); + }); + + it("discards an upload that spans an org round trip", async () => { + const resolveUpload = mockDeferredUpload(); + const { result, rerender } = renderAttachments({ orgId: "org-a" }); + act(() => { + result.current.startUpload(new File(["x"], "x.txt")); + }); + + // After A -> B -> A, only the adoption epoch distinguishes the stale upload. + rerender({ orgId: "org-b" }); + await waitFor(() => { + expect(result.current.organizationAdopted).toBe(true); + }); + rerender({ orgId: "org-a" }); + await waitFor(() => { + expect(result.current.organizationAdopted).toBe(true); + }); + await act(async () => { + resolveUpload({ id: "file-x" }); + }); + + expect(uploadedFileIds(result.current)).toStrictEqual([]); + expect( + localStorage.getItem(persistedAttachmentsStorageKey) ?? "", + ).not.toContain("file-x"); + }); + + it("hides attachments when authorization leaves no org", async () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + const { result, rerender } = renderAttachments({ orgId: "org-a" }); + await waitFor(() => { + expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); + }); + + rerender({ orgId: undefined }); + expect(result.current.attachments).toStrictEqual([]); + expect(uploadedFileIds(result.current)).toStrictEqual([]); + expect(localStorage.getItem(persistedAttachmentsStorageKey)).toContain( + "file-a", + ); + + rerender({ orgId: "org-a" }); + await waitFor(() => { + expect(uploadedFileIds(result.current)).toStrictEqual(["file-a"]); + }); + }); + + it("does not prune storage during a render that never commits", () => { + localStorage.setItem( + persistedAttachmentsStorageKey, + JSON.stringify([persistEntry("file-a", "a.txt", "org-a")]), + ); + // Suspending after the hook runs simulates a render React abandons before commit. + const Suspender: FC<{ orgId: string }> = ({ orgId }) => { + useFileAttachments(orgId, { persist: true }); + throw new Promise(() => {}); + }; + render( + <Suspense fallback={null}> + <Suspender orgId="org-b" /> + </Suspense>, + ); + expect(localStorage.getItem(persistedAttachmentsStorageKey)).toContain( + "file-a", + ); + }); +}); diff --git a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts index eb6098c631225..b4b9193fb8808 100644 --- a/site/src/pages/AgentsPage/hooks/useFileAttachments.ts +++ b/site/src/pages/AgentsPage/hooks/useFileAttachments.ts @@ -52,9 +52,7 @@ function restorePersistedAttachments(currentOrgId: string): { uploadStates: Map<File, UploadState>; previewUrls: Map<File, string>; } { - // Skip when org ID isn't loaded yet so we don't prune valid - // entries. The initializer runs once, so callers must wait for - // the org ID before mounting. + // An unknown org must not prune entries persisted for the eventual org. if (!currentOrgId) { return { attachments: [], @@ -164,6 +162,11 @@ function clearPersistedAttachments() { } interface UseFileAttachmentsReturn { + /** + * True after the post-commit effect assigns in-memory attachment state to + * the supplied organization. Keep attach and send controls disabled until then. + */ + organizationAdopted: boolean; attachments: File[]; textContents: Map<File, string>; uploadStates: Map<File, UploadState>; @@ -192,19 +195,14 @@ export function useFileAttachments( providerRef.current = provider; }, [provider]); - const [restored] = useState(() => - persist - ? restorePersistedAttachments(organizationId ?? "") - : { - attachments: [] as File[], - uploadStates: new Map<File, UploadState>(), - previewUrls: new Map<File, string>(), - }, + const [attachments, setAttachments] = useState<File[]>([]); + const [uploadStates, setUploadStates] = useState( + () => new Map<File, UploadState>(), ); - - const [attachments, setAttachments] = useState<File[]>(restored.attachments); - const [uploadStates, setUploadStates] = useState(restored.uploadStates); - const [previewUrls, setPreviewUrls] = useState(restored.previewUrls); + const [previewUrls, setPreviewUrls] = useState(() => new Map<File, string>()); + // Persisted state remains unowned until post-commit org adoption; restoring + // against a provisional org would prune entries for the eventual org. + const [stateOrgId, setStateOrgId] = useState<string | null>(null); const [textContents, setTextContents] = useState( () => new Map<File, string>(), ); @@ -220,6 +218,27 @@ export function useFileAttachments( return () => revokePreviewUrls(); }, []); + // Every adoption invalidates older upload completions, including A-to-B-to-A + // round trips where stateOrgId matches again. Otherwise a stale completion + // could persist and later restore an abandoned upload. + const adoptionEpochRef = useRef(0); + const commitUploadOutcome = useEffectEvent( + ( + file: File, + uploadOrgId: string, + uploadEpoch: number, + state: UploadState, + ) => { + if (persist && adoptionEpochRef.current !== uploadEpoch) { + return; + } + setUploadStates((prev) => new Map(prev).set(file, state)); + if (persist && state.status === "uploaded" && state.fileId) { + addPersistedAttachment(file, state.fileId, uploadOrgId); + } + }, + ); + const startUpload = (file: File) => { if (!organizationId) { setUploadStates((prev) => @@ -231,25 +250,18 @@ export function useFileAttachments( return; } - const shouldPersist = persist && Boolean(organizationId); + const uploadOrgId = organizationId; + const uploadEpoch = adoptionEpochRef.current; const isImage = file.type.startsWith("image/"); setUploadStates((prev) => new Map(prev).set(file, { status: "uploading" })); void (async () => { try { - const result = await API.experimental.uploadChatFile( - file, - organizationId, - ); - setUploadStates((prev) => - new Map(prev).set(file, { - status: "uploaded", - fileId: result.id, - }), - ); - if (shouldPersist) { - addPersistedAttachment(file, result.id, organizationId!); - } + const result = await API.experimental.uploadChatFile(file, uploadOrgId); + commitUploadOutcome(file, uploadOrgId, uploadEpoch, { + status: "uploaded", + fileId: result.id, + }); if (isImage) { // Pre-warm the HTTP cache so the timeline can // render the image instantly after send. Text @@ -257,13 +269,10 @@ export function useFileAttachments( void fetch(getChatFileURL(result.id)); } } catch (err: unknown) { - const errorMessage = formatAgentAttachmentUploadError(err); - setUploadStates((prev) => - new Map(prev).set(file, { - status: "error", - error: errorMessage, - }), - ); + commitUploadOutcome(file, uploadOrgId, uploadEpoch, { + status: "error", + error: formatAgentAttachmentUploadError(err), + }); } })(); }; @@ -273,6 +282,28 @@ export function useFileAttachments( // file can't be resurrected. WeakSet lets entries get GC'd. const abandonedResizesRef = useRef<WeakSet<File>>(new WeakSet()); + // Permission refetches can change the org without user action. Replace state + // after commit so stale file IDs cannot cross orgs and an abandoned render + // cannot prune localStorage through restorePersistedAttachments. + const adoptOrganization = useEffectEvent((orgId: string) => { + adoptionEpochRef.current += 1; + for (const file of attachments) { + abandonedResizesRef.current.add(file); + } + revokePreviewUrls(); + setTextContents(new Map()); + setStateOrgId(orgId); + const restored = restorePersistedAttachments(orgId); + setAttachments(restored.attachments); + setUploadStates(restored.uploadStates); + setPreviewUrls(restored.previewUrls); + }); + useEffect(() => { + if (persist && organizationId && stateOrgId !== organizationId) { + adoptOrganization(organizationId); + } + }, [persist, stateOrgId, organizationId]); + type AttachItem = { file: File; needsResize: boolean }; const processResizes = async ( @@ -508,11 +539,18 @@ export function useFileAttachments( } }; + // Hide state that belongs to another organization. Exposing it could send + // stale file IDs or remove persisted attachments from the previous org. + const orgMismatch = + persist && stateOrgId !== null && stateOrgId !== organizationId; + return { - attachments, - textContents, - uploadStates, - previewUrls, + organizationAdopted: + !persist || (Boolean(organizationId) && stateOrgId === organizationId), + attachments: orgMismatch ? [] : attachments, + textContents: orgMismatch ? new Map<File, string>() : textContents, + uploadStates: orgMismatch ? new Map<File, UploadState>() : uploadStates, + previewUrls: orgMismatch ? new Map<File, string>() : previewUrls, handleAttach, handleRemoveAttachment, startUpload, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 87fb8947d2cb6..c004e152ab285 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Meta, StoryObj, WebSocketEvent } from "@storybook/react-vite"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { API } from "#/api/api"; @@ -13,35 +13,31 @@ import { import { withAuthProvider, withDashboardProvider, + withWebSocket, } from "#/testHelpers/storybook"; import CreateWorkspacePage from "./CreateWorkspacePage"; -/** - * Mocks API.templateVersionDynamicParameters to immediately send an empty - * DynamicParametersResponse so the page renders the form instead of the - * loader. - */ -function mockDynamicParameters() { - spyOn(API, "templateVersionDynamicParameters").mockImplementation( - (_versionId, _ownerId, callbacks) => { - // Fire asynchronously so the component mounts before the message - // arrives, matching real WebSocket behavior. - setTimeout(() => { - callbacks.onMessage({ id: 0, parameters: [], diagnostics: [] }); - }, 0); - - return { close: () => {} } as unknown as WebSocket; +// The page renders its form once the dynamic-parameters socket opens (which +// sends the initial parameters and records the response ID to wait for) and +// the server's initial id: -1 response arrives. +function dynamicParametersWebSocket(): WebSocketEvent[] { + return [ + { event: "open" }, + { + event: "message", + data: JSON.stringify({ id: -1, parameters: [], diagnostics: [] }), }, - ); + ]; } const meta: Meta<typeof CreateWorkspacePage> = { title: "pages/CreateWorkspacePage", component: CreateWorkspacePage, - decorators: [withAuthProvider, withDashboardProvider], + decorators: [withAuthProvider, withDashboardProvider, withWebSocket], parameters: { layout: "fullscreen", user: MockUserOwner, + webSocket: dynamicParametersWebSocket(), reactRouter: reactRouterParameters({ location: { pathParams: { @@ -63,12 +59,13 @@ const meta: Meta<typeof CreateWorkspacePage> = { spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); spyOn(API, "getTemplateVersionPresets").mockResolvedValue(null); spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: true, createWorkspaceForAny: true, canUpdateTemplate: false, }); - // Dynamic parameters over WebSocket. - mockDynamicParameters(); + // Dynamic parameters over WebSocket are provided by the withWebSocket + // decorator and parameters.webSocket. // Default: no external auth required. spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); @@ -226,3 +223,61 @@ export const SequentialAuthFlow: Story = { }); }, }; + +/** + * A user without workspace-create permission is blocked by the + * RequirePermission dialog instead of seeing the form. + */ +export const PermissionDenied: Story = { + beforeEach: () => { + spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: false, + createWorkspaceForAny: false, + canUpdateTemplate: false, + }); + }, + play: async ({ canvasElement }) => { + // The dialog renders in a portal outside the story canvas. + const body = within(canvasElement.ownerDocument.body); + await body.findByText(/you don't have permission to view this page/i); + expect( + within(canvasElement).queryByRole("form", { + name: /create workspace/i, + }), + ).toBeNull(); + }, +}; + +/** + * A user without workspace-create permission following a ?mode=auto link is + * blocked by the RequirePermission dialog without seeing the auto-create + * consent dialog. + */ +export const PermissionDeniedAutoMode: Story = { + parameters: { + reactRouter: reactRouterParameters({ + location: { + pathParams: { + organization: MockTemplate.organization_name, + template: MockTemplate.name, + }, + searchParams: { mode: "auto" }, + }, + routing: { + path: "/templates/:organization/:template/workspace", + }, + }), + }, + beforeEach: () => { + spyOn(API, "checkAuthorization").mockResolvedValue({ + createWorkspaceForUserID: false, + createWorkspaceForAny: false, + canUpdateTemplate: false, + }); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await body.findByText(/you don't have permission to view this page/i); + expect(body.queryByText(/automatic workspace creation/i)).toBeNull(); + }, +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index 7e28018cc23e3..e2474f3ab4ff2 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -7,7 +7,6 @@ import { MockDropdownParameter, MockDynamicParametersResponseWithError, MockMultiSelectParameter, - MockPermissions, MockPreviewParameter1, MockPreviewParameter2, MockPreviewParameter7, @@ -21,6 +20,7 @@ import { MockUserOwner, MockValidationParameter, MockWorkspace, + mockApiError, } from "#/testHelpers/entities"; import { checkParameters, editParameters } from "#/testHelpers/parameters"; import { @@ -55,6 +55,18 @@ describe("CreateWorkspacePage", () => { mockPublisher: MockWebSocketServer; }; + // checkAuthorization returns a boolean for each key it is asked about and no + // others, so the mock resolves every requested check from `overrides`, + // defaulting unlisted keys to false. + const mockCheckAuthorization = (overrides: Record<string, boolean> = {}) => + vi + .spyOn(API, "checkAuthorization") + .mockImplementation(async ({ checks }) => + Object.fromEntries( + Object.keys(checks).map((key) => [key, overrides[key] ?? false]), + ), + ); + // Mocks the required endpoints, most importantly the web socket, constructs // the route with the required query parameters, then renders the page on that // route. @@ -222,7 +234,10 @@ describe("CreateWorkspacePage", () => { vi.spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); vi.spyOn(API, "getTemplateVersionPresets").mockResolvedValue([]); vi.spyOn(API, "createWorkspace").mockResolvedValue(MockWorkspace); - vi.spyOn(API, "checkAuthorization").mockResolvedValue(MockPermissions); + mockCheckAuthorization({ + createWorkspaceForUserID: true, + createWorkspaceForAny: true, + }); }); afterEach(() => { @@ -636,6 +651,87 @@ describe("CreateWorkspacePage", () => { }); }); + describe("Permissions", () => { + it("blocks the form behind a permission dialog when the user cannot create workspaces", async () => { + mockCheckAuthorization(); + + const { mockPublisher } = await renderPageWithSocket({}); + await expectSocketHandshake({ mockPublisher, parameters: [] }); + + expect( + await screen.findByText(/you don't have permission to view this page/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole("form", { name: /create workspace/i }), + ).not.toBeInTheDocument(); + }); + + it("blocks auto-creation without showing the consent dialog when the user cannot create workspaces", async () => { + mockCheckAuthorization(); + const autoCreateSpy = vi.spyOn(API, "createWorkspace"); + + const { mockPublisher } = await renderPageWithSocket({ + route: `/templates/${MockTemplate.name}/workspace?mode=auto`, + }); + await expectSocketHandshake({ mockPublisher, parameters: [] }); + + expect( + await screen.findByText(/you don't have permission to view this page/i), + ).toBeInTheDocument(); + expect( + screen.queryByText(/automatic workspace creation/i), + ).not.toBeInTheDocument(); + expect(autoCreateSpy).not.toHaveBeenCalled(); + }); + + it("shows an error instead of the form when the permission check fails", async () => { + // Only reject the page's own check batch; the auth provider also + // calls checkAuthorization and must keep resolving. + vi.spyOn(API, "checkAuthorization").mockImplementation( + async ({ checks }) => { + if ("createWorkspaceForUserID" in checks) { + throw mockApiError({ + message: "failed to check authorization", + }); + } + return {}; + }, + ); + + const { mockPublisher } = await renderPageWithSocket({}); + await expectSocketHandshake({ mockPublisher, parameters: [] }); + + expect( + await screen.findByRole("heading", { + name: /failed to check authorization/i, + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("form", { name: /create workspace/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/you don't have permission to view this page/i), + ).not.toBeInTheDocument(); + }); + }); + + describe("Load Errors", () => { + it("shows an error instead of the loader when the template fails to load", async () => { + vi.spyOn(API, "getTemplateByName").mockRejectedValue( + mockApiError({ message: "failed to load template" }), + ); + + renderCreateWorkspacePage(); + + expect( + await screen.findByRole("heading", { + name: /failed to load template/i, + }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + }); + }); + describe("Form Submission", () => { it("creates workspace with correct parameters", async () => { const parameters = [ diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 3636a262ced9e..d818a887f9db4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -24,9 +24,12 @@ import type { MinimalUser, Workspace, } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; +import { Margins } from "#/components/Margins/Margins"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useExternalAuth } from "#/hooks/useExternalAuth"; +import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { generateWorkspaceName } from "#/modules/workspaces/generateWorkspaceName"; import { pageTitle } from "#/utils/page"; import type { AutofillBuildParameter } from "#/utils/richParameters"; @@ -89,11 +92,18 @@ const CreateWorkspacePage: FC = () => { ...checkAuthorization({ checks: createWorkspaceChecks( templateQuery.data?.organization_id ?? "", + me.id, templateQuery.data?.id, ), }), enabled: Boolean(templateQuery.data), }); + // Scoped to the template's organization and to workspaces owned by the + // current user; holding workspace-create permission in other organizations + // does not grant access here. + const canCreateWorkspaceInOrg = Boolean( + permissionsQuery.data?.createWorkspaceForUserID, + ); const templateVersionQuery = useQuery({ ...templateVersion(realizedVersionId ?? ""), @@ -259,7 +269,10 @@ const CreateWorkspacePage: FC = () => { const isLoadingFormData = ws.current?.readyState === WebSocket.CONNECTING || templateQuery.isLoading || - permissionsQuery.isLoading; + // isPending stays true until the permission data exists, covering the + // renders where the query is still disabled or has not started fetching, + // during which isLoading would be false. + permissionsQuery.isPending; const loadFormDataError = templateQuery.error ?? permissionsQuery.error; const title = autoCreateWorkspaceMutation.isPending @@ -309,12 +322,14 @@ const CreateWorkspacePage: FC = () => { let autoCreateReady = mode === "auto" && + canCreateWorkspaceInOrg && hasAllRequiredExternalAuth && autoCreateConsented && presetResolved; const showAutoCreateConsent = mode === "auto" && + canCreateWorkspaceInOrg && !autoCreateConsented && !autoCreateError && presetResolved; @@ -403,66 +418,74 @@ const CreateWorkspacePage: FC = () => { onDeny={() => setMode("form")} /> - {shouldShowLoader ? ( + {loadFormDataError ? ( + // The view reads the template and permission results + // unconditionally, so render query failures as a page-level + // error instead of the form. + <Margins> + <ErrorAlert error={loadFormDataError} className="my-4" /> + </Margins> + ) : shouldShowLoader ? ( <Loader /> ) : ( - <CreateWorkspacePageView - mode={mode} - defaultName={defaultName} - diagnostics={latestResponse?.diagnostics ?? []} - disabledParams={disabledParams} - defaultOwner={defaultOwner} - owner={owner} - setOwner={setOwner} - autofillParameters={autofillParameters} - canUpdateTemplate={permissionsQuery.data?.canUpdateTemplate} - error={ - wsError || - createWorkspaceMutation.error || - autoCreateError || - loadFormDataError || - autoCreateWorkspaceMutation.error - } - resetMutation={createWorkspaceMutation.reset} - template={templateQuery.data} - versionId={realizedVersionId} - versionName={templateVersionQuery.data?.name} - externalAuth={externalAuth ?? []} - externalAuthPollingState={externalAuthPollingState} - startPollingExternalAuth={startPollingExternalAuth} - hasAllRequiredExternalAuth={hasAllRequiredExternalAuth} - permissions={permissionsQuery.data as CreateWorkspacePermissions} - parameters={sortedParams} - presets={presets} - urlPreset={urlPresetResult.preset} - urlPresetError={ - autoCreateError?.detail === urlPresetResult.error - ? undefined - : urlPresetResult.error - } - hasIgnoredUrlParams={hasIgnoredUrlParams} - creatingWorkspace={createWorkspaceMutation.isPending} - sendMessage={sendMessage} - onCancel={() => { - navigate(-1); - }} - onSubmit={async (request, owner) => { - let workspaceRequest = request; - if (realizedVersionId) { - workspaceRequest = { - ...request, - template_id: undefined, - template_version_id: realizedVersionId, - }; + <RequirePermission isFeatureVisible={canCreateWorkspaceInOrg}> + <CreateWorkspacePageView + mode={mode} + defaultName={defaultName} + diagnostics={latestResponse?.diagnostics ?? []} + disabledParams={disabledParams} + defaultOwner={defaultOwner} + owner={owner} + setOwner={setOwner} + autofillParameters={autofillParameters} + canUpdateTemplate={permissionsQuery.data?.canUpdateTemplate} + error={ + wsError || + createWorkspaceMutation.error || + autoCreateError || + autoCreateWorkspaceMutation.error } - - const workspace = await createWorkspaceMutation.mutateAsync({ - ...workspaceRequest, - userId: owner.id, - }); - onCreateWorkspace(workspace); - }} - /> + resetMutation={createWorkspaceMutation.reset} + template={templateQuery.data} + versionId={realizedVersionId} + versionName={templateVersionQuery.data?.name} + externalAuth={externalAuth ?? []} + externalAuthPollingState={externalAuthPollingState} + startPollingExternalAuth={startPollingExternalAuth} + hasAllRequiredExternalAuth={hasAllRequiredExternalAuth} + permissions={permissionsQuery.data as CreateWorkspacePermissions} + parameters={sortedParams} + presets={presets} + urlPreset={urlPresetResult.preset} + urlPresetError={ + autoCreateError?.detail === urlPresetResult.error + ? undefined + : urlPresetResult.error + } + hasIgnoredUrlParams={hasIgnoredUrlParams} + creatingWorkspace={createWorkspaceMutation.isPending} + sendMessage={sendMessage} + onCancel={() => { + navigate(-1); + }} + onSubmit={async (request, owner) => { + let workspaceRequest = request; + if (realizedVersionId) { + workspaceRequest = { + ...request, + template_id: undefined, + template_version_id: realizedVersionId, + }; + } + + const workspace = await createWorkspaceMutation.mutateAsync({ + ...workspaceRequest, + userId: owner.id, + }); + onCreateWorkspace(workspace); + }} + /> + </RequirePermission> )} </> ); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index fb4b889ce7d8a..b516ebf33d583 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -27,6 +27,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = { mode: "form", parameters: [], permissions: { + createWorkspaceForUserID: true, createWorkspaceForAny: true, canUpdateTemplate: false, }, diff --git a/site/src/pages/CreateWorkspacePage/permissions.ts b/site/src/pages/CreateWorkspacePage/permissions.ts index a5ca3a469f623..e4dea942d70a1 100644 --- a/site/src/pages/CreateWorkspacePage/permissions.ts +++ b/site/src/pages/CreateWorkspacePage/permissions.ts @@ -1,8 +1,17 @@ export const createWorkspaceChecks = ( organizationId: string, + userId: string, templateId?: string, ) => ({ + createWorkspaceForUserID: { + object: { + resource_type: "workspace" as const, + organization_id: organizationId, + owner_id: userId, + }, + action: "create" as const, + }, createWorkspaceForAny: { object: { resource_type: "workspace" as const, diff --git a/site/src/pages/GroupsPage/AIBudgetPeriod.tsx b/site/src/pages/GroupsPage/AIBudgetPeriod.tsx index 8ce3e8d8e9e50..3ab592efa35c9 100644 --- a/site/src/pages/GroupsPage/AIBudgetPeriod.tsx +++ b/site/src/pages/GroupsPage/AIBudgetPeriod.tsx @@ -1,29 +1,22 @@ -import dayjs from "dayjs"; import type { FC } from "react"; import { useQuery } from "react-query"; import { meAISpend } from "#/api/queries/users"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; +import { formatSpendPeriodLabel } from "#/utils/budget"; /** The current AI budget window, e.g. "June 1 - July 1, 2026". */ export const AIBudgetPeriod: FC = () => { - const { experiments } = useDashboard(); - // TODO(AIGOV-443): drop the experiment gate once cost control is stable. - const visible = - Boolean(useFeatureVisibility().aibridge) && - experiments.includes("ai-gateway-cost-control"); + const visible = Boolean(useFeatureVisibility().aibridge); const { data: aiSpend } = useQuery({ ...meAISpend(), enabled: visible }); if (!visible || !aiSpend) { return null; } - // Local time and raw exclusive period_end, matching the spend page. - const start = dayjs(aiSpend.period_start).format("MMMM D"); - const end = dayjs(aiSpend.period_end).format("MMMM D, YYYY"); return ( <span className="text-sm text-content-secondary"> - AI budget period: {start} - {end} + AI budget period:{" "} + {formatSpendPeriodLabel(aiSpend.period_start, aiSpend.period_end)} </span> ); }; diff --git a/site/src/pages/GroupsPage/AICostControl.tsx b/site/src/pages/GroupsPage/AICostControl.tsx new file mode 100644 index 0000000000000..31f43623ae447 --- /dev/null +++ b/site/src/pages/GroupsPage/AICostControl.tsx @@ -0,0 +1,13 @@ +import type { FC } from "react"; +import { Link } from "#/components/Link/Link"; +import { docs } from "#/utils/docs"; + +export const SpendEstimateDocsLink: FC = () => ( + <Link + href={docs("/ai-coder/ai-gateway/cost-controls#how-spend-is-estimated")} + target="_blank" + rel="noreferrer" + > + How spend is estimated + </Link> +); diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index beda74199d615..36ae58b6e86cb 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -1,4 +1,3 @@ -import dayjs from "dayjs"; import { EllipsisVerticalIcon, UserPlusIcon } from "lucide-react"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; @@ -47,11 +46,13 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { isEveryoneGroup } from "#/modules/groups"; +import { formatSpendResetLabel } from "#/utils/budget"; import { cn } from "#/utils/cn"; import { formatBudgetUSD } from "#/utils/currency"; +import { SpendEstimateDocsLink } from "./AICostControl"; import { effectiveBudgetGroup, GroupMemberBudgetCells, @@ -78,14 +79,14 @@ const GroupMembersPage: FC = () => { const removeMemberMutation = useMutation( removeMember(queryClient, organization), ); + const { permissions: sitePermissions } = useAuthenticated(); const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; + // Setting a user's AI budget override updates both the user and the group + // its spend is charged to, so it needs permission on both. + const canUpdateBudgetOverride = canUpdateGroup && sitePermissions.updateUsers; const [budgetUser, setBudgetUser] = useState<MemberWithSpend | null>(null); - const { experiments } = useDashboard(); - // TODO(AIGOV-443): drop the experiment gate once cost control is stable. - const aibridgeVisible = - Boolean(useFeatureVisibility().aibridge) && - experiments.includes("ai-gateway-cost-control"); + const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); const { data: aiSpend } = useQuery({ ...meAISpend(), enabled: aibridgeVisible, @@ -112,10 +113,8 @@ const GroupMembersPage: FC = () => { }), ); const aiBudgetNote = [ - "Monthly AI spend for this user.", - // Spend resets at period_end, rendered in the viewer's local time. - aiSpend && - `Resets ${dayjs(aiSpend.period_end).format("MMM D, YYYY h:mm A")}.`, + "Estimated monthly AI spend for this user.", + aiSpend && `Resets ${formatSpendResetLabel(aiSpend.period_end)}.`, // A $0 default still shows: it means no spending allowance. groupBudget && `The group's default limit is ${formatBudgetUSD(groupBudget.spend_limit_micros)} per member.`, @@ -173,7 +172,13 @@ const GroupMembersPage: FC = () => { message="AI spend couldn't be loaded, so budgets aren't shown." /> ) : ( - <StatusIconTooltip message={aiBudgetNote} /> + <StatusIconTooltip + message={ + <> + {aiBudgetNote} <SpendEstimateDocsLink /> + </> + } + /> )} </div> </TableHead> @@ -237,6 +242,7 @@ const GroupMembersPage: FC = () => { user={budgetUser} currentGroup={groupData} effectiveGroupId={budgetUser.spend?.effective_group_id} + canUpdate={canUpdateBudgetOverride} /> )} </div> diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 8b8c15ba3be7a..3556d507d7521 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, spyOn, userEvent, within } from "storybook/test"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterOutlet, reactRouterParameters, @@ -24,6 +24,7 @@ import type { GroupMemberAISpend, GroupMembersAISpend, ReducedUser, + UserAIBudgetOverride, UserAISpendStatus, } from "#/api/typesGenerated"; import { @@ -36,15 +37,20 @@ import { MockUserMember, MockUserOwner, } from "#/testHelpers/entities"; -import { withDashboardProvider } from "#/testHelpers/storybook"; +import { + withAuthProvider, + withDashboardProvider, +} from "#/testHelpers/storybook"; import GroupMembersPage from "./GroupMembersPage"; import GroupPage from "./GroupPage"; const meta: Meta<typeof GroupPage> = { title: "pages/OrganizationGroupsPage/GroupPage", component: GroupPage, - decorators: [withDashboardProvider], + decorators: [withDashboardProvider, withAuthProvider], parameters: { + user: MockUserOwner, + permissions: { updateUsers: true }, reactRouter: reactRouterParameters({ location: { pathParams: { @@ -100,9 +106,11 @@ const membersQuery = (data: unknown) => ({ /** period_end is exclusive. */ const mockUserAISpend: UserAISpendStatus = { user_id: MockUserOwner.id, - spend_limit_micros: 9_000_000_000, effective_group_id: MockGroupWithoutMembers.id, - limit_source: "group", + effective_budget: { + spend_limit_micros: 9_000_000_000, + limit_source: "group", + }, current_spend_micros: 1_345_000_000, period_start: "2026-06-01T00:00:00Z", period_end: "2026-07-01T00:00:00Z", @@ -157,7 +165,6 @@ export const MembersSpendError: Story = { }, parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ users: [MockUserMember], count: 1 }), @@ -312,7 +319,6 @@ const mockGroupBudget: GroupAIBudget = { export const WithMemberAIBudget: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -342,9 +348,8 @@ export const WithMemberAIBudget: Story = { const canvas = within(canvasElement); await expect(await canvas.findByText("AI budget")).toBeInTheDocument(); await expect(await canvas.findByText("Budget group")).toBeInTheDocument(); - // Dates depend on the runner's timezone; match loosely. await expect( - await canvas.findByText(/^AI budget period: \w+ \d+ - \w+ \d+, 2026$/), + await canvas.findByText("AI budget period: June 1 - July 1, 2026"), ).toBeInTheDocument(); await expect( @@ -359,7 +364,7 @@ export const WithMemberAIBudget: Story = { ); await expect( await body.findByText( - /^Monthly AI spend for this user\. Resets .*The group's default limit is \$7,000 per member\.$/, + /^Estimated monthly AI spend for this user\. Resets .*The group's default limit is \$7,000 per member\.$/, ), ).toBeInTheDocument(); await userEvent.click( @@ -395,7 +400,6 @@ export const WithoutMemberAIBudgetColumn: Story = { export const AIBudgetActionDisabledForOtherGroup: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -429,7 +433,9 @@ export const AIBudgetActionDisabledForOtherGroup: Story = { }), ); await expect( - await body.findByText(/^Monthly AI spend for this user\. Resets .*\.$/), + await body.findByText( + /^Estimated monthly AI spend for this user\. Resets .*\.$/, + ), ).toBeInTheDocument(); await userEvent.keyboard("{Escape}"); @@ -449,7 +455,6 @@ export const AIBudgetActionDisabledForOtherGroup: Story = { export const WithMemberAIBudgetInAnotherOrg: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -503,10 +508,143 @@ export const WithMemberAIBudgetInAnotherOrg: Story = { }, }; -export const OpenAIBudgetForCurrentGroupMember: Story = { +const mockUserBudgetOverride: UserAIBudgetOverride = { + user_id: MockUserOwner.id, + group_id: MockGroupWithoutMembers.id, + spend_limit_micros: 12_000_000_000, + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", +}; + +const mockOwnerSpend: GroupMemberAISpend = { + ...mockSpend, + user_id: MockUserOwner.id, +}; + +const mockOwnerOverrideSpend: GroupMemberAISpend = { + ...mockOwnerSpend, + group_budget: { + spend_limit_micros: mockUserBudgetOverride.spend_limit_micros, + limit_source: "user_override", + }, +}; + +export const SaveMemberAIBudgetRefreshesRow: Story = { + beforeEach: () => { + spyOn(API, "upsertUserAIBudgetOverride").mockResolvedValue( + mockUserBudgetOverride, + ); + spyOn(API, "getUserAIBudgetOverride").mockResolvedValue( + mockUserBudgetOverride, + ); + spyOn(API, "getGroupMembersAISpend").mockResolvedValue( + membersSpendQuery([mockOwnerOverrideSpend]).data, + ); + }, + parameters: { + features: ["aibridge"], + experiments: ["ai-gateway-cost-control"], + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ users: [MockUserOwner], count: 1 }), + membersSpendQuery([mockOwnerSpend]), + permissionsQuery({ canUpdateGroup: true }), + { key: meAISpendKey, data: mockUserAISpend }, + { key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null }, + { + key: getGroupsForUserQueryKey( + MockUserOwner.id, + MockGroupWithoutMembers.organization_id, + ), + data: [MockGroup2], + }, + { + key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, + data: mockGroupBudget, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const budgetCell = await canvas.findByTestId( + `member-ai-budget-${MockUserOwner.id}`, + ); + await expect(budgetCell).toHaveTextContent("Group limit $9,000"); + + await userEvent.click(canvas.getByRole("button", { name: "Open menu" })); + await userEvent.click( + await body.findByRole("menuitem", { name: "Manage AI budget" }), + ); + await userEvent.click(await body.findByText("Override group budget")); + const input = body.getByLabelText("Custom monthly budget"); + await userEvent.clear(input); + await userEvent.type(input, "12000"); + await userEvent.click(body.getByRole("button", { name: "Update" })); + + await waitFor(() => + expect(budgetCell).toHaveTextContent("Custom limit $12,000"), + ); + }, +}; + +export const DeleteMemberAIBudgetRefreshesRow: Story = { + beforeEach: () => { + spyOn(API, "deleteUserAIBudgetOverride").mockResolvedValue(); + spyOn(API, "getGroupMembersAISpend").mockResolvedValue( + membersSpendQuery([mockOwnerSpend]).data, + ); + }, parameters: { features: ["aibridge"], experiments: ["ai-gateway-cost-control"], + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ users: [MockUserOwner], count: 1 }), + membersSpendQuery([mockOwnerOverrideSpend]), + permissionsQuery({ canUpdateGroup: true }), + { key: meAISpendKey, data: mockUserAISpend }, + { + key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), + data: mockUserBudgetOverride, + }, + { + key: getGroupsForUserQueryKey( + MockUserOwner.id, + MockGroupWithoutMembers.organization_id, + ), + data: [MockGroup2], + }, + { + key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, + data: mockGroupBudget, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const budgetCell = await canvas.findByTestId( + `member-ai-budget-${MockUserOwner.id}`, + ); + await expect(budgetCell).toHaveTextContent("Custom limit $12,000"); + + await userEvent.click(canvas.getByRole("button", { name: "Open menu" })); + await userEvent.click( + await body.findByRole("menuitem", { name: "Manage AI budget" }), + ); + await userEvent.click(body.getByRole("checkbox")); + await userEvent.click(body.getByRole("button", { name: "Update" })); + + await waitFor(() => + expect(budgetCell).toHaveTextContent("Group limit $9,000"), + ); + }, +}; + +export const OpenAIBudgetForCurrentGroupMember: Story = { + parameters: { + features: ["aibridge"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -544,6 +682,50 @@ export const OpenAIBudgetForCurrentGroupMember: Story = { }, }; +/** Group admins can read a member's budget without the site user permission. */ +export const AIBudgetReadOnlyWithoutUserPermission: Story = { + parameters: { + features: ["aibridge"], + permissions: { updateUsers: false }, + queries: [ + groupQuery(MockGroupWithoutMembers), + groupMembersQuery({ + users: [MockUserOwner], + count: 1, + }), + membersSpendQuery([{ ...mockSpend, user_id: MockUserOwner.id }]), + permissionsQuery({ canUpdateGroup: true }), + { key: meAISpendKey, data: mockUserAISpend }, + { key: getUserAIBudgetOverrideQueryKey(MockUserOwner.id), data: null }, + { + key: getGroupsForUserQueryKey( + MockUserOwner.id, + MockGroupWithoutMembers.organization_id, + ), + data: [MockGroup2], + }, + { key: groupAIBudget(MockGroupWithoutMembers.id).queryKey, data: null }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.click( + canvas.getAllByRole("button", { name: "Open menu" })[0], + ); + await userEvent.click( + await body.findByRole("menuitem", { name: "Manage AI budget" }), + ); + await expect( + await body.findByText( + /To update this limit, contact a Coder administrator\./, + ), + ).toBeInTheDocument(); + await expect(body.queryByRole("checkbox")).not.toBeInTheDocument(); + }, +}; + /** Unresolvable via getGroupById, standing in for another org's group. */ const unresolvedGroupId = "external-org-group"; @@ -661,7 +843,6 @@ const showcaseSpends: GroupMemberAISpend[] = [ export const AIBudgetShowcase: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ diff --git a/site/src/pages/GroupsPage/GroupSettingsPage.tsx b/site/src/pages/GroupsPage/GroupSettingsPage.tsx index 5e34c186d43bb..e7f5d29359a84 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPage.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPage.tsx @@ -10,7 +10,6 @@ import { } from "#/api/queries/groups"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Spinner } from "#/components/Spinner/Spinner"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { dollarsToMicros, microsToDollars } from "#/utils/currency"; import type { GroupPageOutletContext } from "./GroupPage"; @@ -29,12 +28,7 @@ const GroupSettingsPage: FC = () => { const patchGroupMutation = useMutation(patchGroup(queryClient, organization)); const navigate = useNavigate(); - const { experiments } = useDashboard(); - // TODO(AIGOV-443): remove the ai-gateway-cost-control experiment gate once - // the cost-control feature is stable. - const aibridgeVisible = - Boolean(useFeatureVisibility().aibridge) && - experiments.includes("ai-gateway-cost-control"); + const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); const budgetQuery = useQuery({ ...groupAIBudget(groupData.id), enabled: aibridgeVisible, diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx index 98cb79f48ff5c..a80aba46e5547 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { maxAIBudgetDollars } from "#/modules/groups"; import { MockGroup } from "#/testHelpers/entities"; import GroupSettingsPageView from "./GroupSettingsPageView"; @@ -40,10 +41,8 @@ export const WithAIBudget: Story = { await expect(canvas.getByLabelText("Monthly limit per member")).toHaveValue( 1000, ); - const helper = canvas.getByText(/month maximum/i); - await expect(helper).toHaveTextContent( - "$7,000/month maximum, based on 7 members.", - ); + const helper = canvas.getByText(/month, based on/i); + await expect(helper).toHaveTextContent("$7,000/month, based on 7 members."); }, }; @@ -91,10 +90,30 @@ export const AIBudgetDecimal: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Cents are kept when the amount is not a whole dollar. - const helper = canvas.getByText(/month maximum/i); - await expect(helper).toHaveTextContent( - "$99.99/month maximum, based on 1 member.", - ); + const helper = canvas.getByText(/month, based on/i); + await expect(helper).toHaveTextContent("$99.99/month, based on 1 member."); + }, +}; + +// A budget above the configurable maximum blocks saving. +export const AIBudgetAboveMaximum: Story = { + args: { + showAISettings: true, + initialBudgetDollars: null, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const input = canvas.getByLabelText("Monthly limit per member"); + + await userEvent.type(input, String(maxAIBudgetDollars + 1)); + // Blur to surface the error, matching the touched-then-validate flow. + await userEvent.tab(); + await expect( + await canvas.findByText("Enter an amount between 0 and $1,000,000."), + ).toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("button", { name: "Save" })); + await expect(args.onSubmit).not.toHaveBeenCalled(); }, }; diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx index a2eee3fc739ba..4bc3596e5d533 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx @@ -14,7 +14,11 @@ import { } from "#/components/InputGroup/InputGroup"; import { Label } from "#/components/Label/Label"; import { Spinner } from "#/components/Spinner/Spinner"; -import { isEveryoneGroup } from "#/modules/groups"; +import { + aiBudgetRangeError, + isEveryoneGroup, + maxAIBudgetDollars, +} from "#/modules/groups"; import { usdBudgetFormatter } from "#/utils/currency"; import { getFormHelpers, @@ -34,10 +38,11 @@ type FormData = { const validationSchema = Yup.object({ name: nameValidator("Name"), quota_allowance: Yup.number().required().min(0).integer(), - // Optional: empty is unlimited. A value must be zero or more; 0 disables. + // Optional: empty is unlimited. A value must be within the range; 0 disables. monthly_budget_per_member: Yup.number() .transform((value, original) => (original === "" ? undefined : value)) - .min(0, "Enter an amount of zero or more."), + .min(0, aiBudgetRangeError) + .max(maxAIBudgetDollars, aiBudgetRangeError), }); interface AIBudgetFeedbackProps { @@ -94,7 +99,7 @@ const AIBudgetFeedback: FC<AIBudgetFeedbackProps> = ({ <span className="font-medium text-content-primary"> {usdBudgetFormatter.format(budgetAmount * memberCount)} </span> - /month maximum, based on{" "} + /month, based on{" "} <span className="font-medium text-content-primary">{memberCount}</span>{" "} {memberCount === 1 ? "member" : "members"}. </span> @@ -244,6 +249,7 @@ const UpdateGroupForm: FC<UpdateGroupFormProps> = ({ onBlur={budgetField.onBlur} type="number" min="0" + max={maxAIBudgetDollars} step="1" placeholder="unlimited" aria-invalid={budgetField.error} diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 65d592a9efb99..b1fd6d89f508e 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -17,7 +17,6 @@ import { SettingsHeaderDescription, SettingsHeaderTitle, } from "#/components/SettingsHeader/SettingsHeader"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { RequirePermission } from "#/modules/permissions/RequirePermission"; import { pageTitle } from "#/utils/page"; @@ -26,12 +25,8 @@ import { GroupsPageView, joinGroupsSpend } from "./GroupsPageView"; const GroupsPage: FC = () => { const { template_rbac: groupsEnabled, aibridge } = useFeatureVisibility(); - const { experiments } = useDashboard(); const { organization, showOrganizations } = useGroupsSettings(); - // TODO(AIGOV-443): remove the ai-gateway-cost-control experiment gate once - // the cost-control feature is stable. - const aibridgeVisible = - Boolean(aibridge) && experiments.includes("ai-gateway-cost-control"); + const aibridgeVisible = Boolean(aibridge); const groupsQuery = useQuery({ ...groupsByOrganization(organization?.name ?? ""), enabled: Boolean(organization), diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 598e1bfad1d73..454f184f62006 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -51,6 +51,7 @@ export const WithAIBudgets: Story = { group_id: "ai-unlimited", current_spend_micros: 25_492_000_000, spend_limit_micros: null, + total_spend_limit_micros: null, }, }, { @@ -58,7 +59,8 @@ export const WithAIBudgets: Story = { spend: { group_id: "ai-under", current_spend_micros: 10_000_000, - spend_limit_micros: 50_000_000, + spend_limit_micros: 25_000_000, + total_spend_limit_micros: 50_000_000, }, }, { @@ -66,7 +68,8 @@ export const WithAIBudgets: Story = { spend: { group_id: "ai-warning", current_spend_micros: 46_000_000, - spend_limit_micros: 50_000_000, + spend_limit_micros: 25_000_000, + total_spend_limit_micros: 50_000_000, }, }, { @@ -74,7 +77,8 @@ export const WithAIBudgets: Story = { spend: { group_id: "ai-at-limit", current_spend_micros: 50_000_000, - spend_limit_micros: 50_000_000, + spend_limit_micros: 25_000_000, + total_spend_limit_micros: 50_000_000, }, }, { @@ -82,7 +86,8 @@ export const WithAIBudgets: Story = { spend: { group_id: "ai-over", current_spend_micros: 75_000_000, - spend_limit_micros: 50_000_000, + spend_limit_micros: 25_000_000, + total_spend_limit_micros: 50_000_000, }, }, { @@ -91,6 +96,7 @@ export const WithAIBudgets: Story = { group_id: "ai-zero-budget", current_spend_micros: 5_000_000, spend_limit_micros: 0, + total_spend_limit_micros: 0, }, }, { @@ -99,6 +105,7 @@ export const WithAIBudgets: Story = { group_id: "ai-zero-both", current_spend_micros: 0, spend_limit_micros: 0, + total_spend_limit_micros: 0, }, }, // No spend exercises the missing-spend em-dash fallback. diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index 2668f25417fd2..79e44597904ff 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -25,6 +25,7 @@ import { } from "#/components/TableLoader/TableLoader"; import { useClickableTableRow } from "#/hooks/useClickableTableRow"; import { docs } from "#/utils/docs"; +import { SpendEstimateDocsLink } from "./AICostControl"; import { StatusIconTooltip } from "./StatusIconTooltip"; const EM_DASH = "\u2014"; @@ -94,7 +95,14 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({ message="AI spend couldn't be loaded, so budgets aren't shown." /> ) : ( - <StatusIconTooltip message="Current AI spend compared to the group's AI budget for the active period." /> + <StatusIconTooltip + message={ + <> + Estimated AI spend compared to the group's AI budget for + the active period. <SpendEstimateDocsLink /> + </> + } + /> )} </div> </TableHead> @@ -218,7 +226,7 @@ const GroupRow: FC<GroupRowProps> = ({ group, showAIBudget }) => { {group.spend ? ( <AIBudgetUsage currentSpend={group.spend.current_spend_micros} - spendLimit={group.spend.spend_limit_micros} + spendLimit={group.spend.total_spend_limit_micros} /> ) : ( EM_DASH diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index e26162a8d4dfd..4aa71c5fbf209 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -4,6 +4,7 @@ import { API } from "#/api/api"; import { groupAIBudget, groupsForUser } from "#/api/queries/groups"; import { getUserAIBudgetOverrideQueryKey } from "#/api/queries/users"; import type { GroupAIBudget, UserAIBudgetOverride } from "#/api/typesGenerated"; +import { maxAIBudgetDollars } from "#/modules/groups"; import { MockGroup, MockGroup2, MockUserMember } from "#/testHelpers/entities"; import { UserAIBudgetOverrideDialog } from "./UserAIBudgetOverrideDialog"; @@ -38,6 +39,7 @@ const meta: Meta<typeof UserAIBudgetOverrideDialog> = { onOpenChange: () => undefined, user: MockUserMember, currentGroup: MockGroup, + canUpdate: true, }, }; @@ -87,6 +89,80 @@ export const WithoutOverride: Story = { }, }; +/** Without permission, an existing override can be read but not removed. */ +export const ReadOnlyWithOverride: Story = { + args: { canUpdate: false }, + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: mockOverride, + }, + ...groupQueries, + ], + }, + play: async () => { + const body = within(document.body); + await expect(await body.findByText("$12,000 USD")).toBeInTheDocument(); + await expect( + body.getByText(/To update this limit, contact a Coder administrator\./), + ).toBeInTheDocument(); + await expect(body.queryByRole("checkbox")).not.toBeInTheDocument(); + await expect( + body.queryByLabelText("Custom monthly budget"), + ).not.toBeInTheDocument(); + await expect( + body.queryByRole("button", { name: "Budget assigned to" }), + ).not.toBeInTheDocument(); + for (const name of ["Update", "Cancel", "Close"]) { + await expect( + body.queryByRole("button", { name }), + ).not.toBeInTheDocument(); + } + }, +}; + +/** Without permission or an override, the group's budget is shown as-is. */ +export const ReadOnlyWithoutOverride: Story = { + args: { canUpdate: false }, + parameters: { + queries: [ + { key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), data: null }, + ...groupQueries, + ], + }, + play: async () => { + const body = within(document.body); + await expect(await body.findByText("$5,000 USD")).toBeInTheDocument(); + await expect( + body.getByText(/To update this limit, contact a Coder administrator\./), + ).toBeInTheDocument(); + await expect(body.queryByRole("checkbox")).not.toBeInTheDocument(); + await expect( + body.queryByRole("button", { name: "Update" }), + ).not.toBeInTheDocument(); + }, +}; + +/** The assigned group is in another organization, so it can't be named. */ +export const OverrideWithUnresolvableGroup: Story = { + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: { ...mockOverride, group_id: "another-org-group" }, + }, + ...groupQueries, + ], + }, + play: async () => { + const body = within(document.body); + const summary = await body.findByText(/charged to/); + await expect(summary).toHaveTextContent("charged to their group."); + await expect(summary).not.toHaveTextContent("group group"); + }, +}; + export const Uncapped: Story = { parameters: { queries: [ @@ -122,12 +198,12 @@ export const Uncapped: Story = { async () => { const budgetInput = body.getByLabelText("Custom monthly budget"); await expect( - body.queryByText("Enter a monthly budget of 0 or more."), + body.queryByText("Enter an amount between 0 and $1,000,000."), ).not.toBeInTheDocument(); await userEvent.click(budgetInput); await userEvent.tab(); await expect( - await body.findByText("Enter a monthly budget of 0 or more."), + await body.findByText("Enter an amount between 0 and $1,000,000."), ).toBeInTheDocument(); }, ); @@ -222,7 +298,7 @@ export const SubmitRequiresValueOrUncheck: Story = { // Blur to surface the error, matching the touched-then-validate flow. await userEvent.tab(); await expect( - await body.findByText("Enter a monthly budget of 0 or more."), + await body.findByText("Enter an amount between 0 and $1,000,000."), ).toBeInTheDocument(); await expect(updateButton).toBeDisabled(); }); @@ -244,6 +320,40 @@ export const SubmitRequiresValueOrUncheck: Story = { }, }; +// A budget above the configurable maximum blocks submit. +export const SubmitBlockedAboveMaximum: Story = { + parameters: { + queries: [ + { + key: getUserAIBudgetOverrideQueryKey(MockUserMember.id), + data: mockOverride, + }, + ...groupQueries, + ], + }, + play: async ({ step }) => { + const body = within(document.body); + const budgetInput = await body.findByLabelText("Custom monthly budget"); + const updateButton = body.getByRole("button", { name: "Update" }); + + await step("the maximum itself is submittable", async () => { + await userEvent.clear(budgetInput); + await userEvent.type(budgetInput, String(maxAIBudgetDollars)); + await expect(updateButton).toBeEnabled(); + }); + + await step("one dollar above the maximum blocks submit", async () => { + await userEvent.clear(budgetInput); + await userEvent.type(budgetInput, String(maxAIBudgetDollars + 1)); + await userEvent.tab(); + await expect( + await body.findByText("Enter an amount between 0 and $1,000,000."), + ).toBeInTheDocument(); + await expect(updateButton).toBeDisabled(); + }); + }, +}; + export const Loading: Story = { beforeEach: () => { spyOn(API, "getUserAIBudgetOverride").mockReturnValue( diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx index a7448793c88a4..9e3b32ba4ab47 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx @@ -52,6 +52,7 @@ import { import { Label } from "#/components/Label/Label"; import { Separator } from "#/components/Separator/Separator"; import { Spinner } from "#/components/Spinner/Spinner"; +import { aiBudgetRangeError, maxAIBudgetDollars } from "#/modules/groups"; import { cn } from "#/utils/cn"; import { dollarsToMicros, @@ -65,11 +66,20 @@ interface UserAIBudgetOverrideDialogProps { user: ReducedUser; currentGroup: Group; effectiveGroupId?: string | null; + // When false, the budget is shown without the controls to change it. + canUpdate: boolean; } export const UserAIBudgetOverrideDialog: FC< UserAIBudgetOverrideDialogProps -> = ({ open, onOpenChange, user, currentGroup, effectiveGroupId }) => { +> = ({ + open, + onOpenChange, + user, + currentGroup, + effectiveGroupId, + canUpdate, +}) => { const queryClient = useQueryClient(); const budgetOverrideQuery = useQuery({ ...userAIBudgetOverride(user.id), @@ -99,6 +109,40 @@ export const UserAIBudgetOverrideDialog: FC< userGroupsQuery.isLoading || groupBudgetQuery.isLoading; const isSubmitting = saveMutation.isPending || deleteMutation.isPending; + const budget: BudgetProps = { + user, + currentGroup, + override: budgetOverrideQuery.data ?? null, + groupBudget: groupBudgetQuery.data ?? null, + userGroups: userGroupsQuery.data ?? [], + }; + + let body: ReactNode; + if (loadError) { + body = <ErrorAlert error={loadError} />; + } else if (isLoading) { + body = ( + <div className="flex items-center gap-2 text-sm text-content-secondary"> + <Spinner loading /> + Loading AI budget... + </div> + ); + } else if (canUpdate) { + body = ( + <OverrideForm + {...budget} + defaultGroupId={ + effectiveGroupId === undefined ? currentGroup.id : effectiveGroupId + } + isSubmitting={isSubmitting} + onSave={saveMutation.mutateAsync} + onRemove={deleteMutation.mutateAsync} + onClose={() => onOpenChange(false)} + /> + ); + } else { + body = <ReadOnlyBudget {...budget} />; + } return ( <Dialog @@ -127,44 +171,72 @@ export const UserAIBudgetOverrideDialog: FC< /> </div> - {loadError ? ( - <ErrorAlert error={loadError} /> - ) : isLoading ? ( - <div className="flex items-center gap-2 text-sm text-content-secondary"> - <Spinner loading /> - Loading AI budget... - </div> - ) : ( - <OverrideForm - user={user} - currentGroup={currentGroup} - defaultGroupId={ - effectiveGroupId === undefined - ? currentGroup.id - : effectiveGroupId - } - override={budgetOverrideQuery.data ?? null} - groupBudget={groupBudgetQuery.data ?? null} - userGroups={userGroupsQuery.data ?? []} - isSubmitting={isSubmitting} - onSave={saveMutation.mutateAsync} - onRemove={deleteMutation.mutateAsync} - onClose={() => onOpenChange(false)} - /> - )} + {body} </DialogContent> </Dialog> ); }; -interface OverrideFormProps { +interface BudgetProps { user: ReducedUser; currentGroup: Group; - // Group marked "(default)" in the picker; null marks none. - defaultGroupId: string | null; override: UserAIBudgetOverride | null; groupBudget: GroupAIBudget | null; userGroups: readonly Group[]; +} + +/** The member's effective limit as a sentence, to place inside a paragraph. */ +const BudgetSummary: FC<BudgetProps> = ({ + user, + currentGroup, + override, + groupBudget, + userGroups, +}) => { + if (!override) { + return ( + <> + {user.username}'s monthly limit is{" "} + <Bold> + {groupBudget ? formatUSD(groupBudget.spend_limit_micros) : "uncapped"} + </Bold> + , charged to <Bold>{groupDisplayName(currentGroup)}</Bold> group. + </> + ); + } + + const overrideGroup = findGroup(currentGroup, userGroups, override.group_id); + return ( + <> + {user.username}'s <Bold>custom</Bold> monthly limit is{" "} + <Bold>{formatUSD(override.spend_limit_micros)}</Bold>, charged to{" "} + {overrideGroup ? ( + <> + <Bold>{groupDisplayName(overrideGroup)}</Bold> group. + </> + ) : ( + // The group is unresolvable here, so it can't be named. + <Bold>their group.</Bold> + )} + </> + ); +}; + +/** + * The budget without any editing controls. Setting an override requires + * updating both the user and the group it charges, so group admins can read a + * member's budget without being able to change it. + */ +const ReadOnlyBudget: FC<BudgetProps> = (props) => ( + <p className="m-0 text-sm text-content-secondary"> + <BudgetSummary {...props} /> To update this limit, contact a Coder + administrator. + </p> +); + +interface OverrideFormProps extends BudgetProps { + // Group marked "(default)" in the picker; null marks none. + defaultGroupId: string | null; isSubmitting: boolean; onSave: (request: UpsertUserAIBudgetOverrideRequest) => Promise<unknown>; onRemove: () => Promise<unknown>; @@ -211,11 +283,14 @@ const OverrideForm: FC<OverrideFormProps> = ({ }, [currentGroup, userGroups]); const selectedGroup = groupOptions.find((g) => g.id === selectedGroupId); - const overrideGroup = groupOptions.find((g) => g.id === override?.group_id); - // A "0" budget is valid and disables AI; empty or negative is not. + // A "0" budget is valid and disables AI. Empty, negative, or above the + // configurable maximum is not. const budgetAmount = Number(budgetDollars); - const budgetValid = budgetDollars.trim() !== "" && budgetAmount >= 0; + const budgetValid = + budgetDollars.trim() !== "" && + budgetAmount >= 0 && + budgetAmount <= maxAIBudgetDollars; // Hold the error until the field is touched, so it doesn't flag immediately. const budgetInvalid = overrideEnabled && budgetTouched && !budgetValid; const budgetDisablesAI = budgetValid && budgetAmount === 0; @@ -261,26 +336,13 @@ const OverrideForm: FC<OverrideFormProps> = ({ return ( <form onSubmit={handleSubmit} className="flex flex-col gap-5"> <p className="m-0 text-sm text-content-secondary"> - {override ? ( - <> - {user.username}'s <Bold>custom</Bold> monthly limit is{" "} - <Bold>{formatUSD(override.spend_limit_micros)}</Bold>, charged to{" "} - <Bold> - {overrideGroup ? groupDisplayName(overrideGroup) : "their group"} - </Bold>{" "} - group. - </> - ) : ( - <> - {user.username}'s monthly limit is{" "} - <Bold> - {groupBudget - ? formatUSD(groupBudget.spend_limit_micros) - : "uncapped"} - </Bold> - , charged to <Bold>{groupDisplayName(currentGroup)}</Bold> group. - </> - )} + <BudgetSummary + user={user} + currentGroup={currentGroup} + override={override} + groupBudget={groupBudget} + userGroups={userGroups} + /> </p> <Separator /> @@ -319,6 +381,7 @@ const OverrideForm: FC<OverrideFormProps> = ({ onBlur={() => setBudgetTouched(true)} type="number" min="0" + max={maxAIBudgetDollars} step="1" aria-invalid={budgetInvalid} aria-describedby={ @@ -332,7 +395,7 @@ const OverrideForm: FC<OverrideFormProps> = ({ id={`${budgetId}-error`} className="m-0 text-sm text-content-destructive" > - Enter a monthly budget of 0 or more. + {aiBudgetRangeError} </p> )} </div> @@ -423,4 +486,15 @@ const Bold: FC<{ children: ReactNode }> = ({ children }) => ( const groupDisplayName = (group: Group): string => group.display_name || group.name; +/** + * Finds a group among the ones this dialog knows about. Groups in another + * organization aren't fetchable here, so they resolve to undefined. + */ +const findGroup = ( + currentGroup: Group, + userGroups: readonly Group[], + groupID: string, +): Group | undefined => + [currentGroup, ...userGroups].find((group) => group.id === groupID); + const formatUSD = (micros: number): string => `${formatBudgetUSD(micros)} USD`; diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx index 4e6709ed715f7..b9181299dcb94 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx @@ -1,11 +1,20 @@ import { type FormikTouched, useFormik } from "formik"; import { type FC, type ReactNode, useState } from "react"; -import type { - CreateUserSecretRequest, - UpdateUserSecretRequest, - UserSecret, +import { + type FieldError, + getErrorMessage, + isApiError, + isApiErrorResponse, +} from "#/api/errors"; +import { + type CreateUserSecretRequest, + type ImportUserSecretsRequest, + MaxSecretsFileBytes, + type UpdateUserSecretRequest, + type UserSecret, } from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { Dialog, @@ -14,9 +23,10 @@ import { DialogHeader, DialogTitle, } from "#/components/Dialog/Dialog"; +import { FileUpload } from "#/components/FileUpload/FileUpload"; import { FormField } from "#/components/FormField/FormField"; -import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; +import { Separator } from "#/components/Separator/Separator"; import { Spinner } from "#/components/Spinner/Spinner"; import { Textarea } from "#/components/Textarea/Textarea"; import { cn } from "#/utils/cn"; @@ -28,6 +38,7 @@ import { mapSecretApiErrorToFormErrors, type SecretFieldErrors, type SecretFormValues, + secretsFileFormatFromFilename, } from "./secretForm"; type SecretDialogProps = { @@ -43,6 +54,7 @@ type SecretDialogProps = { name: string, request: UpdateUserSecretRequest, ) => Promise<UserSecret> | UserSecret; + onImportSecrets: (request: ImportUserSecretsRequest) => Promise<UserSecret[]>; }; const emptyValues: SecretFormValues = { @@ -64,6 +76,7 @@ export const SecretDialog: FC<SecretDialogProps> = ({ onClose, onCreateSecret, onUpdateSecret, + onImportSecrets, }) => { const isEdit = Boolean(secret); const initialValues = secret @@ -76,6 +89,9 @@ export const SecretDialog: FC<SecretDialogProps> = ({ } : emptyValues; const [clearValueRequested, setClearValueRequested] = useState(false); + const [importFile, setImportFile] = useState<File | undefined>(undefined); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState<unknown>(undefined); const form = useFormik<SecretFormValues>({ initialValues, @@ -111,17 +127,62 @@ export const SecretDialog: FC<SecretDialogProps> = ({ const closeDialog = () => { setClearValueRequested(false); + setImportFile(undefined); + setImportError(undefined); + setIsImporting(false); form.resetForm(); onClose(); }; + const handleImportFile = (file: File) => { + if (isImporting) { + return; + } + setImportError(undefined); + setImportFile(file); + + const format = secretsFileFormatFromFilename(file.name); + if (!format) { + setImportError({ + message: + "Unsupported file type. Import a .env, .json, .yaml, or .yml file.", + }); + return; + } + if (file.size > MaxSecretsFileBytes) { + setImportError({ + message: "File is too large. Import a file of 1 MiB or smaller.", + }); + return; + } + + setIsImporting(true); + const reader = new FileReader(); + reader.onload = async () => { + const content = typeof reader.result === "string" ? reader.result : ""; + try { + await onImportSecrets({ format, content }); + closeDialog(); + } catch (error) { + setImportError(error); + } finally { + setIsImporting(false); + } + }; + reader.onerror = () => { + setImportError({ message: "Failed to read the selected file." }); + setIsImporting(false); + }; + reader.readAsText(file); + }; + const request = secret ? buildUpdateUserSecretRequest(secret, form.values, { clearValue: clearValueRequested, }) : undefined; const hasUpdate = request ? Object.keys(request).length > 0 : false; - const isBusy = isSubmitting || form.isSubmitting; + const isBusy = isSubmitting || form.isSubmitting || isImporting; const confirmDisabled = isBusy || !form.isValid || (secret ? !hasUpdate : !form.dirty); const getFieldHelpers = getFormHelpers(form); @@ -193,6 +254,32 @@ export const SecretDialog: FC<SecretDialogProps> = ({ </> ) : ( <> + <div className="flex flex-col gap-3"> + <FileUpload + isUploading={isImporting} + file={importFile} + onUpload={handleImportFile} + onUnsupportedFile={handleImportFile} + onRemove={() => { + setImportFile(undefined); + setImportError(undefined); + }} + removeLabel="Remove file" + title="Import secrets from a file" + description="Import a single or multiple secrets at once with a .env, .json, .yaml, or .yml file." + extensions={["env", "json", "yaml", "yml"]} + /> + {importError !== undefined && ( + <ImportSecretsError error={importError} /> + )} + </div> + <div className="flex items-center"> + <Separator className="flex-1" /> + <span className="whitespace-nowrap px-3 text-xs text-content-secondary"> + or add individually + </span> + <Separator className="flex-1" /> + </div> <SecretFields getFieldHelpers={getFieldHelpers} showRequiredLabels @@ -353,13 +440,13 @@ const SecretValueField: FC<SecretValueFieldProps> = ({ {required ? <RequiredFieldLabel>Value</RequiredFieldLabel> : "Value"} </Label> <div className="flex flex-col gap-2 sm:flex-row sm:items-start"> - <Input + <Textarea id={field.id} name={field.name} - type="text" value={value} placeholder={placeholder} autoComplete="off" + rows={4} aria-required={required} aria-invalid={displayField.error} aria-describedby={ @@ -371,9 +458,9 @@ const SecretValueField: FC<SecretValueFieldProps> = ({ } disabled={clearValueRequested} className={cn( - "placeholder:text-content-disabled sm:flex-1", + "placeholder:text-content-disabled sm:flex-1 font-mono", displayField.error && "border-border-destructive", - maskTypedValue && "[-webkit-text-security:circle]", + maskTypedValue && "[-webkit-text-security:disc]", )} onFocus={(event) => { if (isShowingSavedValue) { @@ -465,3 +552,42 @@ function touchedFromFieldErrors( Object.keys(fieldErrors).map((field) => [field, true]), ) as FormikTouched<SecretFormValues>; } + +type ImportSecretsErrorProps = { + error: unknown; +}; + +const ImportSecretsError: FC<ImportSecretsErrorProps> = ({ error }) => { + const validations = getImportSecretValidations(error); + if (validations.length === 0) { + return <ErrorAlert error={error} showDebugDetail={false} />; + } + + return ( + <Alert severity="error" prominent> + <AlertTitle> + {getErrorMessage(error, "Failed to import secrets.")} + </AlertTitle> + <AlertDescription> + <ul className="m-0 flex list-disc flex-col gap-1 pl-5"> + {validations.map((validation) => ( + <li key={validation.field}> + <span className="font-semibold">{validation.field}</span> + <span className="block">{validation.detail}</span> + </li> + ))} + </ul> + </AlertDescription> + </Alert> + ); +}; + +function getImportSecretValidations(error: unknown): FieldError[] { + if (isApiError(error)) { + return error.response.data.validations ?? []; + } + if (isApiErrorResponse(error)) { + return error.validations ?? []; + } + return []; +} diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx index fa74cfd4a5d01..3f72521c33bc0 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx @@ -5,11 +5,13 @@ import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { createUserSecret, deleteUserSecret, + importUserSecrets, updateUserSecret, userSecrets, } from "#/api/queries/userSecrets"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { SecretsPageView } from "./SecretsPageView"; +import { buildImportSuccessMessage } from "./secretForm"; const SecretsPage: FC = () => { const { user: me } = useAuthenticated(); @@ -25,6 +27,9 @@ const SecretsPage: FC = () => { const deleteSecretMutation = useMutation( deleteUserSecret(queryClient, me.id), ); + const importSecretsMutation = useMutation( + importUserSecrets(queryClient, me.id), + ); return ( <SecretsPageView @@ -54,6 +59,11 @@ const SecretsPage: FC = () => { toast.success(`Updated secret "${secret.name}" successfully.`); return secret; }} + onImportSecrets={async (request) => { + const secrets = await importSecretsMutation.mutateAsync(request); + toast.success(buildImportSuccessMessage(secrets)); + return secrets; + }} onDeleteSecret={async (secret) => { try { await deleteSecretMutation.mutateAsync(secret.name); @@ -65,6 +75,26 @@ const SecretsPage: FC = () => { throw error; } }} + onToggleSecretEnabled={async (secret, enabled) => { + try { + await updateSecretMutation.mutateAsync({ + name: secret.name, + request: { enabled }, + }); + toast.success( + `${enabled ? "Enabled" : "Disabled"} secret "${secret.name}".`, + ); + } catch (error) { + toast.error( + getErrorMessage( + error, + `Failed to ${enabled ? "enable" : "disable"} secret.`, + ), + { description: getErrorDetail(error) }, + ); + throw error; + } + }} /> ); }; diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index bac0308dfadd9..2668ca7e62139 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -1,11 +1,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, waitFor, within } from "storybook/test"; -import type { - CreateUserSecretRequest, - UpdateUserSecretRequest, - UserSecret, +import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { + type CreateUserSecretRequest, + type ImportUserSecretsRequest, + MaxSecretsFileBytes, + type UpdateUserSecretRequest, + type UserSecret, } from "#/api/typesGenerated"; -import { MockUserSecrets, mockApiError } from "#/testHelpers/entities"; +import { createDeferred } from "#/testHelpers/deferred"; +import { + MockImportedUserSecrets, + MockUserSecrets, + mockApiError, +} from "#/testHelpers/entities"; import { SAVED_SECRET_VALUE_DISPLAY } from "./SecretDialog"; import { SecretsPageView } from "./SecretsPageView"; @@ -28,7 +35,9 @@ const meta: Meta<typeof SecretsPageView> = { onRefresh: fn(), onCreateSecret: fn(), onUpdateSecret: fn(), + onImportSecrets: fn(), onDeleteSecret: fn(), + onToggleSecretEnabled: fn(), }, }; @@ -45,6 +54,9 @@ type UpdateSecretMock = ReturnType< type DeleteSecretMock = ReturnType< typeof fn<(secret: UserSecret) => Promise<void> | void> >; +type ToggleSecretEnabledMock = ReturnType< + typeof fn<(secret: UserSecret, enabled: boolean) => Promise<void> | void> +>; const waitForDialogToClose = async (body: ReturnType<typeof within>) => { await waitFor(() => { @@ -52,6 +64,16 @@ const waitForDialogToClose = async (body: ReturnType<typeof within>) => { }); }; +const uploadImportFile = async (canvasElement: HTMLElement, file: File) => { + const user = userEvent.setup({ applyAccept: false }); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + await user.upload(dialog.getByTestId("file-upload"), file); + return { user, dialog, body }; +}; + const expectNoValueField = (body: ReturnType<typeof within>) => { expect(body.queryByLabelText("Value")).not.toBeInTheDocument(); }; @@ -64,6 +86,7 @@ const createSecretFromRequest = ( description: request.description ?? "", env_name: request.env_name ?? "", file_path: request.file_path ?? "", + enabled: request.enabled ?? true, created_at: "2026-05-04T00:00:00Z", updated_at: "2026-05-04T00:00:00Z", }); @@ -395,9 +418,11 @@ export const EditSecretClearValue: Story = { await user.click(dialog.getByRole("button", { name: "Clear" })); await expect(valueField).toHaveValue(""); await expect(valueField).toBeDisabled(); - await expect( - dialog.getByText("Saved value will be cleared when you update."), - ).toBeVisible(); + await waitFor(() => + expect( + dialog.getByText("Saved value will be cleared when you update."), + ).toBeVisible(), + ); await expect(updateButton).toBeEnabled(); await user.click(dialog.getByRole("button", { name: "Undo" })); @@ -597,3 +622,324 @@ export const CreateMutationErrorDisplay: Story = { expectNoValueField(body); }, }; + +const importSecretsSuccess = fn< + (request: ImportUserSecretsRequest) => Promise<UserSecret[]> +>(async () => MockImportedUserSecrets); + +export const ImportSecretsFromFileSubmit: Story = { + args: { + onImportSecrets: importSecretsSuccess, + }, + beforeEach: () => { + importSecretsSuccess.mockClear(); + }, + play: async ({ canvasElement }) => { + const { body } = await uploadImportFile( + canvasElement, + new File(["A=1\nB=2"], "secrets.ENV", { type: "text/plain" }), + ); + + await waitFor(() => expect(importSecretsSuccess).toHaveBeenCalledTimes(1)); + expect(importSecretsSuccess).toHaveBeenCalledWith({ + format: "env", + content: "A=1\nB=2", + }); + await waitForDialogToClose(body); + }, +}; + +const importSecretsValidationError = fn< + (request: ImportUserSecretsRequest) => Promise<UserSecret[]> +>(async () => { + throw mockApiError({ + message: "Validation failed.", + validations: [ + { + field: "secrets[1].value", + detail: "Value is required.", + }, + ], + }); +}); + +export const ImportSecretsValidationError: Story = { + args: { + onImportSecrets: importSecretsValidationError, + }, + beforeEach: () => { + importSecretsValidationError.mockClear(); + }, + play: async ({ canvasElement }) => { + const { dialog } = await uploadImportFile( + canvasElement, + new File(["A=1\nB="], "secrets.env", { type: "text/plain" }), + ); + + await waitFor(() => + expect(importSecretsValidationError).toHaveBeenCalledTimes(1), + ); + await waitFor(() => + expect(dialog.getByText("secrets[1].value")).toBeVisible(), + ); + expect(dialog.getByText("Value is required.")).toBeVisible(); + expect(dialog.getByRole("heading", { name: "Add secret" })).toBeVisible(); + }, +}; + +const importSecretsUnsupportedFile = fn< + (request: ImportUserSecretsRequest) => Promise<UserSecret[]> +>(async () => MockImportedUserSecrets); + +export const ImportSecretsUnsupportedFile: Story = { + args: { + onImportSecrets: importSecretsUnsupportedFile, + }, + beforeEach: () => { + importSecretsUnsupportedFile.mockClear(); + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + const dropZone = dialog.getByRole("button", { + name: /Import secrets from a file/, + }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(["not a secret"], "bad.txt", { type: "text/plain" }), + ); + dropZone.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + + await waitFor(() => + expect( + dialog.getByText( + "Unsupported file type. Import a .env, .json, .yaml, or .yml file.", + ), + ).toBeVisible(), + ); + expect(importSecretsUnsupportedFile).not.toHaveBeenCalled(); + }, +}; + +const importSecretsTooLarge = fn< + (request: ImportUserSecretsRequest) => Promise<UserSecret[]> +>(async () => MockImportedUserSecrets); + +export const ImportSecretsTooLarge: Story = { + args: { + onImportSecrets: importSecretsTooLarge, + }, + beforeEach: () => { + importSecretsTooLarge.mockClear(); + }, + play: async ({ canvasElement }) => { + const { dialog } = await uploadImportFile( + canvasElement, + new File([new Uint8Array(MaxSecretsFileBytes + 1)], "too-large.env"), + ); + + await waitFor(() => + expect( + dialog.getByText( + "File is too large. Import a file of 1 MiB or smaller.", + ), + ).toBeVisible(), + ); + expect(importSecretsTooLarge).not.toHaveBeenCalled(); + }, +}; + +const importSecretsParseError = fn< + (request: ImportUserSecretsRequest) => Promise<UserSecret[]> +>(async () => { + throw mockApiError({ + message: "Failed to parse secrets file.", + detail: "Line 2 must contain KEY=VALUE.", + }); +}); + +export const ImportSecretsParseError: Story = { + args: { + onImportSecrets: importSecretsParseError, + }, + beforeEach: () => { + importSecretsParseError.mockClear(); + }, + play: async ({ canvasElement }) => { + const { dialog } = await uploadImportFile( + canvasElement, + new File(["GOOD=1\nbad line"], "secrets.env"), + ); + + await expect( + await dialog.findByText("Failed to parse secrets file."), + ).toBeVisible(); + expect(dialog.getByText("Line 2 must contain KEY=VALUE.")).toBeVisible(); + expect(dialog.queryByText("Response data")).not.toBeInTheDocument(); + expect(dialog.queryByText("Stack Trace")).not.toBeInTheDocument(); + }, +}; + +export const ImportSecretsFileReadError: Story = { + beforeEach: () => { + const readAsText = spyOn(FileReader.prototype, "readAsText"); + readAsText.mockImplementation(function (this: FileReader) { + this.dispatchEvent(new ProgressEvent("error")); + }); + return () => readAsText.mockRestore(); + }, + play: async ({ canvasElement }) => { + const { dialog } = await uploadImportFile( + canvasElement, + new File(["A=1"], "secrets.env"), + ); + + await expect( + await dialog.findByText("Failed to read the selected file."), + ).toBeVisible(); + }, +}; + +const pendingImport = createDeferred<UserSecret[]>(); +const importSecretsPending = fn< + (request: ImportUserSecretsRequest) => Promise<UserSecret[]> +>(() => pendingImport.promise); + +export const ImportSecretsPending: Story = { + args: { + onImportSecrets: importSecretsPending, + }, + beforeEach: () => { + importSecretsPending.mockClear(); + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await user.click(canvas.getByRole("button", { name: "Add secret" })); + const dialog = within(await body.findByRole("dialog")); + const input = dialog.getByTestId("file-upload"); + await user.upload(input, new File(["A=1"], "secrets.env")); + + await waitFor(() => expect(importSecretsPending).toHaveBeenCalledTimes(1)); + expect(dialog.getByRole("button", { name: "Cancel" })).toBeDisabled(); + expect(dialog.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(input).toBeDisabled(); + await user.upload(input, new File(["B=2"], "second.env")); + expect(importSecretsPending).toHaveBeenCalledTimes(1); + }, +}; + +const importSecretsAfterRemoval = fn< + (request: ImportUserSecretsRequest) => Promise<UserSecret[]> +>(async () => MockImportedUserSecrets); + +export const ImportSecretsRemoveAndRetry: Story = { + args: { + onImportSecrets: importSecretsAfterRemoval, + }, + beforeEach: () => { + importSecretsAfterRemoval.mockClear(); + }, + play: async ({ canvasElement }) => { + const { user, dialog, body } = await uploadImportFile( + canvasElement, + new File(["not a secret"], "bad.txt"), + ); + + await expect( + await dialog.findByText( + "Unsupported file type. Import a .env, .json, .yaml, or .yml file.", + ), + ).toBeVisible(); + await user.click(dialog.getByRole("button", { name: "Remove file" })); + expect( + dialog.queryByText( + "Unsupported file type. Import a .env, .json, .yaml, or .yml file.", + ), + ).not.toBeInTheDocument(); + await user.upload( + dialog.getByTestId("file-upload"), + new File(["A=1"], "secrets.env"), + ); + await waitFor(() => + expect(importSecretsAfterRemoval).toHaveBeenCalledTimes(1), + ); + await waitForDialogToClose(body); + }, +}; + +export const ToggleEnabledSubmit: Story = { + args: { + onToggleSecretEnabled: fn< + (secret: UserSecret, enabled: boolean) => Promise<void> + >(async () => {}), + }, + play: async ({ canvasElement, args }) => { + const onToggleSecretEnabled = + args.onToggleSecretEnabled as ToggleSecretEnabledMock; + onToggleSecretEnabled.mockClear(); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const secret = findVisibleSecretByName("EXAMPLE_TOKEN"); + + const toggle = canvas.getByRole("switch", { + name: `Toggle secret ${secret.name}`, + }); + await expect(toggle).toBeChecked(); + await user.click(toggle); + + await waitFor(() => expect(onToggleSecretEnabled).toHaveBeenCalledTimes(1)); + expect(onToggleSecretEnabled).toHaveBeenCalledWith(secret, false); + }, +}; + +export const ToggleEnabledMutationErrorDisplay: Story = { + args: { + onToggleSecretEnabled: fn< + (secret: UserSecret, enabled: boolean) => Promise<void> + >(async () => { + throw mockApiError({ message: "Failed to disable secret." }); + }), + }, + play: async ({ canvasElement, args }) => { + const onToggleSecretEnabled = + args.onToggleSecretEnabled as ToggleSecretEnabledMock; + onToggleSecretEnabled.mockClear(); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const secret = findVisibleSecretByName("EXAMPLE_TOKEN"); + + const toggle = canvas.getByRole("switch", { + name: `Toggle secret ${secret.name}`, + }); + await user.click(toggle); + + await waitFor(() => expect(onToggleSecretEnabled).toHaveBeenCalledTimes(1)); + // Handler rejected; the parent owns the secret state so the switch + // remains checked in this story where no state change is applied. + await expect(toggle).toBeChecked(); + }, +}; + +export const ToggleEnabledDisabledForTargetlessSecret: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const secret = findVisibleSecretByName("SERVICE_PASSWORD"); + + const toggle = canvas.getByRole("switch", { + name: `Toggle secret ${secret.name}`, + }); + await expect(toggle).not.toBeChecked(); + await expect(toggle).toBeDisabled(); + }, +}; diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx index 8306ea2d203cf..1e44db1b232d3 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx @@ -2,6 +2,7 @@ import { PlusIcon, RefreshCwIcon } from "lucide-react"; import { type FC, useRef, useState } from "react"; import type { CreateUserSecretRequest, + ImportUserSecretsRequest, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; @@ -35,7 +36,12 @@ type SecretsPageViewProps = { name: string, request: UpdateUserSecretRequest, ) => Promise<UserSecret> | UserSecret; + onImportSecrets: (request: ImportUserSecretsRequest) => Promise<UserSecret[]>; onDeleteSecret: (secret: UserSecret) => Promise<void> | void; + onToggleSecretEnabled: ( + secret: UserSecret, + enabled: boolean, + ) => Promise<void> | void; }; type SecretDialogState = @@ -54,7 +60,9 @@ export const SecretsPageView: FC<SecretsPageViewProps> = ({ onRefresh, onCreateSecret, onUpdateSecret, + onImportSecrets, onDeleteSecret, + onToggleSecretEnabled, }) => { const [dialogState, setDialogState] = useState<SecretDialogState>({ mode: "add", @@ -122,6 +130,7 @@ export const SecretsPageView: FC<SecretsPageViewProps> = ({ onClose={closeSecretDialog} onCreateSecret={onCreateSecret} onUpdateSecret={onUpdateSecret} + onImportSecrets={onImportSecrets} /> {getSecretsError ? <ErrorAlert error={getSecretsError} /> : undefined} @@ -143,6 +152,7 @@ export const SecretsPageView: FC<SecretsPageViewProps> = ({ onAddSecret={openAddSecret} onEditSecret={openEditSecret} onDeleteSecret={onDeleteSecret} + onToggleEnabled={onToggleSecretEnabled} /> </section> </div> diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx index a74dc30c48151..ac1966f9b1db7 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx @@ -11,6 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; +import { Switch } from "#/components/Switch/Switch"; import { Table, TableBody, @@ -21,6 +22,11 @@ import { } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { relativeTime } from "#/utils/time"; type SecretsTableProps = { @@ -34,6 +40,10 @@ type SecretsTableProps = { returnFocusElement?: HTMLElement | null, ) => void; onDeleteSecret: (secret: UserSecret) => Promise<void> | void; + onToggleEnabled: ( + secret: UserSecret, + enabled: boolean, + ) => Promise<void> | void; }; export const SecretsTable: FC<SecretsTableProps> = ({ @@ -44,8 +54,25 @@ export const SecretsTable: FC<SecretsTableProps> = ({ onAddSecret, onEditSecret, onDeleteSecret, + onToggleEnabled, }) => { const [secretToDelete, setSecretToDelete] = useState<UserSecret>(); + const [togglingSecretId, setTogglingSecretId] = useState<string | null>(null); + + const handleToggle = (secret: UserSecret, enabled: boolean) => { + setTogglingSecretId(secret.id); + void Promise.resolve() + .then(() => onToggleEnabled(secret, enabled)) + .catch(() => { + // onToggleEnabled reports failures with a toast before rejecting. + // Swallow the rejection here to avoid an unhandled promise rejection warning. + }) + .finally(() => { + setTogglingSecretId((current) => + current === secret.id ? null : current, + ); + }); + }; return ( <> @@ -69,12 +96,13 @@ export const SecretsTable: FC<SecretsTableProps> = ({ <Table aria-label="User secrets"> <TableHeader> <TableRow> - <TableHead className="w-[16%]">Name</TableHead> - <TableHead className="w-[14%]">Environment variable</TableHead> - <TableHead className="w-[18%]">File path</TableHead> - <TableHead className="w-[11%]">Type</TableHead> - <TableHead className="w-[23%]">Description</TableHead> - <TableHead className="w-[12%]">Updated</TableHead> + <TableHead className="w-[14%]">Name</TableHead> + <TableHead className="w-[13%]">Environment variable</TableHead> + <TableHead className="w-[16%]">File path</TableHead> + <TableHead className="w-[10%]">Type</TableHead> + <TableHead className="w-[20%]">Description</TableHead> + <TableHead className="w-[11%]">Updated</TableHead> + <TableHead className="w-[15%]">Enabled</TableHead> <TableHead className="w-[1%]" /> </TableRow> </TableHeader> @@ -115,6 +143,13 @@ export const SecretsTable: FC<SecretsTableProps> = ({ <TableCell data-pixel="ignore"> {relativeTime(secret.updated_at)} </TableCell> + <TableCell> + <EnabledToggle + secret={secret} + isPending={togglingSecretId === secret.id} + onToggle={handleToggle} + /> + </TableCell> <TableCell> <SecretRowActions secret={secret} @@ -160,6 +195,66 @@ const SecretTypeBadge: FC<{ secret: UserSecret }> = ({ secret }) => { return <Badge>not injected</Badge>; }; +type EnabledToggleProps = { + secret: UserSecret; + isPending: boolean; + onToggle: (secret: UserSecret, enabled: boolean) => void; +}; + +const EnabledToggle: FC<EnabledToggleProps> = ({ + secret, + isPending, + onToggle, +}) => { + const hasTarget = Boolean(secret.env_name) || Boolean(secret.file_path); + // An enabled secret must have at least one injection target. Prevent + // enabling a target-less secret; the user must add a target first. + const cannotEnable = !secret.enabled && !hasTarget; + const label = `Toggle secret ${secret.name}`; + const stateLabel = secret.enabled ? "Enabled" : "Disabled"; + + const control = ( + <Switch + aria-label={label} + checked={secret.enabled} + disabled={isPending || cannotEnable} + onCheckedChange={(checked) => onToggle(secret, checked)} + /> + ); + + if (cannotEnable) { + return ( + <div className="flex items-center gap-2"> + <Tooltip> + <TooltipTrigger asChild> + {/* + * Wrap the disabled Switch in a focusable span so the + * tooltip can be triggered by keyboard and pointer. + * biome-ignore lint/a11y/noNoninteractiveTabindex: needed to + * surface the tooltip on a disabled control via keyboard focus. + */} + <span tabIndex={0} className="inline-flex"> + {control} + </span> + </TooltipTrigger> + <TooltipContent side="top"> + Add an environment variable or file path before enabling this + secret. + </TooltipContent> + </Tooltip> + <span className="text-content-secondary text-xs">{stateLabel}</span> + </div> + ); + } + + return ( + <div className="flex items-center gap-2"> + {control} + <span className="text-content-secondary text-xs">{stateLabel}</span> + </div> + ); +}; + type SecretRowActionsProps = { secret: UserSecret; onEditSecret: ( diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts index f45415bdfa028..cb34711570636 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts @@ -1,10 +1,12 @@ import type { UserSecret } from "#/api/typesGenerated"; -import { mockApiError } from "#/testHelpers/entities"; +import { MockImportedUserSecret, mockApiError } from "#/testHelpers/entities"; import { buildCreateUserSecretRequest, + buildImportSuccessMessage, buildUpdateUserSecretRequest, getCreateSecretRequiredFieldErrors, mapSecretApiErrorToFormErrors, + secretsFileFormatFromFilename, } from "./secretForm"; const existingSecrets: UserSecret[] = [ @@ -14,6 +16,7 @@ const existingSecrets: UserSecret[] = [ description: "Service token", env_name: "SERVICE_TOKEN", file_path: "", + enabled: true, created_at: "2026-05-04T00:00:00Z", updated_at: "2026-05-04T00:00:00Z", }, @@ -23,11 +26,64 @@ const existingSecrets: UserSecret[] = [ description: "", env_name: "SERVICE_API_KEY", file_path: "~/.config/service/key", + enabled: true, created_at: "2026-05-04T00:00:00Z", updated_at: "2026-05-04T00:00:00Z", }, ]; +describe("buildImportSuccessMessage", () => { + it("reports a single secret imported successfully", () => { + expect(buildImportSuccessMessage([MockImportedUserSecret])).toBe( + "Imported 1 secret successfully.", + ); + }); + + it("reports multiple secrets imported successfully", () => { + expect( + buildImportSuccessMessage([ + MockImportedUserSecret, + { ...MockImportedUserSecret, id: "second-secret" }, + ]), + ).toBe("Imported 2 secrets successfully."); + }); + + it("reports one secret imported without an env name", () => { + expect( + buildImportSuccessMessage([ + MockImportedUserSecret, + { + ...MockImportedUserSecret, + id: "without-env-name", + env_name: "", + }, + ]), + ).toBe( + "Imported 2 secrets. " + + "1 was imported without an environment variable name " + + "because its key is not a valid environment variable name. Edit it to set one.", + ); + }); + + it("reports multiple secrets imported without env names", () => { + expect( + buildImportSuccessMessage([ + { ...MockImportedUserSecret, env_name: "" }, + { + ...MockImportedUserSecret, + id: "second-without-env-name", + env_name: "", + }, + MockImportedUserSecret, + ]), + ).toBe( + "Imported 3 secrets. " + + "2 were imported without an environment variable name " + + "because their keys are not valid environment variable names. Edit them to set one.", + ); + }); +}); + describe("getCreateSecretRequiredFieldErrors", () => { it("requires name and value on create", () => { expect( @@ -119,6 +175,31 @@ describe("payload builders", () => { }); }); +describe("secretsFileFormatFromFilename", () => { + it.each([ + ["a.env", "env"], + [".env", "env"], + ["prod.env", "env"], + ["config.json", "json"], + ["values.yaml", "yaml"], + ["values.yml", "yaml"], + ["CONFIG.JSON", "json"], + ["Values.YML", "yaml"], + ["secrets.ENV", "env"], + ])("maps %s to the %s format", (filename, format) => { + expect(secretsFileFormatFromFilename(filename)).toBe(format); + }); + + it.each([ + ["foo.txt"], + ["noextension"], + ["archive.tar.gz"], + [""], + ])("returns undefined for unsupported filename %s", (filename) => { + expect(secretsFileFormatFromFilename(filename)).toBeUndefined(); + }); +}); + describe("mapSecretApiErrorToFormErrors", () => { it("maps structured API validation errors to fields", () => { expect( diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts index 4b242ea4a0dc3..af951c464131d 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.ts @@ -6,6 +6,7 @@ import { } from "#/api/errors"; import type { CreateUserSecretRequest, + SecretsFileFormat, UpdateUserSecretRequest, UserSecret, } from "#/api/typesGenerated"; @@ -27,6 +28,41 @@ interface SecretFormErrors { formError?: string; } +export const buildImportSuccessMessage = (secrets: UserSecret[]): string => { + const total = secrets.length; + const noEnvName = secrets.filter((s) => s.env_name === "").length; + const secretWord = total === 1 ? "secret" : "secrets"; + if (noEnvName === 0) { + return `Imported ${total} ${secretWord} successfully.`; + } + const wasWere = noEnvName === 1 ? "was" : "were"; + const keyPhrase = + noEnvName === 1 + ? "its key is not a valid environment variable name. Edit it to set one." + : "their keys are not valid environment variable names. Edit them to set one."; + return ( + `Imported ${total} ${secretWord}. ` + + `${noEnvName} ${wasWere} imported without an environment variable name ` + + `because ${keyPhrase}` + ); +}; + +export const secretsFileFormatFromFilename = ( + filename: string, +): SecretsFileFormat | undefined => { + const lowerName = filename.toLowerCase(); + if (lowerName.endsWith(".env")) { + return "env"; + } + if (lowerName.endsWith(".json")) { + return "json"; + } + if (lowerName.endsWith(".yaml") || lowerName.endsWith(".yml")) { + return "yaml"; + } + return undefined; +}; + export const getCreateSecretRequiredFieldErrors = ( values: Pick<SecretFormValues, "name" | "value">, ): SecretFieldErrors => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx b/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx index 17baf47c5e770..ef15d1a653f52 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx @@ -10,12 +10,14 @@ interface WorkspacesEmptyProps { isUsingFilter: boolean; templates?: Template[]; canCreateTemplate: boolean; + canCreateWorkspace: boolean; } export const WorkspacesEmpty: FC<WorkspacesEmptyProps> = ({ isUsingFilter, templates, canCreateTemplate, + canCreateWorkspace, }) => { const getLink = useLinks(); @@ -34,6 +36,17 @@ export const WorkspacesEmpty: FC<WorkspacesEmptyProps> = ({ return <EmptyState message="No results matched your search" />; } + if (!canCreateWorkspace) { + return ( + <EmptyState + message="No workspaces" + description="You don't have permission to create workspaces. Contact your administrator if you need workspace access." + className="pb-0" + image={defaultImage} + /> + ); + } + if (templates && templates.length === 0 && canCreateTemplate) { return ( <EmptyState diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx index 9d857e43a18b4..0fec96ff6da47 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx @@ -74,6 +74,7 @@ const meta = { user: MockUserOwner, permissions: { viewDeploymentConfig: false, + createWorkspace: true, }, queries: [ { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 8baaa8f40fa71..2c08c716e85ba 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -169,6 +169,7 @@ const WorkspacesPage: FC = () => { <WorkspacesPageView canCreateTemplate={permissions.createTemplates} + canCreateWorkspace={permissions.createWorkspace} canChangeVersions={permissions.updateTemplates} checkedWorkspaces={checkedWorkspaces} chatsByWorkspace={chatsByWorkspaceQuery.data} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 35f2deba65b8c..5f3453aa251ae 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -172,6 +172,7 @@ const meta: Meta<typeof WorkspacesPageView> = { checkedWorkspaces: [], templates: mockTemplates, templatesFetchStatus: "success", + canCreateWorkspace: true, count: 13, page: 1, }, @@ -190,6 +191,55 @@ const meta: Meta<typeof WorkspacesPageView> = { export default meta; type Story = StoryObj<typeof WorkspacesPageView>; +export const CannotCreateWorkspace: Story = { + args: { + workspaces: [], + count: 0, + canCreateWorkspace: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByRole("button", { name: /new workspace/i })).toBeNull(); + await canvas.findByText(/don't have permission to create workspaces/i); + }, +}; + +export const CannotCreateWorkspaceWithWorkspaces: Story = { + args: { + workspaces: allWorkspaces, + count: allWorkspaces.length, + canCreateWorkspace: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText(allWorkspaces[0].name); + expect(canvas.queryByRole("button", { name: /new workspace/i })).toBeNull(); + }, +}; + +export const CannotCreateWorkspaceWithFilter: Story = { + args: { + workspaces: [], + count: 0, + canCreateWorkspace: false, + filterState: { + ...defaultFilterProps, + filter: { ...defaultFilterProps.filter, used: true }, + }, + }, + // The filter empty state takes priority: an active filter that matched + // nothing shows "no results" regardless of create permission, since the + // user may own workspaces the filter excluded. + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText(/no results matched your search/i); + expect( + canvas.queryByText(/don't have permission to create workspaces/i), + ).toBeNull(); + expect(canvas.queryByRole("button", { name: /new workspace/i })).toBeNull(); + }, +}; + export const AllStates: Story = { args: { workspaces: allWorkspaces, @@ -247,6 +297,7 @@ export const OwnerHasNoWorkspaces: Story = { workspaces: [], count: 0, canCreateTemplate: true, + canCreateWorkspace: true, }, }; @@ -256,6 +307,7 @@ export const OwnerHasNoWorkspacesAndNoTemplates: Story = { templates: [], count: 0, canCreateTemplate: true, + canCreateWorkspace: true, }, }; @@ -264,6 +316,7 @@ export const UserHasNoWorkspaces: Story = { workspaces: [], count: 0, canCreateTemplate: false, + canCreateWorkspace: true, }, }; @@ -273,6 +326,7 @@ export const UserHasNoWorkspacesAndNoTemplates: Story = { templates: [], count: 0, canCreateTemplate: false, + canCreateWorkspace: true, }, }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx new file mode 100644 index 0000000000000..c87395a888875 --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx @@ -0,0 +1,111 @@ +import { screen } from "@testing-library/react"; +import { MockTemplate } from "#/testHelpers/entities"; +import { renderWithAuth } from "#/testHelpers/renderHelpers"; +import type { WorkspaceFilterState } from "./filter/WorkspacesFilter"; +import { WorkspacesPageView } from "./WorkspacesPageView"; + +const mockMenu = { + initialOption: undefined, + isInitializing: false, + isSearching: false, + query: "", + searchOptions: [], + selectedOption: undefined, + selectOption: vi.fn(), + setQuery: vi.fn(), +}; + +const createFilterState = (used = false) => + ({ + filter: { + query: "", + values: {}, + used, + update: vi.fn(), + debounceUpdate: vi.fn(), + cancelDebounce: vi.fn(), + }, + menus: { + user: mockMenu, + template: mockMenu, + status: mockMenu, + organizations: mockMenu, + }, + }) as WorkspaceFilterState; + +const defaultProps = { + error: undefined, + workspaces: [], + checkedWorkspaces: [], + count: 0, + filterState: createFilterState(), + page: 1, + limit: 25, + onPageChange: vi.fn(), + onCheckChange: vi.fn(), + isRunningBatchAction: false, + onBatchDeleteTransition: vi.fn(), + onBatchUpdateTransition: vi.fn(), + onBatchStartTransition: vi.fn(), + onBatchStopTransition: vi.fn(), + templatesFetchStatus: "success" as const, + templates: [MockTemplate], + canCreateTemplate: false, + canCreateWorkspace: true, + canChangeVersions: false, + onActionSuccess: vi.fn().mockResolvedValue(undefined), + onActionError: vi.fn(), +}; + +describe("WorkspacesPageView", () => { + it("hides the New workspace button and explains the missing permission", async () => { + renderWithAuth( + <WorkspacesPageView {...defaultProps} canCreateWorkspace={false} />, + ); + + await screen.findByText(/don't have permission to create workspaces/i); + expect( + screen.queryByRole("button", { name: /new workspace/i }), + ).not.toBeInTheDocument(); + }); + + it("shows the New workspace button when the user can create workspaces", async () => { + renderWithAuth(<WorkspacesPageView {...defaultProps} />); + + expect( + await screen.findByRole("button", { name: /new workspace/i }), + ).toBeInTheDocument(); + }); + + it("shows the filter empty state instead of the no-permission empty state when a filter is active", async () => { + renderWithAuth( + <WorkspacesPageView + {...defaultProps} + canCreateWorkspace={false} + filterState={createFilterState(true)} + />, + ); + + await screen.findByText(/no results matched your search/i); + expect( + screen.queryByText(/don't have permission to create workspaces/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /new workspace/i }), + ).not.toBeInTheDocument(); + }); + + it("shows the filter empty state when the user can create workspaces but the filter matches nothing", async () => { + renderWithAuth( + <WorkspacesPageView + {...defaultProps} + filterState={createFilterState(true)} + />, + ); + + await screen.findByText(/no results matched your search/i); + expect( + screen.queryByText(/don't have permission to create workspaces/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 6f0fb34ca674a..aa5c4da077e31 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -51,6 +51,7 @@ interface WorkspacesPageViewProps { templatesFetchStatus: TemplateQuery["status"]; templates: TemplateQuery["data"]; canCreateTemplate: boolean; + canCreateWorkspace: boolean; canChangeVersions: boolean; onActionSuccess: () => Promise<void>; onActionError: (error: unknown) => void; @@ -75,6 +76,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ templates, templatesFetchStatus, canCreateTemplate, + canCreateWorkspace, canChangeVersions, onActionSuccess, onActionError, @@ -89,12 +91,14 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ <Margins className="pb-12"> <PageHeader actions={ - <WorkspacesButton - templates={templates} - templatesFetchStatus={templatesFetchStatus} - > - New workspace - </WorkspacesButton> + canCreateWorkspace && ( + <WorkspacesButton + templates={templates} + templatesFetchStatus={templatesFetchStatus} + > + New workspace + </WorkspacesButton> + ) } > <PageHeaderTitle> @@ -212,6 +216,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ ) : ( <WorkspacesTable canCreateTemplate={canCreateTemplate} + canCreateWorkspace={canCreateWorkspace} workspaces={workspaces} isUsingFilter={filterState.filter.used} checkedWorkspaces={checkedWorkspaces} diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 3526c24d937f1..f91b90d244a54 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -94,6 +94,7 @@ interface WorkspacesTableProps { onCheckChange: (checkedWorkspaces: readonly Workspace[]) => void; templates?: Template[]; canCreateTemplate: boolean; + canCreateWorkspace: boolean; onActionSuccess: () => Promise<void>; onActionError: (error: unknown) => void; chatsByWorkspace?: Record<string, string>; @@ -106,6 +107,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({ onCheckChange, templates, canCreateTemplate, + canCreateWorkspace, onActionSuccess, onActionError, chatsByWorkspace, @@ -168,6 +170,7 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({ templates={templates} isUsingFilter={isUsingFilter} canCreateTemplate={canCreateTemplate} + canCreateWorkspace={canCreateWorkspace} /> </TableCell> </TableRow> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 001af980d0e22..2c543dee09e70 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -578,6 +578,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ description: "Used by example templates.", env_name: "EXAMPLE_TOKEN", file_path: "", + enabled: true, created_at: "2026-04-28T16:30:00Z", updated_at: "2026-04-30T16:30:00Z", }, @@ -587,6 +588,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ description: "Mounted as a workspace file.", env_name: "", file_path: "~/.config/example/config.json", + enabled: true, created_at: "2026-04-29T16:30:00Z", updated_at: "2026-05-01T16:30:00Z", }, @@ -596,15 +598,20 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ description: "Available as an environment variable and file.", env_name: "SERVICE_API_KEY", file_path: "/var/run/secrets/service-api-key", + enabled: true, created_at: "2026-04-30T16:30:00Z", updated_at: "2026-05-02T16:30:00Z", }, { + // Mirrors a pre-migration secret that had both env_name and + // file_path empty. The migration flips such rows to + // enabled: false, so this is the shape they have after upgrade. id: "secret-not-injected", name: "SERVICE_PASSWORD", description: "", env_name: "", file_path: "", + enabled: false, created_at: "2026-05-01T16:30:00Z", updated_at: "2026-05-03T16:30:00Z", }, @@ -614,11 +621,33 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ description: "Used to exercise duplicate validation.", env_name: "DUPLICATE_API_KEY", file_path: "", + enabled: true, created_at: "2026-05-01T18:30:00Z", updated_at: "2026-05-03T18:30:00Z", }, ]; +export const MockImportedUserSecret: TypesGen.UserSecret = { + id: "imported-database-url", + name: "DATABASE_URL", + description: "", + env_name: "DATABASE_URL", + file_path: "", + enabled: true, + created_at: "2026-05-04T00:00:00Z", + updated_at: "2026-05-04T00:00:00Z", +}; + +export const MockImportedUserSecrets: TypesGen.UserSecret[] = [ + MockImportedUserSecret, + { + ...MockImportedUserSecret, + id: "imported-api-token", + name: "API_TOKEN", + env_name: "API_TOKEN", + }, +]; + export const MockTasksTabVisible: boolean = false; export const MockAIGatewayEnabled: boolean = true; @@ -3291,6 +3320,7 @@ export const MockTemplateExample2: TypesGen.TemplateExample = { export const MockPermissions: Permissions = { createTemplates: true, createUser: true, + createWorkspace: true, deleteTemplates: true, updateTemplates: true, viewAllUsers: true, @@ -3327,6 +3357,7 @@ export const MockPermissions: Permissions = { export const MockNoPermissions: Permissions = { createTemplates: false, createUser: false, + createWorkspace: false, deleteTemplates: false, updateTemplates: false, viewAllUsers: false, @@ -5525,6 +5556,54 @@ export const MockSession: TypesGen.AIBridgeSession = { last_active_at: "2026-03-09T10:28:15.03152Z", }; +export const MockAIBridgeSessionNetworkCalls: readonly TypesGen.AgentFirewallLog[] = + [ + { + id: "netcall-1", + session_id: "firewall-session-1", + sequence_number: 1, + proto: "http", + method: "POST", + detail: "https://api.github.com/repos/coder/coder", + allowed: true, + matched_rule: "allow api.github.com", + created_at: "2026-03-09T09:28:16.000Z", + }, + { + id: "netcall-2", + session_id: "firewall-session-1", + sequence_number: 2, + proto: "http", + method: "GET", + detail: "https://registry.npmjs.org/lodash", + allowed: false, + matched_rule: null, + created_at: "2026-03-09T09:28:17.000Z", + }, + { + id: "netcall-3", + session_id: "firewall-session-1", + sequence_number: 3, + proto: "http", + method: "POST", + detail: "https://hooks.slack.com/services/T01", + allowed: false, + matched_rule: null, + created_at: "2026-03-09T09:28:18.000Z", + }, + { + id: "netcall-4", + session_id: "firewall-session-1", + sequence_number: 4, + proto: "dns", + method: "A", + detail: "api.github.com", + allowed: true, + matched_rule: "allow api.github.com", + created_at: "2026-03-09T09:28:19.000Z", + }, + ]; + export const MockAIProviderOpenAI: TypesGen.AIProvider = { id: "7a5d6b6a-5f02-4a9c-9c4e-2b3e2a3d2f01", type: "openai", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 4b81b94afcbbc..a615681ff1bdc 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -228,6 +228,9 @@ export const handlers = [ status: 201, }); }), + http.post("/api/v2/users/:userId/secrets/batch", () => { + return HttpResponse.json(M.MockImportedUserSecrets, { status: 201 }); + }), http.patch( "/api/v2/users/:userId/secrets/:name", async ({ request, params }) => { @@ -444,6 +447,7 @@ function userSecretFromCreateRequest( description: request.description ?? "", env_name: request.env_name ?? "", file_path: request.file_path ?? "", + enabled: request.enabled ?? true, created_at: now, updated_at: now, }; diff --git a/site/src/utils/budget.test.ts b/site/src/utils/budget.test.ts index 7ee182c8fa812..923cd15448cc1 100644 --- a/site/src/utils/budget.test.ts +++ b/site/src/utils/budget.test.ts @@ -1,10 +1,36 @@ import { describe, expect, it } from "vitest"; import { clampPercentage, + formatSpendPeriodLabel, + formatSpendResetLabel, getSeverity, usageProgressPercentage, } from "./budget"; +// Pin a non-UTC zone so the UTC assertions fail if formatting falls back +// to local time. Under UTC runners both implementations agree. +process.env.TZ = "America/Los_Angeles"; + +describe("formatSpendPeriodLabel", () => { + it("renders API timestamps in UTC with the year on the exclusive end", () => { + expect( + formatSpendPeriodLabel("2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z"), + ).toBe("June 1 - July 1, 2026"); + }); + + it("uses the end year when the window crosses into the next year", () => { + expect( + formatSpendPeriodLabel("2026-12-01T00:00:00Z", "2027-01-01T00:00:00Z"), + ).toBe("December 1 - January 1, 2027"); + }); +}); + +describe("formatSpendResetLabel", () => { + it("renders the exclusive period_end in UTC", () => { + expect(formatSpendResetLabel("2026-08-01T00:00:00Z")).toBe("Aug 1, 2026"); + }); +}); + describe("getSeverity", () => { it("returns normal below the warning threshold", () => { expect(getSeverity(0, 50)).toBe("normal"); diff --git a/site/src/utils/budget.ts b/site/src/utils/budget.ts index 6b14b76970f4a..e3fbff4af7a07 100644 --- a/site/src/utils/budget.ts +++ b/site/src/utils/budget.ts @@ -1,5 +1,31 @@ +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); + export type UsageSeverity = "normal" | "warning" | "exceeded"; +/** + * Formats an AI budget window in UTC, e.g. "June 1 - July 1, 2026". Renders + * the exclusive period_end as-is. + */ +export function formatSpendPeriodLabel( + periodStart: string, + periodEnd: string, +): string { + const start = dayjs.utc(periodStart).format("MMMM D"); + const end = dayjs.utc(periodEnd).format("MMMM D, YYYY"); + return `${start} - ${end}`; +} + +/** + * Formats an AI budget window's exclusive period_end in UTC, e.g. + * "Aug 1, 2026", matching formatSpendPeriodLabel's convention. + */ +export function formatSpendResetLabel(periodEnd: string): string { + return dayjs.utc(periodEnd).format("MMM D, YYYY"); +} + /** * Classifies usage against a budget. Returns "warning" once usage reaches 85% * of the budget and "exceeded" once it meets or passes the budget. A budget of diff --git a/tailnet/coordinator.go b/tailnet/coordinator.go index c3df906b4d865..79db8ca0a22ba 100644 --- a/tailnet/coordinator.go +++ b/tailnet/coordinator.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/netip" + runtimedebug "runtime/debug" "sync" "time" @@ -27,6 +28,7 @@ const ( RequestBufferSize = 32 CloseErrOverwritten = "peer ID overwritten by new connection" CloseErrCoordinatorClose = "coordinator closed" + CloseErrInternal = "internal coordinator error" ReadyForHandshakeError = "ready for handshake error" ) @@ -176,6 +178,15 @@ func (c *coordinator) Coordinate( c.wg.Add(1) go func() { defer c.wg.Done() + // Peer cleanup can also panic, outside reqLoop's recovery boundary. + defer func() { + if recovered := recover(); recovered != nil { + logger.Error(ctx, "panic coordinating peer (recovered)", + slog.F("panic", recovered), + slog.F("stack", string(runtimedebug.Stack())), + ) + } + }() loopErr := p.reqLoop(ctx, logger, c.core.handleRequest) closeErrStr := "" if loopErr != nil { @@ -235,6 +246,9 @@ func (c *core) node(id uuid.UUID) *Node { } func (c *core) handleRequest(ctx context.Context, p *peer, req *proto.CoordinateRequest) error { + if err := ValidateCoordinateRequest(req); err != nil { + return err + } c.mutex.Lock() defer c.mutex.Unlock() if c.closed { @@ -290,6 +304,10 @@ func (c *core) handleRequest(ctx context.Context, p *peer, req *proto.Coordinate } if req.Disconnect != nil { c.removePeerLocked(p.id, proto.CoordinateResponse_PeerUpdate_DISCONNECTED, "graceful disconnect", "") + // The peer and its response channel are gone, so nothing else in this + // request can be delivered. Matches the enterprise coordinator, which + // stops processing a request once it sees Disconnect. + return nil } if rfhs := req.ReadyForHandshake; rfhs != nil { err := c.handleReadyForHandshakeLocked(pr, rfhs) @@ -349,6 +367,10 @@ func (c *core) nodeUpdateLocked(p *peer, node *proto.Node) (err error) { p.node = node c.updateTunnelPeersLocked(p.id, node, proto.CoordinateResponse_PeerUpdate_NODE, "node update") + // Fan-out can recursively remove this peer if its response buffer is full. + if c.peers[p.id] != p { + return ErrAlreadyRemoved + } return nil } @@ -480,6 +502,13 @@ func (c *core) removePeerLocked(id uuid.UUID, kind proto.CoordinateResponse_Peer return } c.updateTunnelPeersLocked(id, nil, kind, reason) + if _, ok := c.peers[id]; !ok { + // A tunnel peer with a full response buffer gets removed during the + // fan-out above, and removing it fans out to us in turn. When that + // nested call already closed and deleted this peer, p is stale and + // closing p.resps again would panic. Nothing is left to do. + return + } c.tunnels.removeAll(id) if closeErr != "" { select { diff --git a/tailnet/coordinator_internal_test.go b/tailnet/coordinator_internal_test.go index 9f5ac7c6a46eb..654bf28015e1f 100644 --- a/tailnet/coordinator_internal_test.go +++ b/tailnet/coordinator_internal_test.go @@ -2,6 +2,7 @@ package tailnet import ( "bytes" + "context" "flag" "os" "path/filepath" @@ -11,6 +12,11 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/testutil" ) // UpdateGoldenFiles indicates golden files should be updated. @@ -72,3 +78,209 @@ func TestDebugTemplate(t *testing.T) { goldenPath, ) } + +// TestCoreDisconnectIgnoresReadyForHandshake sends Disconnect and +// ReadyForHandshake in one request. Disconnect closes the peer's response +// channel, so the handler must stop there instead of trying to answer the +// handshake on a closed channel. Other peers still see DISCONNECTED. +func TestCoreDisconnectIgnoresReadyForHandshake(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + c := newCore(logger) + t.Cleanup(func() { require.NoError(t, c.close()) }) + + type testPeer struct { + *peer + resps chan *proto.CoordinateResponse + } + newPeer := func(name string, id uuid.UUID, auth CoordinateeAuth) testPeer { + resps := make(chan *proto.CoordinateResponse, ResponseBufferSize) + p := &peer{ + logger: logger.With(slog.F("peer_name", name)), + id: id, + name: name, + resps: resps, + reqs: make(chan *proto.CoordinateRequest, RequestBufferSize), + auth: auth, + sent: make(map[uuid.UUID]*proto.Node), + } + require.NoError(t, c.initPeer(p)) + return testPeer{peer: p, resps: resps} + } + observer := newPeer("observer", uuid.New(), SingleTailnetCoordinateeAuth{}) + leaverID := uuid.New() + leaver := newPeer("leaver", leaverID, AgentCoordinateeAuth{ID: leaverID}) + + require.NoError(t, c.handleRequest(ctx, observer.peer, &proto.CoordinateRequest{ + AddTunnel: &proto.CoordinateRequest_Tunnel{Id: leaver.id[:]}, + })) + require.NoError(t, c.handleRequest(ctx, leaver.peer, &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{PreferredDerp: 1}}, + })) + nodeUpdate := testutil.RequireReceive(ctx, t, observer.resps) + require.Len(t, nodeUpdate.PeerUpdates, 1) + require.Equal(t, proto.CoordinateResponse_PeerUpdate_NODE, nodeUpdate.PeerUpdates[0].Kind) + + stranger := uuid.New() + var err error + require.NotPanics(t, func() { + err = c.handleRequest(ctx, leaver.peer, &proto.CoordinateRequest{ + Disconnect: &proto.CoordinateRequest_Disconnect{}, + ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{{Id: stranger[:]}}, + }) + }) + require.NoError(t, err) + + // The leaver's channel closes without an error response. + for closed := false; !closed; { + select { + case resp, ok := <-leaver.resps: + if !ok { + closed = true + continue + } + require.Empty(t, resp.Error) + case <-ctx.Done(): + t.Fatal("leaver response channel was not closed") + } + } + c.mutex.RLock() + _, stillPresent := c.peers[leaver.id] + c.mutex.RUnlock() + require.False(t, stillPresent) + + disconnected := testutil.RequireReceive(ctx, t, observer.resps) + require.Len(t, disconnected.PeerUpdates, 1) + gotID, err := uuid.FromBytes(disconnected.PeerUpdates[0].Id) + require.NoError(t, err) + require.Equal(t, leaver.id, gotID) + require.Equal(t, proto.CoordinateResponse_PeerUpdate_DISCONNECTED, disconnected.PeerUpdates[0].Kind) +} + +// TestCoreRemovePeerNestedRemoval sets up two tunneled peers whose response +// buffers are both full, then removes one of them. Notifying the other peer +// fails with ErrWouldBlock, which removes that peer too, and notifying back +// removes the first peer inside the nested call. The outer removePeerLocked +// must notice its peer is already gone instead of closing the channel again. +// Both request handling and lostPeer cleanup must finish without relying on +// panic recovery. +func TestCoreRemovePeerNestedRemoval(t *testing.T) { + t.Parallel() + + type fullPeers struct { + core *core + a, b *peer + aResps chan *proto.CoordinateResponse + bResps chan *proto.CoordinateResponse + } + // setup returns a core with peers a and b sharing a tunnel, where each + // peer's single-slot response buffer already holds the other's node. + setup := func(t *testing.T) fullPeers { + // The full buffers make updateTunnelPeersLocked log "failed to update + // mapping" at Error, which is the expected trigger. Anything else at + // Error or Critical, such as "removed non-existent peer", still fails + // the test. + logger := slogtest.Make(t, &slogtest.Options{ + IgnoreErrorFn: func(ent slog.SinkEntry) bool { + return ent.Message == "failed to update mapping" + }, + }).Leveled(slog.LevelDebug) + c := newCore(logger) + t.Cleanup(func() { require.NoError(t, c.close()) }) + newPeer := func(name string) (*peer, chan *proto.CoordinateResponse) { + resps := make(chan *proto.CoordinateResponse, 1) + p := &peer{ + logger: logger.With(slog.F("peer_name", name)), + id: uuid.New(), + name: name, + resps: resps, + reqs: make(chan *proto.CoordinateRequest, RequestBufferSize), + auth: SingleTailnetCoordinateeAuth{}, + sent: make(map[uuid.UUID]*proto.Node), + } + require.NoError(t, c.initPeer(p)) + return p, resps + } + a, aResps := newPeer("a") + b, bResps := newPeer("b") + + c.mutex.Lock() + defer c.mutex.Unlock() + // No tunnel yet, so these updates are not sent anywhere. + require.NoError(t, c.nodeUpdateLocked(a, &proto.Node{PreferredDerp: 1})) + require.NoError(t, c.nodeUpdateLocked(b, &proto.Node{PreferredDerp: 2})) + // Adding the tunnel sends each node to the other peer, which fills + // both buffers. + require.NoError(t, c.addTunnelLocked(a, b.id)) + require.Len(t, aResps, 1) + require.Len(t, bResps, 1) + require.Contains(t, a.sent, b.id) + require.Contains(t, b.sent, a.id) + return fullPeers{core: c, a: a, b: b, aResps: aResps, bResps: bResps} + } + + requireClosed := func(ctx context.Context, t *testing.T, ch chan *proto.CoordinateResponse) { + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-ctx.Done(): + t.Fatal("response channel was not closed") + } + } + } + + requireBothRemoved := func(ctx context.Context, t *testing.T, fp fullPeers) { + fp.core.mutex.RLock() + _, aPresent := fp.core.peers[fp.a.id] + _, bPresent := fp.core.peers[fp.b.id] + aTunnels := fp.core.tunnels.findTunnelPeers(fp.a.id) + bTunnels := fp.core.tunnels.findTunnelPeers(fp.b.id) + fp.core.mutex.RUnlock() + require.False(t, aPresent) + require.False(t, bPresent) + require.Empty(t, aTunnels) + require.Empty(t, bTunnels) + requireClosed(ctx, t, fp.aResps) + requireClosed(ctx, t, fp.bResps) + } + + for _, name := range []string{"UpdateSelf", "UpdateSelfAddTunnel", "UpdateSelfReadyForHandshake"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + fp := setup(t) + req := &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{PreferredDerp: 3}}, + } + dstID := uuid.New() + switch name { + case "UpdateSelfAddTunnel": + req.AddTunnel = &proto.CoordinateRequest_Tunnel{Id: dstID[:]} + case "UpdateSelfReadyForHandshake": + req.ReadyForHandshake = []*proto.CoordinateRequest_ReadyForHandshake{{Id: dstID[:]}} + } + var err error + require.NotPanics(t, func() { + err = fp.core.handleRequest(ctx, fp.a, req) + }) + require.NoError(t, err) + requireBothRemoved(ctx, t, fp) + }) + } + + t.Run("LostPeer", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + fp := setup(t) + var err error + require.NotPanics(t, func() { + err = fp.core.lostPeer(fp.a, "") + }) + require.NoError(t, err) + requireBothRemoved(ctx, t, fp) + }) +} diff --git a/tailnet/peer.go b/tailnet/peer.go index 1954534a1d142..8631c24e62d58 100644 --- a/tailnet/peer.go +++ b/tailnet/peer.go @@ -2,6 +2,7 @@ package tailnet import ( "context" + "runtime/debug" "time" "github.com/google/uuid" @@ -121,7 +122,16 @@ func (p *peer) storeMappingLocked( }, nil } -func (p *peer) reqLoop(ctx context.Context, logger slog.Logger, handler func(context.Context, *peer, *proto.CoordinateRequest) error) error { +func (p *peer) reqLoop(ctx context.Context, logger slog.Logger, handler func(context.Context, *peer, *proto.CoordinateRequest) error) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + logger.Error(ctx, "peerReadLoop panic handling request (recovered)", + slog.F("panic", recovered), + slog.F("stack", string(debug.Stack())), + ) + err = xerrors.New(CloseErrInternal) + } + }() for { select { case <-ctx.Done(): diff --git a/tailnet/peer_internal_test.go b/tailnet/peer_internal_test.go new file mode 100644 index 0000000000000..1dd2083e82f0c --- /dev/null +++ b/tailnet/peer_internal_test.go @@ -0,0 +1,119 @@ +package tailnet + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/testutil" +) + +func TestPeerReqLoopPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + requests := make(chan *proto.CoordinateRequest, 1) + responses := make(chan *proto.CoordinateResponse, 1) + p := &peer{ + logger: logger, + id: uuid.New(), + resps: responses, + reqs: requests, + sent: make(map[uuid.UUID]*proto.Node), + } + core := newCore(logger) + require.NoError(t, core.initPeer(p)) + t.Cleanup(func() { require.NoError(t, core.close()) }) + + testutil.RequireSend(ctx, t, requests, &proto.CoordinateRequest{}) + err := p.reqLoop(ctx, logger, func(context.Context, *peer, *proto.CoordinateRequest) error { + panic("private handler panic") + }) + require.EqualError(t, err, CloseErrInternal) + require.NoError(t, core.lostPeer(p, err.Error())) + + response := testutil.RequireReceive(ctx, t, responses) + require.Equal(t, CloseErrInternal, response.Error) + require.NotContains(t, response.Error, "private handler panic") + _, ok := <-responses + require.False(t, ok) + core.mutex.RLock() + _, ok = core.peers[p.id] + core.mutex.RUnlock() + require.False(t, ok) +} + +func TestPeerCleanupPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + id := uuid.New() + sink := &peerCleanupPanicSink{ + FakeSink: testutil.NewFakeSink(t), + peerID: id, + } + logger := slog.Make(sink).Leveled(slog.LevelDebug) + c := NewCoordinator(logger).(*coordinator) + t.Cleanup(func() { require.NoError(t, c.Close()) }) + + requests, responses := c.Coordinate(ctx, id, t.Name(), SingleTailnetCoordinateeAuth{}) + close(requests) + done := make(chan struct{}) + go func() { + c.wg.Wait() + close(done) + }() + testutil.TryReceive(ctx, t, done) + + entries := sink.Entries(func(entry slog.SinkEntry) bool { + return entry.Level == slog.LevelError + }) + require.Len(t, entries, 1) + require.Equal(t, "panic coordinating peer (recovered)", entries[0].Message) + fields := make(map[string]any) + for _, field := range entries[0].Fields { + fields[field.Name] = field.Value + } + require.Equal(t, id, fields["peer_id"]) + require.Equal(t, "private cleanup panic", fields["panic"]) + require.Contains(t, fields["stack"], "(*core).lostPeer") + + // Recovery must leave the core unlocked and the coordinator usable. + healthyID := uuid.New() + healthyRequests, healthyResponses := c.Coordinate(ctx, healthyID, t.Name(), SingleTailnetCoordinateeAuth{}) + close(healthyRequests) + require.Nil(t, testutil.TryReceive(ctx, t, healthyResponses)) + + // Shutdown must still remove the peer whose cleanup was interrupted. + closed := make(chan error, 1) + go func() { closed <- c.Close() }() + require.NoError(t, testutil.RequireReceive(ctx, t, closed)) + response := testutil.RequireReceive(ctx, t, responses) + require.Equal(t, CloseErrCoordinatorClose, response.Error) + require.NotContains(t, response.Error, "private cleanup panic") + require.Nil(t, testutil.TryReceive(ctx, t, responses)) + c.core.mutex.RLock() + _, present := c.core.peers[id] + c.core.mutex.RUnlock() + require.False(t, present) +} + +type peerCleanupPanicSink struct { + *testutil.FakeSink + peerID uuid.UUID +} + +func (s *peerCleanupPanicSink) LogEntry(ctx context.Context, entry slog.SinkEntry) { + if entry.Message == "lostPeer" { + for _, field := range entry.Fields { + if field.Name == "peer_id" && field.Value == s.peerID { + panic("private cleanup panic") + } + } + } + s.FakeSink.LogEntry(ctx, entry) +} diff --git a/tailnet/requests.go b/tailnet/requests.go new file mode 100644 index 0000000000000..94cebe151efa6 --- /dev/null +++ b/tailnet/requests.go @@ -0,0 +1,31 @@ +package tailnet + +import ( + "slices" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/tailnet/proto" +) + +// ValidateCoordinateRequest checks the structural invariants required by the +// coordinator before authorization or state updates. +func ValidateCoordinateRequest(req *proto.CoordinateRequest) error { + if req == nil { + return xerrors.New("coordinate request is required") + } + if err := validateUpdateSelf(req); err != nil { + return err + } + if slices.Contains(req.ReadyForHandshake, nil) { + return xerrors.New("ready_for_handshake entries must not be nil") + } + return nil +} + +func validateUpdateSelf(req *proto.CoordinateRequest) error { + if upd := req.GetUpdateSelf(); upd != nil && upd.GetNode() == nil { + return xerrors.New("update_self node is required") + } + return nil +} diff --git a/tailnet/requests_test.go b/tailnet/requests_test.go new file mode 100644 index 0000000000000..8d50a5077195c --- /dev/null +++ b/tailnet/requests_test.go @@ -0,0 +1,100 @@ +package tailnet_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" + "github.com/coder/coder/v2/tailnet/test" + "github.com/coder/coder/v2/testutil" +) + +func TestCoordinateeAuthNilNode(t *testing.T) { + t.Parallel() + for name, auth := range map[string]tailnet.CoordinateeAuth{ + "Agent": tailnet.AgentCoordinateeAuth{ID: uuid.New()}, + "Client": tailnet.ClientCoordinateeAuth{AgentID: uuid.New()}, + "ClientUser": tailnet.ClientUserCoordinateeAuth{}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + require.NotPanics(t, func() { + require.EqualError(t, auth.Authorize(ctx, &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{}, + }), "update_self node is required") + }) + }) + } +} + +func TestCoordinator_InvalidRequests(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + coordinator := tailnet.NewCoordinator(slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})) + t.Cleanup(func() { require.NoError(t, coordinator.Close()) }) + test.InvalidCoordinateRequestTest(ctx, t, coordinator) +} + +func TestCoordinator_RequestPanic(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + coordinator := tailnet.NewCoordinator(slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})) + t.Cleanup(func() { require.NoError(t, coordinator.Close()) }) + test.CoordinateRequestPanicTest(ctx, t, coordinator) +} + +func TestValidateCoordinateRequest(t *testing.T) { + t.Parallel() + validRFH := &proto.CoordinateRequest_ReadyForHandshake{Id: tailnet.UUIDToByteSlice(uuid.New())} + for _, tc := range []struct { + name string + req *proto.CoordinateRequest + err string // empty means the request is accepted + }{ + {name: "NilRequest", err: "coordinate request is required"}, + {name: "EmptyRequest", req: &proto.CoordinateRequest{}}, + { + name: "NilNode", + req: &proto.CoordinateRequest{UpdateSelf: &proto.CoordinateRequest_UpdateSelf{}}, + err: "update_self node is required", + }, + { + name: "EmptyNode", + req: &proto.CoordinateRequest{UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{}}}, + }, + {name: "NilReadyForHandshake", req: &proto.CoordinateRequest{ReadyForHandshake: nil}}, + { + name: "EmptyReadyForHandshake", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{}}, + }, + { + name: "ValidReadyForHandshake", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{validRFH}}, + }, + { + name: "NilReadyForHandshakeEntry", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{nil}}, + err: "ready_for_handshake entries must not be nil", + }, + { + name: "NilReadyForHandshakeAfterValid", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{validRFH, nil}}, + err: "ready_for_handshake entries must not be nil", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := tailnet.ValidateCoordinateRequest(tc.req) + if tc.err == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, tc.err) + }) + } +} diff --git a/tailnet/service.go b/tailnet/service.go index 6ae02876a4dd3..9773530c76fcf 100644 --- a/tailnet/service.go +++ b/tailnet/service.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/hashicorp/yamux" "golang.org/x/xerrors" + "storj.io/drpc/drpcerr" "storj.io/drpc/drpcmux" "storj.io/drpc/drpcserver" "tailscale.com/tailcfg" @@ -92,7 +93,7 @@ func NewClientService(options ClientServiceOptions) ( if err != nil { return nil, xerrors.Errorf("register DRPC service: %w", err) } - server := drpcserver.NewWithOptions(mux, drpcserver.Options{ + server := drpcsdk.NewServer(options.Logger, mux, drpcserver.Options{ Manager: drpcsdk.DefaultDRPCOptions(nil), Log: func(err error) { if xerrors.Is(err, io.EOF) || @@ -185,6 +186,13 @@ func (s *DRPCService) StreamDERPMaps(_ *proto.StreamDERPMapsRequest, stream prot } func (s *DRPCService) RefreshResumeToken(ctx context.Context, _ *proto.RefreshResumeTokenRequest) (*proto.RefreshResumeTokenResponse, error) { + if s.ResumeTokenProvider == nil { + return nil, drpcerr.WithCode( + xerrors.New("resume tokens not supported on this connection"), + drpcerr.Unimplemented, + ) + } + streamID, ok := ctx.Value(streamIDContextKey{}).(StreamID) if !ok { return nil, xerrors.New("no Stream ID") @@ -219,6 +227,13 @@ func (s *DRPCService) Coordinate(stream proto.DRPCTailnet_CoordinateStream) erro } func (s *DRPCService) WorkspaceUpdates(req *proto.WorkspaceUpdatesRequest, stream proto.DRPCTailnet_WorkspaceUpdatesStream) error { + if s.WorkspaceUpdatesProvider == nil { + return drpcerr.WithCode( + xerrors.New("workspace updates not supported on this connection"), + drpcerr.Unimplemented, + ) + } + defer stream.Close() ctx := stream.Context() diff --git a/tailnet/service_test.go b/tailnet/service_test.go index 0c268b05edb50..21a290ef35803 100644 --- a/tailnet/service_test.go +++ b/tailnet/service_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" + "storj.io/drpc/drpcerr" "tailscale.com/tailcfg" "github.com/coder/coder/v2/tailnet" @@ -178,6 +179,27 @@ func TestClientService_ServeClient_V1(t *testing.T) { require.ErrorIs(t, err, tailnet.ErrUnsupportedVersion) } +func TestClientService_UnsupportedProviders(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + clientID := uuid.New() + _, client := createUpdateService(t, ctx, clientID, nil) + + _, err := client.RefreshResumeToken(ctx, &proto.RefreshResumeTokenRequest{}) + require.ErrorContains(t, err, "resume tokens not supported on this connection") + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) + + updates, err := client.WorkspaceUpdates(ctx, &proto.WorkspaceUpdatesRequest{ + WorkspaceOwnerId: tailnet.UUIDToByteSlice(clientID), + }) + if err == nil { + _, err = updates.Recv() + } + require.ErrorContains(t, err, "workspace updates not supported on this connection") + require.EqualValues(t, drpcerr.Unimplemented, drpcerr.Code(err)) +} + func TestNetworkTelemetryBatcher(t *testing.T) { t.Parallel() diff --git a/tailnet/test/peer.go b/tailnet/test/peer.go index 601dc748d42d7..9dc44f431ed45 100644 --- a/tailnet/test/peer.go +++ b/tailnet/test/peer.go @@ -407,3 +407,12 @@ func (f FakeCoordinateeAuth) Authorize(ctx context.Context, _ *proto.CoordinateR } var _ tailnet.CoordinateeAuth = (*FakeCoordinateeAuth)(nil) + +// CoordinateeAuthFunc adapts a plain function to tailnet.CoordinateeAuth so a +// test can return a specific error or panic on a chosen request without +// declaring a new type. +type CoordinateeAuthFunc func(context.Context, *proto.CoordinateRequest) error + +func (f CoordinateeAuthFunc) Authorize(ctx context.Context, req *proto.CoordinateRequest) error { + return f(ctx, req) +} diff --git a/tailnet/test/requests.go b/tailnet/test/requests.go new file mode 100644 index 0000000000000..da84432604efe --- /dev/null +++ b/tailnet/test/requests.go @@ -0,0 +1,116 @@ +package test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/tailnet/proto" +) + +// InvalidCoordinateRequestTest checks that malformed requests are rejected +// before authorization and that a valid node bundled with a malformed entry +// is not published to tunnel peers. +func InvalidCoordinateRequestTest(ctx context.Context, t *testing.T, coordinator tailnet.CoordinatorV2) { + const ( + baselineDERP = 31001 + malformedDERP = 31002 + ) + for _, tc := range []struct { + name string + req *proto.CoordinateRequest + err string + verifyNoMutation bool + }{ + {name: "NilRequest", err: "coordinate request is required"}, + { + name: "NilNode", + req: &proto.CoordinateRequest{UpdateSelf: &proto.CoordinateRequest_UpdateSelf{}}, + err: "update_self node is required", + }, + { + name: "NilReadyForHandshake", + req: &proto.CoordinateRequest{ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{nil}}, + err: "ready_for_handshake entries must not be nil", + }, + { + name: "NodeAndNilReadyForHandshake", + req: &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{ + PreferredDerp: malformedDERP, + }}, + ReadyForHandshake: []*proto.CoordinateRequest_ReadyForHandshake{nil}, + }, + err: "ready_for_handshake entries must not be nil", + verifyNoMutation: true, + }, + } { + // The cases share the caller's ctx and coordinator, so they run + // sequentially. Parallel subtests would pause until the caller + // returns and, under -parallel=1, could resume after that ctx has + // already expired. + t.Run(tc.name, func(t *testing.T) { + var auth tailnet.CoordinateeAuth = CoordinateeAuthFunc(func(context.Context, *proto.CoordinateRequest) error { + panic("authorization must not see malformed requests") + }) + if tc.verifyNoMutation { + auth = tailnet.SingleTailnetCoordinateeAuth{} + } + peer := NewPeer(ctx, t, coordinator, tc.name, WithAuth(auth)) + defer peer.Close(ctx) + var observer *Peer + if tc.verifyNoMutation { + observer = NewPeer(ctx, t, coordinator, tc.name+"Observer") + defer observer.Close(ctx) + observer.AddTunnel(peer.ID) + peer.UpdateDERP(baselineDERP) + observer.AssertEventuallyHasDERP(peer.ID, baselineDERP) + } + require.NoError(t, tailnet.SendCtx(ctx, peer.reqs, tc.req)) + peer.AssertEventuallyResponsesClosed(tc.err) + if tc.verifyNoMutation { + observer.AssertEventuallyLost(peer.ID) + for _, update := range observer.peerUpdates[peer.ID] { + require.NotEqual(t, int32(malformedDERP), update.GetNode().GetPreferredDerp()) + } + } + }) + } +} + +// CoordinateRequestPanicTest checks that a failed connection is withdrawn while +// other connections continue exchanging updates. +func CoordinateRequestPanicTest(ctx context.Context, t *testing.T, coordinator tailnet.CoordinatorV2) { + broken := NewPeer(ctx, t, coordinator, "broken", WithAuth(CoordinateeAuthFunc(func(_ context.Context, req *proto.CoordinateRequest) error { + if req.Disconnect != nil { + panic("private panic detail") + } + return nil + }))) + defer broken.Close(ctx) + healthy := NewPeer(ctx, t, coordinator, "healthy") + defer healthy.Close(ctx) + other := NewPeer(ctx, t, coordinator, "other") + defer other.Close(ctx) + healthy.AddTunnel(broken.ID) + healthy.AddTunnel(other.ID) + broken.UpdateDERP(1) + healthy.UpdateDERP(2) + other.UpdateDERP(3) + healthy.AssertEventuallyHasDERP(broken.ID, 1) + healthy.AssertEventuallyHasDERP(other.ID, 3) + + broken.Disconnect() + broken.AssertEventuallyResponsesClosed(tailnet.CloseErrInternal) + healthy.AssertEventuallyLost(broken.ID) + + // Empty messages are valid no-ops, and an empty Node is still a Node. + require.NoError(t, tailnet.SendCtx(ctx, other.reqs, &proto.CoordinateRequest{})) + require.NoError(t, tailnet.SendCtx(ctx, other.reqs, &proto.CoordinateRequest{ + UpdateSelf: &proto.CoordinateRequest_UpdateSelf{Node: &proto.Node{}}, + })) + other.UpdateDERP(4) + healthy.AssertEventuallyHasDERP(other.ID, 4) +} diff --git a/tailnet/tunnel.go b/tailnet/tunnel.go index c575d0b986eec..86b4a846afa4d 100644 --- a/tailnet/tunnel.go +++ b/tailnet/tunnel.go @@ -66,6 +66,9 @@ type AgentCoordinateeAuth struct { } func (a AgentCoordinateeAuth) Authorize(_ context.Context, req *proto.CoordinateRequest) error { + if err := validateUpdateSelf(req); err != nil { + return err + } if tun := req.GetAddTunnel(); tun != nil { return xerrors.New("agents cannot open tunnels") } @@ -130,6 +133,9 @@ func (a ClientUserCoordinateeAuth) Authorize(ctx context.Context, req *proto.Coo // handleClientNodeRequests validates GetUpdateSelf requests and declines ReadyForHandshake requests func handleClientNodeRequests(req *proto.CoordinateRequest) error { + if err := validateUpdateSelf(req); err != nil { + return err + } if upd := req.GetUpdateSelf(); upd != nil { for _, addrStr := range upd.Node.Addresses { pre, err := netip.ParsePrefix(addrStr)