From 1bda5f7dcfa62369f1fb5481af65fa8fb1df7e5f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:42:45 +0200 Subject: [PATCH 01/69] feat(site/src/pages/AISettingsPage/SpendPage): announce cost controls move to AI Governance (#27543) (#27571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27543 Original PR: #27543 — feat(site/src/pages/AISettingsPage/SpendPage): announce cost controls move to AI Governance Merge commit: e83f018f5f56582650e0586de40509d5a84dd6ec Requested by: @ibetitsmike Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> --- .../SpendPage/SpendPageView.stories.tsx | 11 +++++++++++ .../AISettingsPage/SpendPage/SpendPageView.tsx | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx index 47dd6ffa611..64d6b102d64 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx @@ -300,6 +300,17 @@ export const SpendWithLimitsAndUsers: Story = { const canvas = within(canvasElement); await canvas.findByText("Spend limits and usage"); + await expect( + canvas.getByText( + /Cost controls features will move to AI Governance in v2\.37\./, + ), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: /Read more here/ }), + ).toHaveAttribute( + "href", + expect.stringContaining("/ai-coder/ai-gateway/cost-controls"), + ); await expect( canvas.getByRole("switch", { name: "Spend limit" }), ).toBeInTheDocument(); diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx index eb044738786..2514f8c13a4 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx @@ -1,9 +1,11 @@ import type { FC } from "react"; import { getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; +import { Alert, AlertDescription } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker"; +import { Link } from "#/components/Link/Link"; import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer"; import { SettingsHeader, @@ -22,6 +24,7 @@ import { useTemporarySavedState, } from "#/components/TemporarySavedState/TemporarySavedState"; import { dollarsToMicros, microsToDollars } from "#/utils/currency"; +import { docs } from "#/utils/docs"; import { DefaultLimitController, type DefaultLimitFormValues, @@ -232,6 +235,19 @@ export const SpendPageView: FC = ({ > {(userCtrl) => (
+ + + Cost controls features will move to AI Governance in v2.37.{" "} + + Read more here + + + + Spend limits and usage From 9da0f01fe6bc6aaa94f593b30296231ba0510fef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:50:42 +0200 Subject: [PATCH 02/69] feat: add group AI spend endpoint (#27568) (#27572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27568 Original PR: #27568 — feat: add group AI spend endpoint Merge commit: ed37483ff7ee7986a85e0f0c0414155c31af57a2 Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- coderd/apidoc/docs.go | 63 ++++++++ coderd/apidoc/swagger.json | 59 +++++++ codersdk/aibridge.go | 26 +++ docs/reference/api/enterprise.md | 43 +++++ docs/reference/api/schemas.md | 22 +++ enterprise/coderd/aibridge.go | 52 ++++++ enterprise/coderd/aibridge_test.go | 243 +++++++++++++++++++++++++++++ enterprise/coderd/coderd.go | 9 ++ site/src/api/typesGenerated.ts | 9 ++ 9 files changed, 526 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 13eea683db2..6f9843821fe 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": [ @@ -20642,6 +20678,33 @@ const docTemplate = `{ } } }, + "codersdk.GroupAISpend": { + "type": "object", + "properties": { + "current_spend_micros": { + "description": "CurrentSpendMicros is the group's spend over the current budget\nperiod.", + "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 limit. Null when\nthe group has no configured budget.", + "type": "integer" + } + } + }, "codersdk.GroupMemberAISpend": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a50b9dabee9..59bfe973780 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"], @@ -18790,6 +18822,33 @@ } } }, + "codersdk.GroupAISpend": { + "type": "object", + "properties": { + "current_spend_micros": { + "description": "CurrentSpendMicros is the group's spend over the current budget\nperiod.", + "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 limit. Null when\nthe group has no configured budget.", + "type": "integer" + } + } + }, "codersdk.GroupMemberAISpend": { "type": "object", "properties": { diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index ff3fcff369b..3215bed8b3c 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -92,6 +92,13 @@ type OrganizationGroupAISpend struct { 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 { @@ -577,6 +584,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/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index cff040db9af..88026e8599f 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1035,6 +1035,49 @@ 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 +} +``` + +### 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 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 582c2051ee6..80023c6ccc6 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7750,6 +7750,28 @@ 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 +} +``` + +### 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 limit. Null when the group has no configured budget. | + ## codersdk.GroupMemberAISpend ```json diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 5b6f256e68a..be338982c0f 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -1232,6 +1232,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 38e467f0c60..7410f9279c1 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -4570,6 +4570,249 @@ 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) + dv.Experiments = []string{string(codersdk.ExperimentAIGatewayCostControl)} + 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("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-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-cost-control") + }) + + 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()) + }) + + tests := []struct { + name string + setBudget bool + spendLimit int64 + spent int64 + wantSpendLimit *int64 + wantCurrentSpend int64 + }{ + { + name: "NoBudgetNoSpend", + }, + { + name: "ZeroLimitBudget", + setBudget: true, + spendLimit: 0, + wantSpendLimit: 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: "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: "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, + }, + } + + 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.wantCurrentSpend, got.CurrentSpendMicros) + }) + } +} + +func TestGroupAISpendRoleAccess(t *testing.T) { + t.Parallel() + + 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{ + 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() diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 0a6118214da..d65dd1f6950 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -633,6 +633,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { ) 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( + // 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.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)) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4567c34e078..543a5d9dd86 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5212,6 +5212,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 { /** From 1bf446c0efc8ee94d89bc1a9bccd1d611b596f7a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:37:53 +0200 Subject: [PATCH 03/69] fix!: deprecate `login_type=none`, convert existing users to password login (#26851) (#27578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. > ⚠️ Renumbered the migration from `000554` to `000555`: `release/2.36` already has `000554_aibridge_token_usage_spend_export_index`, so the cherry-picked `000554_legacy_none_login_to_password` collided. Moved it to the next free slot (`000555`) and updated `migrate_test.go` accordingly. Cherry-pick of https://github.com/coder/coder/pull/26851 Original PR: #26851 - fix!: deprecate `login_type=none`, convert existing users to password login Merge commit: 0e104f38e04aac62e7644253fc7037059c61ea42 Requested by: @jakehwll --------- Co-authored-by: Jake Howell Co-authored-by: Sushant P Co-authored-by: Jake Howell --- cli/usercreate.go | 15 ++-- cli/usercreate_test.go | 15 ++++ ...555_legacy_none_login_to_password.down.sql | 2 + ...00555_legacy_none_login_to_password.up.sql | 9 ++ coderd/database/migrations/migrate_test.go | 83 +++++++++++++++++++ coderd/userauth_test.go | 16 ++-- coderd/users.go | 7 ++ coderd/users_test.go | 67 +++++++++++++-- 8 files changed, 198 insertions(+), 16 deletions(-) create mode 100644 coderd/database/migrations/000555_legacy_none_login_to_password.down.sql create mode 100644 coderd/database/migrations/000555_legacy_none_login_to_password.up.sql diff --git a/cli/usercreate.go b/cli/usercreate.go index 1a904582593..dbbe92be664 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 7453d371238..5fc9c86d81f 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/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 00000000000..b6ae9ef7962 --- /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 00000000000..82c13214e71 --- /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/migrate_test.go b/coderd/database/migrations/migrate_test.go index 11897bec83c..14119ed81e3 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() diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 463ce836512..709b6d3764b 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 77c6954c6cc..a9151bd6c60 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 bf35cf1d7f4..1b3916fe9b7 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) { From c22ba3ece2b02b1b7a9c985f0eb7fcd4274817c9 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 28 Jul 2026 18:01:28 +0100 Subject: [PATCH 04/69] fix: remove chatd usage limit enforcement (#27535) (#27599) This PR surgically removes enforcement of Agents spend limits: - Adjusts the relevant function that checks usage to always return nil - Deletes tests that expect a usage limit error. (cherry picked from commit 75fd7bc09ae71d7b66ba916dedfe170602c32aba) --- coderd/exp_chats_test.go | 172 ------------- coderd/x/chatd/chatd.go | 25 +- coderd/x/chatd/chatd_internal_test.go | 2 - coderd/x/chatd/chatd_test.go | 242 ------------------ .../x/chatd/title_override_internal_test.go | 1 - 5 files changed, 3 insertions(+), 439 deletions(-) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index d4762af6c37..f37f082cca0 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/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 59072a6e0a2..5daff17e443 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 15bb8df0f4e..0ed9206878a 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 7accb04b464..baf97534623 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/title_override_internal_test.go b/coderd/x/chatd/title_override_internal_test.go index 9ed08a47e56..204639bc2a0 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, From f188881ec992fbb4468e62fefff5ad2b7f2d5316 Mon Sep 17 00:00:00 2001 From: Zach <3724288+zedkipp@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:17:57 -0600 Subject: [PATCH 05/69] feat: add enable/disable support for user secrets (#27537) (#27594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of #27537 to `release/2.36`. Original PR: #27537 — feat: add enable/disable support for user secrets Merge commit: `85984ff142cc937b9b15e9f314cc3385334d1b23` Users can now disable a secret to stop it from being injected into workspaces without deleting it, and re-enable it later. Disabled secrets stay visible and editable everywhere they already appear. An enabled secret must have at least one injection target; a secret with no target can be stored only while disabled. Existing target-less secrets are migrated to disabled to preserve current behavior. Support spans the REST API, SDK, CLI, dashboard, and audit log.
Backport details - Cherry-picked the squash-merge commit `85984ff142` with `git cherry-pick -x`. Applied with no conflicts (only automatic content merges in `apidoc/docs.go`, `swagger.json`, `schemas.md`, `typesGenerated.ts`). - `release/2.36` tops out at migration `000555_legacy_none_login_to_password`, so `000556_user_secrets_enabled` is already the correct next number. No renumbering required. - `make gen` produced no drift. - `TestMigration000556UserSecretsEnabled` passes against Postgres on this branch. - Note: #27537 was squash-merged (single-parent commit), so the label-driven backport workflow's `cherry-pick -m1` path does not apply here; this backport was prepared manually.
_This backport was prepared by Coder Agents on behalf of @zedkipp._ Co-authored-by: Marcin Tojek --- cli/secret.go | 108 +++++++- cli/secret_test.go | 154 ++++++++++- cli/testdata/coder_secret_--help.golden | 10 +- .../coder_secret_create_--help.golden | 5 + .../coder_secret_disable_--help.golden | 9 + .../coder_secret_enable_--help.golden | 9 + cli/testdata/coder_secret_list_--help.golden | 2 +- .../coder_secret_update_--help.golden | 11 +- coderd/agentapi/manifest.go | 9 +- coderd/agentapi/manifest_test.go | 15 +- coderd/apidoc/docs.go | 10 + coderd/apidoc/swagger.json | 10 + coderd/database/check_constraint.go | 1 + coderd/database/db2sdk/db2sdk.go | 2 + coderd/database/dbgen/dbgen.go | 1 + coderd/database/dump.sql | 4 +- .../000556_user_secrets_enabled.down.sql | 2 + .../000556_user_secrets_enabled.up.sql | 30 +++ coderd/database/migrations/migrate_test.go | 97 +++++++ coderd/database/models.go | 1 + coderd/database/querier_test.go | 83 +++++- coderd/database/queries.sql.go | 37 ++- coderd/database/queries/user_secrets.sql | 9 +- coderd/telemetry/telemetry_test.go | 12 +- coderd/usersecrets.go | 85 +++++- coderd/usersecrets_audit_test.go | 35 ++- coderd/usersecrets_test.go | 247 +++++++++++++++--- coderd/usersecretsimport_test.go | 10 +- codersdk/usersecrets.go | 22 +- codersdk/usersecretsimport.go | 8 + codersdk/usersecretsimport_test.go | 8 +- codersdk/usersecretvalidation.go | 18 ++ codersdk/usersecretvalidation_test.go | 14 +- docs/admin/security/audit-logs.md | 2 +- docs/admin/security/secrets.md | 3 +- docs/reference/api/schemas.md | 50 ++-- docs/reference/api/secrets.md | 49 ++-- docs/reference/cli/secret.md | 14 +- docs/reference/cli/secret_create.md | 9 + docs/reference/cli/secret_disable.md | 10 + docs/reference/cli/secret_enable.md | 10 + docs/reference/cli/secret_list.md | 8 +- docs/reference/cli/secret_update.md | 10 +- docs/user-guides/user-secrets.md | 82 ++++-- enterprise/audit/table.go | 1 + enterprise/coderd/usersecrets_audit_test.go | 1 + enterprise/dbcrypt/cliutil.go | 4 + site/src/api/api.test.ts | 1 + site/src/api/typesGenerated.ts | 30 ++- .../SecretsPage/SecretsPage.tsx | 20 ++ .../SecretsPage/SecretsPageView.stories.tsx | 71 +++++ .../SecretsPage/SecretsPageView.tsx | 6 + .../SecretsPage/SecretsTable.tsx | 107 +++++++- .../SecretsPage/secretForm.test.ts | 2 + site/src/testHelpers/entities.ts | 8 + site/src/testHelpers/handlers.ts | 1 + 56 files changed, 1391 insertions(+), 186 deletions(-) create mode 100644 cli/testdata/coder_secret_disable_--help.golden create mode 100644 cli/testdata/coder_secret_enable_--help.golden create mode 100644 coderd/database/migrations/000556_user_secrets_enabled.down.sql create mode 100644 coderd/database/migrations/000556_user_secrets_enabled.up.sql create mode 100644 docs/reference/cli/secret_disable.md create mode 100644 docs/reference/cli/secret_enable.md diff --git a/cli/secret.go b/cli/secret.go index 2fb6d75c4fc..630a1720b55 100644 --- a/cli/secret.go +++ b/cli/secret.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "io" + "strconv" "strings" "time" @@ -10,6 +11,7 @@ import ( "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" @@ -48,6 +50,8 @@ func (r *RootCmd) secrets() *serpent.Command { Children: []*serpent.Command{ r.secretCreate(), r.secretUpdate(), + r.secretEnable(), + r.secretDisable(), r.secretList(), r.secretDelete(), }, @@ -62,6 +66,7 @@ func (r *RootCmd) secretCreate() *serpent.Command { description string env string file string + enabled bool ) cmd := &serpent.Command{ @@ -96,6 +101,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 +126,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 +156,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 +194,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 +224,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 { @@ -295,6 +322,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 +334,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_test.go b/cli/secret_test.go index be3d993db5f..455405a4924 100644 --- a/cli/secret_test.go +++ b/cli/secret_test.go @@ -195,8 +195,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 +225,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 +237,6 @@ func TestSecretUpdate(t *testing.T) { "--value", "rotated-secret", "--description", "", "--env", "", - "--file", "", ) output := clitest.Capture(inv) clitest.SetupConfig(t, client, root) @@ -246,7 +250,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 +295,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 +325,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 +350,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 +548,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 +584,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 +634,86 @@ func TestSecretDelete(t *testing.T) { require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) } + +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 45447c96e39..5088a3caf4a 100644 --- a/cli/testdata/coder_secret_--help.golden +++ b/cli/testdata/coder_secret_--help.golden @@ -30,10 +30,12 @@ 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 + 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 0a5d53d1198..8b829bade8d 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 00000000000..5eeb305db64 --- /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 00000000000..ea5896449c2 --- /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_list_--help.golden b/cli/testdata/coder_secret_list_--help.golden index 803968373cf..3d2fd83dc5b 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 6864ca22daa..12407a74afd 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/coderd/agentapi/manifest.go b/coderd/agentapi/manifest.go index fd8e6f7739c..7af2e6fd85d 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 4c5890052b0..fecfc1cf5be 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/apidoc/docs.go b/coderd/apidoc/docs.go index 6f9843821fe..945f529539f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19272,6 +19272,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "env_name": { "type": "string" }, @@ -26047,6 +26050,9 @@ const docTemplate = `{ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "env_name": { "type": "string" }, @@ -26655,6 +26661,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 59bfe973780..ba174267697 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17439,6 +17439,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "env_name": { "type": "string" }, @@ -23941,6 +23944,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "env_name": { "type": "string" }, @@ -24522,6 +24528,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/database/check_constraint.go b/coderd/database/check_constraint.go index 224928f2c11..dbf0debfcab 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 d0f94b71d1b..c6e49c4dd93 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -2098,6 +2098,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 +2113,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/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 9cdad7e8e82..98c8718697e 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/dump.sql b/coderd/database/dump.sql index 5127d0159d0..d99062c3671 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/000556_user_secrets_enabled.down.sql b/coderd/database/migrations/000556_user_secrets_enabled.down.sql new file mode 100644 index 00000000000..468b6b3bd26 --- /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 00000000000..20a11905f73 --- /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 14119ed81e3..fdabc3a7df1 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2251,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 08b71a244ef..74e982f5fc4 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_test.go b/coderd/database/querier_test.go index 8a21c366a93..5cc5c9bd1a7 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -8483,7 +8483,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 +8493,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 +8509,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) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1428f399832..ee88e6cdba8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -29754,7 +29754,8 @@ INSERT INTO user_secrets ( value, value_key_id, env_name, - file_path + file_path, + enabled ) VALUES ( $1, $2, @@ -29763,8 +29764,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 +29778,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 +29791,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 +29805,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP &i.CreatedAt, &i.UpdatedAt, &i.ValueKeyID, + &i.Enabled, ) return i, err } @@ -29808,7 +29813,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 +29835,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 +29860,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 +29890,7 @@ func (q *sqlQuerier) GetUserSecretByUserIDAndName(ctx context.Context, arg GetUs &i.CreatedAt, &i.UpdatedAt, &i.ValueKeyID, + &i.Enabled, ) return i, err } @@ -29983,7 +29991,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 +30005,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 +30028,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 +30046,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 +30075,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 +30098,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 +30114,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 +30131,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 +30148,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/user_secrets.sql b/coderd/database/queries/user_secrets.sql index 2bca3a0ca4b..56e07b57ad3 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/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 305c149a6b8..a626c9faa1d 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/usersecrets.go b/coderd/usersecrets.go index 633983cb612..fe5e41b50b4 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 ba1fdd96f3b..9cf38dcc2ff 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 4fedcb2dec3..1b4a6c97768 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 0cddeba7a16..e14846eeda3 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/codersdk/usersecrets.go b/codersdk/usersecrets.go index 7d59d9b6d86..1f36100c4d7 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 b7c020ad463..f26a725e25f 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 7faa8e0606b..86e616d4f44 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 5892d1c35c7..6fdc73058bb 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 55d7d001b7f..28584ac1993 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/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index b83bcdc42ae..127eefd19a8 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
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| | TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| | User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| +| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
enabledtrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| | UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| | WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| diff --git a/docs/admin/security/secrets.md b/docs/admin/security/secrets.md index 91cfdc5da47..f8276a3ad46 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/reference/api/schemas.md b/docs/reference/api/schemas.md index 80023c6ccc6..86c071c4380 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5129,6 +5129,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 +5139,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 @@ -13846,6 +13848,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" @@ -13854,12 +13857,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 @@ -14545,6 +14549,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", @@ -14555,15 +14560,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 74b44c0ec23..d1eee9875d2 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 7d022f3dfd6..e9460e38a75 100644 --- a/docs/reference/cli/secret.md +++ b/docs/reference/cli/secret.md @@ -39,9 +39,11 @@ coder secret ## Subcommands -| Name | Purpose | -|-------------------------------------------|-----------------------------------| -| [create](./secret_create.md) | Create a secret | -| [update](./secret_update.md) | Update a secret | -| [list](./secret_list.md) | List secrets, or show one by name | -| [delete](./secret_delete.md) | Delete a secret | +| Name | Purpose | +|---------------------------------------------|---------------------------------------------------| +| [create](./secret_create.md) | Create a secret | +| [update](./secret_update.md) | Update a secret | +| [enable](./secret_enable.md) | Enable a secret so it is injected into workspaces | +| [disable](./secret_disable.md) | Disable a secret without removing it | +| [list](./secret_list.md) | List secrets, or show one by name | +| [delete](./secret_delete.md) | Delete a secret | diff --git a/docs/reference/cli/secret_create.md b/docs/reference/cli/secret_create.md index df9086f6930..84e09ec2940 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 | string | Workspace file path where this secret will be written. Must start with ~/ or /. + +### --enabled + +| | | +|---------|-------------------| +| Type | 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. diff --git a/docs/reference/cli/secret_disable.md b/docs/reference/cli/secret_disable.md new file mode 100644 index 00000000000..1ee4c3de822 --- /dev/null +++ b/docs/reference/cli/secret_disable.md @@ -0,0 +1,10 @@ + +# secret disable + +Disable a secret without removing it + +## Usage + +```console +coder secret disable +``` diff --git a/docs/reference/cli/secret_enable.md b/docs/reference/cli/secret_enable.md new file mode 100644 index 00000000000..56d83a38673 --- /dev/null +++ b/docs/reference/cli/secret_enable.md @@ -0,0 +1,10 @@ + +# secret enable + +Enable a secret so it is injected into workspaces + +## Usage + +```console +coder secret enable +``` diff --git a/docs/reference/cli/secret_list.md b/docs/reference/cli/secret_list.md index 9bffcd6a495..2a93cf659b6 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 | [created\|name\|updated\|env\|file\|description] | -| Default | name,created,updated,env,file,description | +| | | +|---------|------------------------------------------------------------------------| +| Type | [created\|name\|updated\|env\|file\|enabled\|description] | +| Default | name,created,updated,env,file,enabled,description | Columns to display in table output. diff --git a/docs/reference/cli/secret_update.md b/docs/reference/cli/secret_update.md index 83b03b2b3a5..fd628f03ebe 100644 --- a/docs/reference/cli/secret_update.md +++ b/docs/reference/cli/secret_update.md @@ -12,7 +12,7 @@ coder secret update [flags] ## 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 | string | Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it. + +### --enabled + +| | | +|------|-------------------| +| Type | 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. diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index 7dd3d8f4d0a..f3be7306758 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,12 @@ 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. + ### Environment variable secrets Coder injects environment variable secrets into every new shell, terminal, @@ -46,11 +62,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 +80,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 @@ -120,7 +140,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 +217,21 @@ want to store a trailing newline: echo -n "$API_KEY" | coder secret create api-key --env API_KEY ``` +### 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 +240,26 @@ 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 "" ``` +### 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,7 +276,8 @@ 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. diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 540f4cce34d..ea66b579332 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/coderd/usersecrets_audit_test.go b/enterprise/coderd/usersecrets_audit_test.go index 46deac17f76..f95023be70f 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 b298828055d..086c9ba6b4f 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/site/src/api/api.test.ts b/site/src/api/api.test.ts index 98a9cb4eff3..446467c52bb 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/typesGenerated.ts b/site/src/api/typesGenerated.ts index 543a5d9dd86..7f209f21213 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4106,8 +4106,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 +4118,7 @@ export interface CreateUserSecretRequest { readonly description?: string; readonly env_name?: string; readonly file_path?: string; + readonly enabled?: boolean; } // From codersdk/userskills.go @@ -9754,13 +9758,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 @@ -10264,6 +10271,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; } @@ -10284,6 +10298,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/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx index fa74cfd4a5d..43b74638842 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx @@ -65,6 +65,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 bac0308dfad..0988c7172ba 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -29,6 +29,7 @@ const meta: Meta = { onCreateSecret: fn(), onUpdateSecret: fn(), onDeleteSecret: fn(), + onToggleSecretEnabled: fn(), }, }; @@ -45,6 +46,9 @@ type UpdateSecretMock = ReturnType< type DeleteSecretMock = ReturnType< typeof fn<(secret: UserSecret) => Promise | void> >; +type ToggleSecretEnabledMock = ReturnType< + typeof fn<(secret: UserSecret, enabled: boolean) => Promise | void> +>; const waitForDialogToClose = async (body: ReturnType) => { await waitFor(() => { @@ -64,6 +68,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", }); @@ -597,3 +602,69 @@ export const CreateMutationErrorDisplay: Story = { expectNoValueField(body); }, }; + +export const ToggleEnabledSubmit: Story = { + args: { + onToggleSecretEnabled: fn< + (secret: UserSecret, enabled: boolean) => Promise + >(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 + >(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 8306ea2d203..26543a988fe 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx @@ -36,6 +36,10 @@ type SecretsPageViewProps = { request: UpdateUserSecretRequest, ) => Promise | UserSecret; onDeleteSecret: (secret: UserSecret) => Promise | void; + onToggleSecretEnabled: ( + secret: UserSecret, + enabled: boolean, + ) => Promise | void; }; type SecretDialogState = @@ -55,6 +59,7 @@ export const SecretsPageView: FC = ({ onCreateSecret, onUpdateSecret, onDeleteSecret, + onToggleSecretEnabled, }) => { const [dialogState, setDialogState] = useState({ mode: "add", @@ -143,6 +148,7 @@ export const SecretsPageView: FC = ({ onAddSecret={openAddSecret} onEditSecret={openEditSecret} onDeleteSecret={onDeleteSecret} + onToggleEnabled={onToggleSecretEnabled} />
diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx index a74dc30c481..ac1966f9b1d 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; + onToggleEnabled: ( + secret: UserSecret, + enabled: boolean, + ) => Promise | void; }; export const SecretsTable: FC = ({ @@ -44,8 +54,25 @@ export const SecretsTable: FC = ({ onAddSecret, onEditSecret, onDeleteSecret, + onToggleEnabled, }) => { const [secretToDelete, setSecretToDelete] = useState(); + const [togglingSecretId, setTogglingSecretId] = useState(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 = ({ - Name - Environment variable - File path - Type - Description - Updated + Name + Environment variable + File path + Type + Description + Updated + Enabled @@ -115,6 +143,13 @@ export const SecretsTable: FC = ({ {relativeTime(secret.updated_at)} + + + = ({ secret }) => { return not injected; }; +type EnabledToggleProps = { + secret: UserSecret; + isPending: boolean; + onToggle: (secret: UserSecret, enabled: boolean) => void; +}; + +const EnabledToggle: FC = ({ + 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 = ( + onToggle(secret, checked)} + /> + ); + + if (cannotEnable) { + return ( +
+ + + {/* + * 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. + */} + + {control} + + + + Add an environment variable or file path before enabling this + secret. + + + {stateLabel} +
+ ); + } + + return ( +
+ {control} + {stateLabel} +
+ ); +}; + 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 f45415bdfa0..a449272857c 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts @@ -14,6 +14,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,6 +24,7 @@ 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", }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 001af980d0e..20e693e4944 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,6 +621,7 @@ 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", }, diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 4b81b94afcb..82833ff6885 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -444,6 +444,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, }; From 0b87fd75c65f8380efb84d0a0057b248fdd1b0eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:00:02 -0700 Subject: [PATCH 06/69] fix(coderd): reject workspace proxy hostname prefixes (#27544) (#27613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/27544 Original PR: #27544 — fix(coderd): reject workspace proxy hostname prefixes Merge commit: 8cc7f2bb0e0b33199393cc3773c7d691bd2fbf6b Requested by: @geokat Co-authored-by: George K Co-authored-by: Bobby Ho --- coderd/database/querier_test.go | 52 +++++++++++++++++++++++++++++ coderd/database/queries.sql.go | 2 +- coderd/database/queries/proxies.sql | 2 +- coderd/workspaceapps_test.go | 9 +++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 5cc5c9bd1a7..3a64ccd68e4 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", diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ee88e6cdba8..a4aa28f2048 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -24210,7 +24210,7 @@ WHERE ( ( $2 :: bool = true AND - url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' + url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]%)*' ) OR ( $3 :: bool = true AND diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index df59d3baf10..cac44de84d4 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/workspaceapps_test.go b/coderd/workspaceapps_test.go index 8db2858e01e..db6f77ab951 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", From a705ef6fb9c8f8788d58d82695683eb5b7e050f8 Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Wed, 29 Jul 2026 03:05:54 -0400 Subject: [PATCH 07/69] feat: add coder secret import for bulk secret files (cherry-pick #27534) (#27623) Cherry-picks #27534 into `release/2.36`. Cherry-picked commit: 0b2a6cac780baa7b93f57bd507a71f7419b3c597 Original description: Adds `coder secret import ` to bulk-import dotenv, JSON, or YAML secrets through the existing batch API. The command infers the format from the extension or accepts `--input-format`, supports non-interactive stdin, validates files locally before upload, and warns when imported keys cannot be injected as environment variables. Cherry-pick applied cleanly with no conflicts. --- _This PR was created by a Coder Agent on behalf of @dylanhuff-at-coder._ --- cli/secret.go | 165 ++++++++++++++++ cli/secret_internal_test.go | 104 ++++++++++ cli/secret_test.go | 187 ++++++++++++++++++ cli/testdata/coder_secret_--help.golden | 5 + .../coder_secret_import_--help.golden | 19 ++ docs/manifest.json | 5 + docs/reference/cli/secret.md | 5 + docs/reference/cli/secret_import.md | 26 +++ docs/user-guides/user-secrets.md | 17 ++ 9 files changed, 533 insertions(+) create mode 100644 cli/testdata/coder_secret_import_--help.golden create mode 100644 docs/reference/cli/secret_import.md diff --git a/cli/secret.go b/cli/secret.go index 630a1720b55..fce71374900 100644 --- a/cli/secret.go +++ b/cli/secret.go @@ -3,11 +3,15 @@ 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" @@ -31,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", @@ -50,6 +58,7 @@ func (r *RootCmd) secrets() *serpent.Command { Children: []*serpent.Command{ r.secretCreate(), r.secretUpdate(), + r.secretImport(), r.secretEnable(), r.secretDisable(), r.secretList(), @@ -241,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) diff --git a/cli/secret_internal_test.go b/cli/secret_internal_test.go index 70b4597feb1..1e5e6be1b71 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 455405a4924..0a24576988d 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" @@ -635,6 +640,188 @@ func TestSecretDelete(t *testing.T) { }) } +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() diff --git a/cli/testdata/coder_secret_--help.golden b/cli/testdata/coder_secret_--help.golden index 5088a3caf4a..a3061334da2 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 @@ -34,6 +38,7 @@ SUBCOMMANDS: 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 diff --git a/cli/testdata/coder_secret_import_--help.golden b/cli/testdata/coder_secret_import_--help.golden new file mode 100644 index 00000000000..217e6144054 --- /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/docs/manifest.json b/docs/manifest.json index cb71529fb93..9faabdc777e 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2153,6 +2153,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/cli/secret.md b/docs/reference/cli/secret.md index e9460e38a75..1413650750e 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 @@ -43,6 +47,7 @@ coder secret |---------------------------------------------|---------------------------------------------------| | [create](./secret_create.md) | Create a secret | | [update](./secret_update.md) | Update a secret | +| [import](./secret_import.md) | Import secrets from a file | | [enable](./secret_enable.md) | Enable a secret so it is injected into workspaces | | [disable](./secret_disable.md) | Disable a secret without removing it | | [list](./secret_list.md) | List secrets, or show one by name | diff --git a/docs/reference/cli/secret_import.md b/docs/reference/cli/secret_import.md new file mode 100644 index 00000000000..0c94b04d226 --- /dev/null +++ b/docs/reference/cli/secret_import.md @@ -0,0 +1,26 @@ + +# secret import + +Import secrets from a file + +## Usage + +```console +coder secret import [flags] +``` + +## 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 | env\|json\|yaml | + +Format of the secrets file. Inferred from the file extension when unset, and required when reading from stdin. diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index f3be7306758..56736293220 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -217,6 +217,23 @@ 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 ` 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 From dbd80db4050ef94c2181e31349711c0c90329b5d Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Wed, 29 Jul 2026 03:06:23 -0400 Subject: [PATCH 08/69] feat: add bulk secret import upload to Add secret dialog (PLAT-240) (#26725) (#27624) Cherry-pick of #26725 onto `release/2.36`. Cherry-picked commit: efbf802319a30c062397c8f9fbba6c43458afadc --- Original PR description: Adds a file dropzone to the create branch of the Add secret dialog (final PR in the PLAT-240 stack, after #26723 and #26724). The browser reads the file, derives the format from the extension (`.env`/`.json`/`.yaml`/`.yml`), and imports via `POST /secrets/batch`; per-entry backend errors surface in an alert and the success toast flags secrets imported without an env name. Storybook play stories and vitests cover the flow. Also documents the upload flow in `docs/user-guides/user-secrets.md`. Closes https://linear.app/codercom/issue/PLAT-240 --- *This cherry-pick was performed by a Coder Agent on behalf of @dylanhuff-at-coder.* --- docs/user-guides/user-secrets.md | 36 +++ site/src/api/api.ts | 12 + site/src/api/queries/userSecrets.ts | 12 + site/src/components/FileUpload/FileUpload.tsx | 17 +- .../SecretsPage/SecretDialog.tsx | 139 ++++++++- .../SecretsPage/SecretsPage.tsx | 10 + .../SecretsPage/SecretsPageView.stories.tsx | 285 +++++++++++++++++- .../SecretsPage/SecretsPageView.tsx | 4 + .../SecretsPage/secretForm.test.ts | 81 ++++- .../SecretsPage/secretForm.ts | 36 +++ site/src/testHelpers/entities.ts | 21 ++ site/src/testHelpers/handlers.ts | 3 + 12 files changed, 636 insertions(+), 20 deletions(-) diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index 56736293220..ff2c862854d 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -299,5 +299,41 @@ 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/site/src/api/api.ts b/site/src/api/api.ts index 284edf485c4..2cd93083fd1 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1788,6 +1788,18 @@ class ApiMethods { ); }; + importUserSecrets = async ( + userId: string, + request: TypesGen.ImportUserSecretsRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/users/${encodeURIComponent(userId)}/secrets/batch`, + request, + ); + + return response.data; + }; + getWorkspaceBuilds = async ( workspaceId: string, req?: TypesGen.WorkspaceBuildsRequest, diff --git a/site/src/api/queries/userSecrets.ts b/site/src/api/queries/userSecrets.ts index 40463d78510..340e791c914 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/components/FileUpload/FileUpload.tsx b/site/src/components/FileUpload/FileUpload.tsx index b35a89e76d3..2e6cd3b2529 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 = ({ isUploading, onUpload, + onUnsupportedFile, onRemove, file, removeLabel, @@ -26,7 +28,7 @@ export const FileUpload: FC = ({ description, extensions, }) => { - const fileDrop = useFileDrop(onUpload, extensions); + const fileDrop = useFileDrop(onUpload, extensions, onUnsupportedFile); const inputRef = useRef(null); const clickable = useClickable(() => inputRef.current?.click(), @@ -87,6 +89,7 @@ export const FileUpload: FC = ({ 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 = ({ const useFileDrop = ( callback: (file: File) => void, extensions?: string[], + onUnsupportedFile?: (file: File) => void, ): { onDragOver: (e: DragEvent) => void; onDrop: (e: DragEvent) => 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/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx index 4e6709ed715..b33420c5b72 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,11 @@ 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 +39,7 @@ import { mapSecretApiErrorToFormErrors, type SecretFieldErrors, type SecretFormValues, + secretsFileFormatFromFilename, } from "./secretForm"; type SecretDialogProps = { @@ -43,6 +55,7 @@ type SecretDialogProps = { name: string, request: UpdateUserSecretRequest, ) => Promise | UserSecret; + onImportSecrets: (request: ImportUserSecretsRequest) => Promise; }; const emptyValues: SecretFormValues = { @@ -64,6 +77,7 @@ export const SecretDialog: FC = ({ onClose, onCreateSecret, onUpdateSecret, + onImportSecrets, }) => { const isEdit = Boolean(secret); const initialValues = secret @@ -76,6 +90,9 @@ export const SecretDialog: FC = ({ } : emptyValues; const [clearValueRequested, setClearValueRequested] = useState(false); + const [importFile, setImportFile] = useState(undefined); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(undefined); const form = useFormik({ initialValues, @@ -111,17 +128,62 @@ export const SecretDialog: FC = ({ 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 +255,32 @@ export const SecretDialog: FC = ({ ) : ( <> +
+ { + 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 && ( + + )} +
+
+ + + or add individually + + +
[field, true]), ) as FormikTouched; } + +type ImportSecretsErrorProps = { + error: unknown; +}; + +const ImportSecretsError: FC = ({ error }) => { + const validations = getImportSecretValidations(error); + if (validations.length === 0) { + return ; + } + + return ( + + + {getErrorMessage(error, "Failed to import secrets.")} + + +
    + {validations.map((validation) => ( +
  • + {validation.field} + {validation.detail} +
  • + ))} +
+
+
+ ); +}; + +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 43b74638842..3f72521c33b 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 ( { 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); diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index 0988c7172ba..91a632afb54 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,6 +35,7 @@ const meta: Meta = { onRefresh: fn(), onCreateSecret: fn(), onUpdateSecret: fn(), + onImportSecrets: fn(), onDeleteSecret: fn(), onToggleSecretEnabled: fn(), }, @@ -56,6 +64,16 @@ const waitForDialogToClose = async (body: ReturnType) => { }); }; +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) => { expect(body.queryByLabelText("Value")).not.toBeInTheDocument(); }; @@ -603,6 +621,261 @@ export const CreateMutationErrorDisplay: Story = { }, }; +const importSecretsSuccess = fn< + (request: ImportUserSecretsRequest) => Promise +>(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 +>(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 +>(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 +>(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 +>(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(); +const importSecretsPending = fn< + (request: ImportUserSecretsRequest) => Promise +>(() => 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 +>(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< diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx index 26543a988fe..1e44db1b232 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,6 +36,7 @@ type SecretsPageViewProps = { name: string, request: UpdateUserSecretRequest, ) => Promise | UserSecret; + onImportSecrets: (request: ImportUserSecretsRequest) => Promise; onDeleteSecret: (secret: UserSecret) => Promise | void; onToggleSecretEnabled: ( secret: UserSecret, @@ -58,6 +60,7 @@ export const SecretsPageView: FC = ({ onRefresh, onCreateSecret, onUpdateSecret, + onImportSecrets, onDeleteSecret, onToggleSecretEnabled, }) => { @@ -127,6 +130,7 @@ export const SecretsPageView: FC = ({ onClose={closeSecretDialog} onCreateSecret={onCreateSecret} onUpdateSecret={onUpdateSecret} + onImportSecrets={onImportSecrets} /> {getSecretsError ? : undefined} diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts index a449272857c..cb347115706 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[] = [ @@ -30,6 +32,58 @@ const existingSecrets: UserSecret[] = [ }, ]; +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( @@ -121,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 4b242ea4a0d..af951c46413 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, ): SecretFieldErrors => { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 20e693e4944..17a296a812b 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -627,6 +627,27 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ }, ]; +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; diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 82833ff6885..a615681ff1b 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 }) => { From 8eb4c508789e9f3a0abe4f2f457635442d14d2db Mon Sep 17 00:00:00 2001 From: "J. Scott Miller" Date: Wed, 29 Jul 2026 02:09:01 -0500 Subject: [PATCH 09/69] feat(site): hide workspace resources when lacking workspace-create permission (cherry-pick #27278) (#27625) Cherry-picks #27278 into `release/2.36`. Cherry-picked commit: ce769680ba3d87b2d10c1d152b04fa949f28d6c1 Original description: Context: experiment `minimum-implicit-member ` added the ability to set the default member-roles at a per-organization level. This, along with the related PR stacked listed below, will be used to enable "gateway accounts", which are accounts that are entitled to use the AI Gateway but not create or use workspaces. The Workspaces tab is intentionally left visible for now. Hides the "New workspace" button and the empty-state creation CTA on the Workspaces page for users who cannot create a workspace in any organization, and guards the creation page itself. Adds a shared `createWorkspace` authorization check (`workspace` resource, `create` action, `owner_id: me`, `any_org: true`) to `site/permissions.json` and threads the result through `WorkspacesPageView`, `WorkspacesTable`, and `WorkspacesEmpty`. Users without the permission see an empty state explaining they don't have permission to create workspaces instead of a dead-end CTA. The create CTAs on the Templates pages were already gated by per-organization checks; this brings the Workspaces page in line. `CreateWorkspacePage` is also gated: it adds an org-scoped `createWorkspaceForUserID` check to its existing authorization batch and wraps the view in `RequirePermission`, so a direct URL shows the standard denial dialog instead of a form that 403s on submit. Users who can create workspaces for others (`createWorkspaceForAny`) still see the form. To see this behavior, enable the experiment. As an admin, visit Organization -> Roles, and remove "Organization Workspace Access" from the default roles. Login as a user that is not granted workspace access via a member role. Storybook coverage: `CannotCreateWorkspace` (empty state + hidden button), `CannotCreateWorkspaceWithWorkspaces` (button hidden while the table renders), `CannotCreateWorkspaceWithFilter` (pins the filter empty state's priority over the no-permission one), and `PermissionDenied` for the CreateWorkspacePage gate. The Go SSR permissions test also asserts the new `createWorkspace` entry. Cherry-pick applied cleanly with no conflicts. --- _This PR was created by a Coder Agent on behalf of @jscottmiller._ --- site/permissions.json | 8 + site/site_test.go | 34 +++++ .../CreateWorkspacePage.stories.tsx | 95 +++++++++--- .../CreateWorkspacePage.test.tsx | 100 ++++++++++++- .../CreateWorkspacePage.tsx | 139 ++++++++++-------- .../CreateWorkspacePageView.stories.tsx | 1 + .../pages/CreateWorkspacePage/permissions.ts | 9 ++ .../pages/WorkspacesPage/WorkspacesEmpty.tsx | 13 ++ .../WorkspacesPage/WorkspacesPage.stories.tsx | 1 + .../pages/WorkspacesPage/WorkspacesPage.tsx | 1 + .../WorkspacesPageView.stories.tsx | 54 +++++++ .../WorkspacesPageView.test.tsx | 111 ++++++++++++++ .../WorkspacesPage/WorkspacesPageView.tsx | 17 ++- .../pages/WorkspacesPage/WorkspacesTable.tsx | 3 + site/src/testHelpers/entities.ts | 2 + 15 files changed, 502 insertions(+), 86 deletions(-) create mode 100644 site/src/pages/WorkspacesPage/WorkspacesPageView.test.tsx diff --git a/site/permissions.json b/site/permissions.json index 66ef7aa2370..71b91a51b12 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 e8f693001e0..bb66ecaf9a6 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/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.stories.tsx index 87fb8947d2c..c004e152ab2 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 = { 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 = { 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 7e28018cc23..e2474f3ab4f 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 = {}) => + 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 3636a262ced..d818a887f9d 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. + + + + ) : shouldShowLoader ? ( ) : ( - { - navigate(-1); - }} - onSubmit={async (request, owner) => { - let workspaceRequest = request; - if (realizedVersionId) { - workspaceRequest = { - ...request, - template_id: undefined, - template_version_id: realizedVersionId, - }; + + + 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); + }} + /> + )} ); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index fb4b889ce7d..b516ebf33d5 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -27,6 +27,7 @@ const meta: Meta = { 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 a5ca3a469f6..e4dea942d70 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/WorkspacesPage/WorkspacesEmpty.tsx b/site/src/pages/WorkspacesPage/WorkspacesEmpty.tsx index 17baf47c5e7..ef15d1a653f 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 = ({ isUsingFilter, templates, canCreateTemplate, + canCreateWorkspace, }) => { const getLink = useLinks(); @@ -34,6 +36,17 @@ export const WorkspacesEmpty: FC = ({ return ; } + if (!canCreateWorkspace) { + return ( + + ); + } + if (templates && templates.length === 0 && canCreateTemplate) { return ( { = { checkedWorkspaces: [], templates: mockTemplates, templatesFetchStatus: "success", + canCreateWorkspace: true, count: 13, page: 1, }, @@ -190,6 +191,55 @@ const meta: Meta = { export default meta; type Story = StoryObj; +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 00000000000..c87395a8888 --- /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( + , + ); + + 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(); + + 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( + , + ); + + 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( + , + ); + + 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 6f0fb34ca67..aa5c4da077e 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; onActionError: (error: unknown) => void; @@ -75,6 +76,7 @@ export const WorkspacesPageView: FC = ({ templates, templatesFetchStatus, canCreateTemplate, + canCreateWorkspace, canChangeVersions, onActionSuccess, onActionError, @@ -89,12 +91,14 @@ export const WorkspacesPageView: FC = ({ - New workspace - + canCreateWorkspace && ( + + New workspace + + ) } > @@ -212,6 +216,7 @@ export const WorkspacesPageView: FC = ({ ) : ( void; templates?: Template[]; canCreateTemplate: boolean; + canCreateWorkspace: boolean; onActionSuccess: () => Promise; onActionError: (error: unknown) => void; chatsByWorkspace?: Record; @@ -106,6 +107,7 @@ export const WorkspacesTable: FC = ({ onCheckChange, templates, canCreateTemplate, + canCreateWorkspace, onActionSuccess, onActionError, chatsByWorkspace, @@ -168,6 +170,7 @@ export const WorkspacesTable: FC = ({ templates={templates} isUsingFilter={isUsingFilter} canCreateTemplate={canCreateTemplate} + canCreateWorkspace={canCreateWorkspace} />
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 17a296a812b..38cf409ccd3 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3320,6 +3320,7 @@ export const MockTemplateExample2: TypesGen.TemplateExample = { export const MockPermissions: Permissions = { createTemplates: true, createUser: true, + createWorkspace: true, deleteTemplates: true, updateTemplates: true, viewAllUsers: true, @@ -3356,6 +3357,7 @@ export const MockPermissions: Permissions = { export const MockNoPermissions: Permissions = { createTemplates: false, createUser: false, + createWorkspace: false, deleteTemplates: false, updateTemplates: false, viewAllUsers: false, From 6dbc9001aef4ec3746d0cb4bb6d38eec5b2e3b32 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 29 Jul 2026 11:25:41 +0100 Subject: [PATCH 10/69] =?UTF-8?q?fix(coderd/x/chatd/chaterror):=20extract?= =?UTF-8?q?=20plain-text=20provider=20error=20bodi=E2=80=A6=20(#27641)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …es (#27597) Follows up #27538. Fixes an issue where `chaterror` would not classify a plain-text aibridge budget error 403 as ChatErrorKindUsageLimit. Root cause: Anthropic adapter drops the body from `ProviderError.Message`, and `providerErrorResponseMessage` extracted only JSON. - Parses the dumped response with `http.ReadResponse` (strips headers, de-chunks, leaves non-dump payloads like Google's raw messages whole). - When JSON extraction yields nothing, falls back to the trimmed first line of the body. - Falls back on `Content-Type: text/plain` only, and never on valid JSON as `Detail` is user-facing. - Adds an end-to-end regression test that Anthropic-shaped 403 budget error → `usage_limit`, not retryable (was `auth`). - Adds table-driven test cases: HTML skipped, whitespace skipped, first-line-only, JSON-without-message, chunked, Google raw message. --- > Generated by Coder Agents on behalf of @johnstcn. (cherry picked from commit 5d2a69d85a2e47b37d1369fe77809cf348c7e161) --- coderd/x/chatd/chaterror/classify_test.go | 140 ++++++++++++++++++++- coderd/x/chatd/chaterror/provider_error.go | 43 +++++-- 2 files changed, 173 insertions(+), 10 deletions(-) diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 2db9ab5a1a4..f4788b488e9 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", "502 Bad Gateway"), + 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 0b3b4406394..e443519b646 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 { From 4ac4749b8a2e0d7c1b07349694fa44672bb09a09 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:27:32 +0200 Subject: [PATCH 11/69] fix: report combined member limit in group AI spend (#27589) (#27639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27589 Original PR: #27589 — fix: report combined member limit in group AI spend Merge commit: 0b4095085ef182d6aefb57cfd5fbc7546ac2868e Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- coderd/apidoc/docs.go | 16 +- coderd/apidoc/swagger.json | 16 +- coderd/database/db2sdk/db2sdk.go | 3 + coderd/database/querier.go | 8 +- coderd/database/querier_test.go | 348 +++++++++++++++--- coderd/database/queries.sql.go | 119 +++++- coderd/database/queries/aicostcontrol.sql | 105 +++++- codersdk/aibridge.go | 12 +- docs/reference/api/enterprise.md | 6 +- docs/reference/api/schemas.md | 35 +- enterprise/coderd/aibridge_test.go | 183 ++++++--- site/src/api/typesGenerated.ts | 14 +- .../GroupsPage/GroupsPageView.stories.tsx | 15 +- site/src/pages/GroupsPage/GroupsPageView.tsx | 2 +- 14 files changed, 705 insertions(+), 177 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 945f529539f..fadcec4cf76 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20685,7 +20685,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": { @@ -20703,7 +20703,11 @@ const docTemplate = `{ "format": "date-time" }, "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" } } @@ -22183,7 +22187,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": { @@ -22191,7 +22195,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" } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ba174267697..5bd0c5dbe49 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18829,7 +18829,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": { @@ -18847,7 +18847,11 @@ "format": "date-time" }, "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" } } @@ -20249,7 +20253,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": { @@ -20257,7 +20261,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" } } diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index c6e49c4dd93..47dff8ac872 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1475,6 +1475,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 } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f82ef6ea146..e3030339624 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -684,9 +684,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) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 3a64ccd68e4..a5715df585a 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -12935,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", - setBudget: true, - spendLimit: 0, - wantCurrentSpend: 0, + name: "ZeroLimitBudget", + budgets: []groupBudget{{group: "eng", limit: 0}}, + want: map[string]wantGroup{ + "eng": {spendLimit: nullInt64(0), totalLimit: nullInt64(0)}, + }, }, { - name: "BudgetZeroSpend", - setBudget: true, - spendLimit: 1_000_000, - wantCurrentSpend: 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)}, + }, }, { - name: "BudgetWithSpend", - setBudget: true, - spendLimit: 1_000_000, - rows: []seedRow{{now, 250}}, - wantCurrentSpend: 250, + // 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", - rows: []seedRow{{now, 100}}, - wantCurrentSpend: 100, + 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: "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: "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: "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: "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)}, + }, }, } @@ -12983,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) }) } @@ -13067,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) }) @@ -13231,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 a4aa28f2048..8eafb9cbf8c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2860,41 +2860,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 +2984,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 diff --git a/coderd/database/queries/aicostcontrol.sql b/coderd/database/queries/aicostcontrol.sql index 6d9753ff6b2..43c5970fe2d 100644 --- a/coderd/database/queries/aicostcontrol.sql +++ b/coderd/database/queries/aicostcontrol.sql @@ -122,23 +122,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/codersdk/aibridge.go b/codersdk/aibridge.go index 3215bed8b3c..e5fb5aeadb6 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -84,11 +84,15 @@ 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"` } diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 88026e8599f..9a85a7a1718 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1066,7 +1066,8 @@ Returns the AI spend limit and aggregate spend for the group. "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 + "spend_limit_micros": 0, + "total_spend_limit_micros": 0 } ``` @@ -2003,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", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 86c071c4380..f7dbb7f7491 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7760,19 +7760,21 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "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 + "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 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 | | | +| `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 @@ -9304,17 +9306,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 @@ -9324,7 +9328,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", diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 7410f9279c1..9d9fc1776b7 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -3544,46 +3544,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 +3638,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) { @@ -4660,46 +4696,53 @@ func TestGroupAISpend(t *testing.T) { 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 - 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, }, } @@ -4746,9 +4789,37 @@ func TestGroupAISpend(t *testing.T) { 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) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7f209f21213..96495ee9b6c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6680,13 +6680,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; } diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx index 598e1bfad1d..454f184f620 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 2668f25417f..f7ed9fffd1d 100644 --- a/site/src/pages/GroupsPage/GroupsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -218,7 +218,7 @@ const GroupRow: FC = ({ group, showAIBudget }) => { {group.spend ? ( ) : ( EM_DASH From b138ddfe4b62568e9305b5228617bee198393a91 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 29 Jul 2026 13:33:58 +0100 Subject: [PATCH 12/69] fix(aibridge): handle sonnet 5 adaptive thinking in bedrock (#27339) (#27642) Adds sonnet 5 to the list of models that require adaptive thinking for Bedrock InvokeModel. Smoke-tested locally. > Obligatory disclosure: a Coder agent helped with this. (cherry picked from commit b371262e5cd7bdacdf891803dbeb9e31740021f3) --- aibridge/intercept/messages/base.go | 16 ++++---- .../intercept/messages/base_internal_test.go | 17 +++++++- aibridge/intercept/messages/reqpayload.go | 13 +++--- .../integrationtest/bridge_internal_test.go | 40 +++++++++++++++---- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index 35e416c63fd..f8143b63d80 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,13 @@ 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. // // See https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-opus-4-7.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-sonnet-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 4f1aa416200..d70d2fa8795 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,21 @@ 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"}, + }, { // 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 cb58afa6e20..00360323608 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/internal/integrationtest/bridge_internal_test.go b/aibridge/internal/integrationtest/bridge_internal_test.go index 1b515735da4..0b2389d3390 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". From fcdcef0db56c37289dd4a2c523496270d431dc21 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:26:37 +0200 Subject: [PATCH 13/69] fix: ai cost control cap configurable AI spend limit (#27640) (#27645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27640 Original PR: #27640 — fix: ai cost control cap configurable AI spend limit Merge commit: e71249a821cf3464c3b6cf86233ce2a9eafb02c7 Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- coderd/apidoc/docs.go | 2 + coderd/apidoc/swagger.json | 2 + codersdk/aibridge.go | 10 ++- docs/reference/api/schemas.md | 8 +-- enterprise/coderd/aibridge.go | 22 ++++++ enterprise/coderd/aibridge_test.go | 69 +++++++++++++++++++ site/src/api/typesGenerated.ts | 13 ++++ site/src/modules/groups.ts | 20 ++++-- .../GroupSettingsPageView.stories.tsx | 35 +++++++--- .../GroupsPage/GroupSettingsPageView.tsx | 14 ++-- .../UserAIBudgetOverrideDialog.stories.tsx | 41 ++++++++++- .../GroupsPage/UserAIBudgetOverrideDialog.tsx | 12 +++- 12 files changed, 218 insertions(+), 30 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fadcec4cf76..204b8eb152b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -26196,6 +26196,7 @@ const docTemplate = `{ "type": "object", "properties": { "spend_limit_micros": { + "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.", "type": "integer", "minimum": 0 } @@ -26213,6 +26214,7 @@ const docTemplate = `{ "format": "uuid" }, "spend_limit_micros": { + "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.", "type": "integer", "minimum": 0 } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5bd0c5dbe49..ccac0cb9d27 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -24086,6 +24086,7 @@ "type": "object", "properties": { "spend_limit_micros": { + "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.", "type": "integer", "minimum": 0 } @@ -24101,6 +24102,7 @@ "format": "uuid" }, "spend_limit_micros": { + "description": "SpendLimitMicros must not exceed MaxAISpendLimitMicros.", "type": "integer", "minimum": 0 } diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index e5fb5aeadb6..ce4ec3fc030 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 @@ -418,6 +422,7 @@ type GroupAIBudget struct { } type UpsertGroupAIBudgetRequest struct { + // SpendLimitMicros must not exceed MaxAISpendLimitMicros. SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"` } @@ -485,8 +490,9 @@ 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. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f7dbb7f7491..0b70aa6906d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -14054,9 +14054,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 @@ -14072,7 +14072,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 diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index be338982c0f..3fddd80371f 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -611,6 +611,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 +656,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. @@ -750,6 +769,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. diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 9d9fc1776b7..46d57130246 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -2289,6 +2289,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 +2624,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() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 96495ee9b6c..4bfcf51bfce 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5804,6 +5804,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 @@ -9918,6 +9925,9 @@ export interface UpsertChatUsageLimitOverrideRequest { // From codersdk/aibridge.go export interface UpsertGroupAIBudgetRequest { + /** + * SpendLimitMicros must not exceed MaxAISpendLimitMicros. + */ readonly spend_limit_micros: number; } @@ -9928,6 +9938,9 @@ export interface UpsertUserAIBudgetOverrideRequest { * be a member of this group. */ readonly group_id: string; + /** + * SpendLimitMicros must not exceed MaxAISpendLimitMicros. + */ readonly spend_limit_micros: number; } diff --git a/site/src/modules/groups.ts b/site/src/modules/groups.ts index ef58ea459b2..673850e4390 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/pages/GroupsPage/GroupSettingsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.stories.tsx index 98cb79f48ff..a80aba46e55 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 a2eee3fc739..4bc3596e5d5 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 = ({ {usdBudgetFormatter.format(budgetAmount * memberCount)} - /month maximum, based on{" "} + /month, based on{" "} {memberCount}{" "} {memberCount === 1 ? "member" : "members"}. @@ -244,6 +249,7 @@ const UpdateGroupForm: FC = ({ onBlur={budgetField.onBlur} type="number" min="0" + max={maxAIBudgetDollars} step="1" placeholder="unlimited" aria-invalid={budgetField.error} diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index e26162a8d4d..3953bf5550d 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"; @@ -122,12 +123,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 +223,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 +245,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 a7448793c88..9b7a292e685 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, @@ -213,9 +214,13 @@ const OverrideForm: FC = ({ 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; @@ -319,6 +324,7 @@ const OverrideForm: FC = ({ onBlur={() => setBudgetTouched(true)} type="number" min="0" + max={maxAIBudgetDollars} step="1" aria-invalid={budgetInvalid} aria-describedby={ @@ -332,7 +338,7 @@ const OverrideForm: FC = ({ id={`${budgetId}-error`} className="m-0 text-sm text-content-destructive" > - Enter a monthly budget of 0 or more. + {aiBudgetRangeError}

)} From a2930b084af068c9c2bc3c69fb751f7a9794b430 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 29 Jul 2026 17:27:18 +0300 Subject: [PATCH 14/69] fix(site): refresh group member budgets after overrides (cherry-pick #27553) (#27648) Cherry-picks #27553 into `release/2.36`. Cherry-picked commit: e3a5a697ab46b62b8ffbc3dffb38065d248840e8 Original description: Group member budget rows remained stale after saving or deleting a user override because the cached member-spend query was not refreshed. Invalidate the affected user's override query and only cached group-member spend queries whose user ID list contains that user. This refreshes relevant rows without invalidating unrelated groups. Cherry-pick applied cleanly with no conflicts. --- _This PR was created by a Coder Agent on behalf of @EhabY._ --- site/src/api/queries/groups.test.ts | 35 +++++ site/src/api/queries/groups.ts | 19 +++ site/src/api/queries/users.ts | 20 ++- .../pages/GroupsPage/GroupPage.stories.tsx | 137 +++++++++++++++++- 4 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 site/src/api/queries/groups.test.ts diff --git a/site/src/api/queries/groups.test.ts b/site/src/api/queries/groups.test.ts new file mode 100644 index 00000000000..74ebf8606b6 --- /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 8093330fc24..6e714f92c79 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/users.ts b/site/src/api/queries/users.ts index b5a9b2bed60..47e7f539319 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/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 8b8c15ba3be..4b3ad04fe84 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 { @@ -503,6 +504,140 @@ export const WithMemberAIBudgetInAnotherOrg: 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"], From f47eb6afca0a9c45ac43c5452c9f226b6f5fd50d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:29:25 +0200 Subject: [PATCH 15/69] refactor: make user AI budget and spend endpoints consistent (#27611) (#27644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27611 Original PR: #27611 — refactor: make user AI budget and spend endpoints consistent Merge commit: d6a5c8e9f85d7f89f4f559b664df4a594f15d8f7 Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- coderd/apidoc/docs.go | 46 +++++----- coderd/apidoc/swagger.json | 46 +++++----- coderd/database/db2sdk/db2sdk.go | 2 +- codersdk/aibridge.go | 24 +++-- docs/reference/api/enterprise.md | 18 ++-- docs/reference/api/schemas.md | 57 ++++++------ enterprise/coderd/aibridge.go | 12 ++- enterprise/coderd/aibridge_test.go | 92 ++++++++++--------- enterprise/coderd/coderd.go | 2 +- site/src/api/api.ts | 6 +- site/src/api/typesGenerated.ts | 36 ++++---- .../UserDropdown/UserDropdown.stories.tsx | 21 ++++- .../Navbar/UserDropdown/UserDropdown.tsx | 7 +- .../pages/GroupsPage/GroupPage.stories.tsx | 6 +- 14 files changed, 194 insertions(+), 181 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 204b8eb152b..d4017aa7dd5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9888,7 +9888,7 @@ const docTemplate = `{ ] } }, - "/api/v2/users/{user}/ai/budget": { + "/api/v2/users/{user}/ai/budget/override": { "get": { "produces": [ "application/json" @@ -15722,6 +15722,17 @@ const docTemplate = `{ } } }, + "codersdk.AIBudgetLimit": { + "type": "object", + "properties": { + "limit_source": { + "$ref": "#/definitions/codersdk.AIBudgetLimitSource" + }, + "spend_limit_micros": { + "type": "integer" + } + } + }, "codersdk.AIBudgetLimitSource": { "type": "string", "enum": [ @@ -15770,17 +15781,6 @@ const docTemplate = `{ } } }, - "codersdk.AIGroupBudget": { - "type": "object", - "properties": { - "limit_source": { - "$ref": "#/definitions/codersdk.AIBudgetLimitSource" - }, - "spend_limit_micros": { - "type": "integer" - } - } - }, "codersdk.AIProvider": { "type": "object", "properties": { @@ -20724,7 +20724,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" } ] }, @@ -26405,19 +26405,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", @@ -26428,10 +26428,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" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ccac0cb9d27..5ef26e52117 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8771,7 +8771,7 @@ ] } }, - "/api/v2/users/{user}/ai/budget": { + "/api/v2/users/{user}/ai/budget/override": { "get": { "produces": ["application/json"], "tags": ["Enterprise"], @@ -14026,6 +14026,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"], @@ -14071,17 +14082,6 @@ } } }, - "codersdk.AIGroupBudget": { - "type": "object", - "properties": { - "limit_source": { - "$ref": "#/definitions/codersdk.AIBudgetLimitSource" - }, - "spend_limit_micros": { - "type": "integer" - } - } - }, "codersdk.AIProvider": { "type": "object", "properties": { @@ -18868,7 +18868,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" } ] }, @@ -24272,19 +24272,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", @@ -24295,10 +24295,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" diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 47dff8ac872..874db0e5258 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1490,7 +1490,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), } diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index ce4ec3fc030..66f754bae61 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -32,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 @@ -126,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"` @@ -498,7 +496,7 @@ type UpsertUserAIBudgetOverrideRequest struct { // 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 { @@ -516,7 +514,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 { @@ -534,7 +532,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 { diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index 9a85a7a1718..1717b38a11e 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -3901,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 @@ -3942,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 @@ -3994,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 @@ -4040,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/schemas.md b/docs/reference/api/schemas.md index 0b70aa6906d..1480100f860 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -938,6 +938,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 +1071,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 @@ -7795,7 +7795,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 | | | @@ -14230,26 +14230,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 diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 3fddd80371f..884bd23afdb 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -732,7 +732,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) @@ -760,7 +760,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) @@ -857,7 +857,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) @@ -961,8 +961,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)) diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 46d57130246..44ace6025c8 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -3230,33 +3230,38 @@ func TestUserAISpendStatus(t *testing.T) { 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, }, { @@ -3264,27 +3269,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, }, } @@ -3343,8 +3354,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) }) } @@ -3377,8 +3387,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) }) @@ -3402,8 +3411,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) }) } @@ -5221,14 +5229,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, }, @@ -5238,7 +5246,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, }, @@ -5248,7 +5256,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, }, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index d65dd1f6950..bff2523fdb1 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -698,7 +698,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { 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/site/src/api/api.ts b/site/src/api/api.ts index 2cd93083fd1..b87d08a9f2b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1615,7 +1615,7 @@ class ApiMethods { userId: TypesGen.User["id"], ): Promise => { const response = await this.axios.get( - `/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 => { const response = await this.axios.put( - `/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 => { await this.axios.delete( - `/api/v2/users/${encodeURIComponent(userId)}/ai/budget`, + `/api/v2/users/${encodeURIComponent(userId)}/ai/budget/override`, ); }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4bfcf51bfce..3031302fccd 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -266,6 +266,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 +320,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 @@ -5261,7 +5261,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. @@ -10001,7 +10001,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; @@ -10012,15 +10012,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 diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 521c9086c5d..1e0900dc254 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -12,9 +12,11 @@ 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", @@ -159,7 +161,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 }) => { @@ -205,7 +207,10 @@ export const AISpendZeroLimit: Story = { data: { ...mockAISpend, current_spend_micros: 0, - spend_limit_micros: 0, + effective_budget: { + spend_limit_micros: 0, + limit_source: "group", + }, }, }, ], @@ -281,7 +286,13 @@ 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 }) => { diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index a7470d826ef..828ffcc17c9 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -44,16 +44,17 @@ export const UserDropdown: FC = ({ 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/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 4b3ad04fe84..a7bda78a142 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -101,9 +101,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", From 358c3e73603138e48a8c23c41be8e7a36afae417 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:55:57 +0200 Subject: [PATCH 16/69] chore: remove ai-gateway-cost-control experiment flag (#27579) (#27647) --- coderd/apidoc/docs.go | 4 - coderd/apidoc/swagger.json | 4 - codersdk/deployment.go | 4 - docs/reference/api/schemas.md | 6 +- enterprise/coderd/aibridge_test.go | 136 +----------------- enterprise/coderd/coderd.go | 12 -- site/src/api/typesGenerated.ts | 2 - .../UserDropdown/UserDropdown.stories.tsx | 11 +- .../Navbar/UserDropdown/UserDropdown.tsx | 7 +- site/src/pages/GroupsPage/AIBudgetPeriod.tsx | 7 +- .../src/pages/GroupsPage/GroupMembersPage.tsx | 7 +- .../pages/GroupsPage/GroupPage.stories.tsx | 6 - .../pages/GroupsPage/GroupSettingsPage.tsx | 8 +- site/src/pages/GroupsPage/GroupsPage.tsx | 7 +- 14 files changed, 14 insertions(+), 207 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d4017aa7dd5..961f8d36391 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20213,12 +20213,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.", @@ -20245,7 +20243,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." ], @@ -20261,7 +20258,6 @@ const docTemplate = `{ "ExperimentMinimumImplicitMember", "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewaySeatExclusion", - "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop" ] diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5ef26e52117..7c8a4950e7b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18357,12 +18357,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.", @@ -18389,7 +18387,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." ], @@ -18405,7 +18402,6 @@ "ExperimentMinimumImplicitMember", "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewaySeatExclusion", - "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop" ] diff --git a/codersdk/deployment.go b/codersdk/deployment.go index f5430fc2fae..c95d910a0ed 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5261,7 +5261,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 +5289,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 +5314,6 @@ var ExperimentsKnown = Experiments{ ExperimentMinimumImplicitMember, ExperimentWorkspaceCapableLicensing, ExperimentAIGatewaySeatExclusion, - ExperimentAIGatewayCostControl, ExperimentChatAdvisor, ExperimentChatVirtualDesktop, } diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 1480100f860..c4e7ad35ce1 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7244,9 +7244,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 diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 44ace6025c8..00c7b4dde7a 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -2998,7 +2998,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{ @@ -3096,7 +3095,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{ @@ -3186,7 +3184,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{ @@ -3202,28 +3199,6 @@ 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 @@ -3421,7 +3396,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{ @@ -3475,7 +3449,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{ @@ -3494,30 +3467,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() @@ -3754,7 +3703,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{ @@ -3857,22 +3805,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) { @@ -3880,9 +3820,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}, @@ -4525,7 +4462,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{ @@ -4690,7 +4626,6 @@ func TestGroupAISpend(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{ @@ -4713,34 +4648,6 @@ func TestGroupAISpend(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-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-cost-control") - }) - t.Run("MalformedGroupID", func(t *testing.T) { t.Parallel() @@ -4904,7 +4811,6 @@ func TestGroupAISpendRoleAccess(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{ @@ -4975,7 +4881,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{ @@ -5067,7 +4972,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{ @@ -5090,34 +4994,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() @@ -5347,7 +5223,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}, @@ -5406,7 +5281,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}, @@ -5496,7 +5370,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{ @@ -5586,16 +5459,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) } @@ -5647,7 +5518,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/coderd.go b/enterprise/coderd/coderd.go index bff2523fdb1..8df7476400e 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,8 +621,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.groupMembersAISpend) @@ -636,8 +628,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.groupAISpend) @@ -692,8 +682,6 @@ 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), diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3031302fccd..759c0a8cb10 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4835,7 +4835,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" @@ -4851,7 +4850,6 @@ export type Experiment = | "workspace-usage"; export const Experiments: Experiment[] = [ - "ai-gateway-cost-control", "ai-gateway-seat-exclusion", "auto-fill-parameters", "chat-advisor", diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 1e0900dc254..6260b96dc71 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -1,11 +1,7 @@ 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"; @@ -22,9 +18,8 @@ const mockAISpend: UserAISpendStatus = { 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 = { @@ -60,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(); }); diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index 828ffcc17c9..2cc33a8fa03 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,11 +33,7 @@ export const UserDropdown: FC = ({ 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, diff --git a/site/src/pages/GroupsPage/AIBudgetPeriod.tsx b/site/src/pages/GroupsPage/AIBudgetPeriod.tsx index 8ce3e8d8e9e..b716da63136 100644 --- a/site/src/pages/GroupsPage/AIBudgetPeriod.tsx +++ b/site/src/pages/GroupsPage/AIBudgetPeriod.tsx @@ -2,16 +2,11 @@ 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"; /** 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) { diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index beda74199d6..ff4ee823f29 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -47,7 +47,6 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; -import { useDashboard } from "#/modules/dashboard/useDashboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { isEveryoneGroup } from "#/modules/groups"; import { cn } from "#/utils/cn"; @@ -81,11 +80,7 @@ const GroupMembersPage: FC = () => { const canUpdateGroup = permissions ? permissions.canUpdateGroup : false; const [budgetUser, setBudgetUser] = useState(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, diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index a7bda78a142..60d5f447a42 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -160,7 +160,6 @@ export const MembersSpendError: Story = { }, parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ users: [MockUserMember], count: 1 }), @@ -315,7 +314,6 @@ const mockGroupBudget: GroupAIBudget = { export const WithMemberAIBudget: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -398,7 +396,6 @@ export const WithoutMemberAIBudgetColumn: Story = { export const AIBudgetActionDisabledForOtherGroup: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -452,7 +449,6 @@ export const AIBudgetActionDisabledForOtherGroup: Story = { export const WithMemberAIBudgetInAnotherOrg: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -643,7 +639,6 @@ export const DeleteMemberAIBudgetRefreshesRow: Story = { export const OpenAIBudgetForCurrentGroupMember: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -798,7 +793,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 5e34c186d43..e7f5d29359a 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/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 65d592a9efb..b1fd6d89f50 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), From e5a046cea54cb1d7c5c0c0cc9e8e190fb0f27915 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:07:28 +0200 Subject: [PATCH 17/69] fix(site/src/pages/AISettingsPage/SpendPage): announce cost controls move in v2.36 (#27690) Backport of #27688 to `release/2.36`. Corrects the version in the AI settings Spend banner: cost controls features move to AI Governance in **v2.36**, not v2.37. Updates the banner copy in `SpendPageView` and the matching Storybook play assertion. No other changes. > Mux, an AI agent, prepared this PR on Mike's behalf. --- .../pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx | 2 +- site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx index 64d6b102d64..91e63fb29bd 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx @@ -302,7 +302,7 @@ export const SpendWithLimitsAndUsers: Story = { await canvas.findByText("Spend limits and usage"); await expect( canvas.getByText( - /Cost controls features will move to AI Governance in v2\.37\./, + /Cost controls features will move to AI Governance in v2\.36\./, ), ).toBeInTheDocument(); await expect( diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx index 2514f8c13a4..b8877849d27 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx @@ -237,7 +237,7 @@ export const SpendPageView: FC = ({
- Cost controls features will move to AI Governance in v2.37.{" "} + Cost controls features will move to AI Governance in v2.36.{" "} Date: Thu, 30 Jul 2026 17:13:56 +0200 Subject: [PATCH 18/69] fix: hide AI budget override controls without permission (#27654) (#27685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27654 Original PR: #27654 — fix: hide AI budget override controls without permission Merge commit: b4eda32a2ee8bf93d7a19db4fee925ee16815e05 Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- .../src/pages/GroupsPage/GroupMembersPage.tsx | 6 + .../pages/GroupsPage/GroupPage.stories.tsx | 53 +++++- .../UserAIBudgetOverrideDialog.stories.tsx | 75 ++++++++ .../GroupsPage/UserAIBudgetOverrideDialog.tsx | 168 ++++++++++++------ 4 files changed, 250 insertions(+), 52 deletions(-) diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index ff4ee823f29..953a87c7cd6 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -47,6 +47,7 @@ import { TableHeader, TableRow, } from "#/components/Table/Table"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { isEveryoneGroup } from "#/modules/groups"; import { cn } from "#/utils/cn"; @@ -77,7 +78,11 @@ 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(null); const aibridgeVisible = Boolean(useFeatureVisibility().aibridge); @@ -232,6 +237,7 @@ const GroupMembersPage: FC = () => { user={budgetUser} currentGroup={groupData} effectiveGroupId={budgetUser.spend?.effective_group_id} + canUpdate={canUpdateBudgetOverride} /> )}
diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 60d5f447a42..47f782fad1b 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -37,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 = { title: "pages/OrganizationGroupsPage/GroupPage", component: GroupPage, - decorators: [withDashboardProvider], + decorators: [withDashboardProvider, withAuthProvider], parameters: { + user: MockUserOwner, + permissions: { updateUsers: true }, reactRouter: reactRouterParameters({ location: { pathParams: { @@ -676,6 +681,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"; diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx index 3953bf5550d..4aa71c5fbf2 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.stories.tsx @@ -39,6 +39,7 @@ const meta: Meta = { onOpenChange: () => undefined, user: MockUserMember, currentGroup: MockGroup, + canUpdate: true, }, }; @@ -88,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: [ diff --git a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx index 9b7a292e685..9e3b32ba4ab 100644 --- a/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx +++ b/site/src/pages/GroupsPage/UserAIBudgetOverrideDialog.tsx @@ -66,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), @@ -100,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 = ; + } else if (isLoading) { + body = ( +
+ + Loading AI budget... +
+ ); + } else if (canUpdate) { + body = ( + onOpenChange(false)} + /> + ); + } else { + body = ; + } return ( - {loadError ? ( - - ) : isLoading ? ( -
- - Loading AI budget... -
- ) : ( - onOpenChange(false)} - /> - )} + {body}
); }; -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 = ({ + user, + currentGroup, + override, + groupBudget, + userGroups, +}) => { + if (!override) { + return ( + <> + {user.username}'s monthly limit is{" "} + + {groupBudget ? formatUSD(groupBudget.spend_limit_micros) : "uncapped"} + + , charged to {groupDisplayName(currentGroup)} group. + + ); + } + + const overrideGroup = findGroup(currentGroup, userGroups, override.group_id); + return ( + <> + {user.username}'s custom monthly limit is{" "} + {formatUSD(override.spend_limit_micros)}, charged to{" "} + {overrideGroup ? ( + <> + {groupDisplayName(overrideGroup)} group. + + ) : ( + // The group is unresolvable here, so it can't be named. + their group. + )} + + ); +}; + +/** + * 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 = (props) => ( +

+ To update this limit, contact a Coder + administrator. +

+); + +interface OverrideFormProps extends BudgetProps { + // Group marked "(default)" in the picker; null marks none. + defaultGroupId: string | null; isSubmitting: boolean; onSave: (request: UpsertUserAIBudgetOverrideRequest) => Promise; onRemove: () => Promise; @@ -212,7 +283,6 @@ const OverrideForm: FC = ({ }, [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, negative, or above the // configurable maximum is not. @@ -266,26 +336,13 @@ const OverrideForm: FC = ({ return (

- {override ? ( - <> - {user.username}'s custom monthly limit is{" "} - {formatUSD(override.spend_limit_micros)}, charged to{" "} - - {overrideGroup ? groupDisplayName(overrideGroup) : "their group"} - {" "} - group. - - ) : ( - <> - {user.username}'s monthly limit is{" "} - - {groupBudget - ? formatUSD(groupBudget.spend_limit_micros) - : "uncapped"} - - , charged to {groupDisplayName(currentGroup)} group. - - )} +

@@ -429,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`; From 525b75b1d88eb75308c9b358c018cc763cd0e233 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:14:43 +0200 Subject: [PATCH 19/69] fix: add info log level to unpriced models message (#27693) (#27694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27693 Original PR: #27693 — fix: add info log level to unpriced models message Merge commit: 7b104b6a9833f1693c37b6a0635fb206794a1b15 Requested by: @ssncferreira --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Susana Ferreira --- coderd/aibridgedserver/cost.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/aibridgedserver/cost.go b/coderd/aibridgedserver/cost.go index 9bd280b53c5..ab1c13472ae 100644 --- a/coderd/aibridgedserver/cost.go +++ b/coderd/aibridgedserver/cost.go @@ -71,7 +71,7 @@ func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBrid 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", + s.logger.Info(ctx, "no price found for model, recording token usage with NULL cost", slog.F("provider", intc.Provider), slog.F("model", intc.Model)) if s.metrics != nil { s.metrics.UnpricedTokenUsageRecords.WithLabelValues(intc.Provider, intc.Model).Inc() From da83b5e088d4dbdc466535d9526a89782f903f35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:16:45 +0200 Subject: [PATCH 20/69] docs: document AI Gateway cost controls (#27643) (#27695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27643 Original PR: #27643 — docs: document AI Gateway cost controls Merge commit: 3f1973f45c41a246d1decc18f0fba641f8a7c290 Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- docs/ai-coder/ai-gateway/cost-controls.md | 309 +++++++++++++++++- docs/images/aibridge/group-ai-budget.png | Bin 0 -> 15617 bytes .../aibridge/user-override-ai-budget.png | Bin 0 -> 53870 bytes docs/manifest.json | 6 +- 4 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 docs/images/aibridge/group-ai-budget.png create mode 100644 docs/images/aibridge/user-override-ai-budget.png diff --git a/docs/ai-coder/ai-gateway/cost-controls.md b/docs/ai-coder/ai-gateway/cost-controls.md index 2af87b1edfb..52c4fa13c44 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. - +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 | `` is approaching their budget | +| 100% | You've reached your budget | `` 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//coderd/aibridge/prices/data/prices.json +``` + +Replace `` 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` and `model`, to detect unpriced usage. 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 `$ / $ USD`, or `$ / 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//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 v2.36, AI Governance Cost Control replaces Coder Agents Cost Control. The +legacy Coder Agents Spend page remains available until v2.37. + +> [!WARNING] +> Spend limits configured under **Admin settings** > **AI** > **Spend** are no +> longer enforced by Coder Agents. To enforce spend, set an AI Governance budget. + +To migrate existing limits: + +1. Record the limits currently set under **Admin settings** > **AI** > + **Spend**, including the default limit and any group or user overrides. +1. Recreate group limits as [group budgets](#group-budget). +1. Recreate per-user limits as [user overrides](#user-override). + +Expect the following differences: + +- No deployment-wide default exists. Each group that needs a limit requires its + own budget. +- The UTC calendar month is the only period. Daily and weekly periods are not + currently supported. +- Users in several budgeted groups receive the highest budget. Coder Agents Cost + Control applied the lowest. +- 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 does not carry over. Every user starts the first period at + $0 USD. +- Coder Agents users who exceed their budget see a usage limit error in chat. + The error details include the AI Governance budget limit. + +## Next steps + +- [Monitoring](./monitoring.md) +- [Auditing AI sessions](./audit.md) +- [AI Gateway API reference](../../reference/api/enterprise.md) diff --git a/docs/images/aibridge/group-ai-budget.png b/docs/images/aibridge/group-ai-budget.png new file mode 100644 index 0000000000000000000000000000000000000000..c3461c569057c74ace59fafcd2de62fd9137232c GIT binary patch literal 15617 zcmbWe2Urtb+b$Y<5d`4{UPK@wh=72Ai1g5;gVG`;pwdK|^cp}!MM0$)da=+;=q(UX zRC<#ZiUdS@?-4@TEBbxke*g2I|Lp7RTvt{mGqYwUt32y2PoCd2&}5`POAmoS7`3(3 zjUW&TCmtv7I`nu$|{(8#W$!wJM z_DK}&1m%+_d4pqmd=DkezqRt6ZPlhw7W=7QxvZ(KeExujrSkre94);C+iJMkqtv*? zXfumjgopmT_@Ex_GYTXbBEOs?&%{-v<_d@P zu7A9j$S2^kXz={QC0lxNqpx>T@*buo%|zEPXUeYx4qxd~>V(IlYwVkoS6Y=vcUx-C z|DsJ~(y7@g=?+Z69~kiA?}k!Pzv7;^XAEZT!}z4(p8l*fczbeMEM>iL)iQPV4%8X6 z?hE_d+79~q5Mi)Q3!x-C3!wsAWZMg~;w`tnzlChG`97$w|Dcp@9s_Ash$CL z&^^>L_kuv!1xRl)Z6p2-u=AXgx}gd91dl*^QM7~N(w|STO_p)`^Gh=@VB|8i)m2RV z$5to&lKwJnY}q_I!K2dn=hM7;WarbQ)M6OOS=7sCF5O}%%zImu=WIsl!PN&Y0 zmq$rh3tT%qov~%I;+Dke@YG$ocm7*`fiVJM{9%4I{&)-5gnvuAIaKuUNh)b0?DymR zDIt{1q+NeXXc%expCyEh)NKDMX`!U;f0nS{E%>K|kkgV5|FdL-(d-ebF%>Vt#|MSdR$}c$31uc7Z#;!ISQbv*A4eLr2Y)B7OdnT-Pk7_>c#del8 z(X=WYVcPD(7z?d)_dGdf;G6evB(=grR){5z-HVnd|8Mn~78fMfOJ$n7Hrt z?eyVl!ZAc)0(?!Xmy6VbQy7Pq;5M1vh$n-#7OFEL+uwiJK#!};JEW0MppBa36Y6jb zIy9sXup<0aqNQ`f(Kq{p4xoiD8oW9uJM`r_EAjyj%?jTyx9_a$c=@-4KUcZqXc5lHW%!pP46M~`xlA|`o%j~Ve3iV*d_bW|~SKR7sh?GQF z|7?#J+(p*?czT(xDm=i3=CnfE99foC5BpNA5XaEok4(xnUK77MeCAn{zpVSb&H&%* zWZdFgCA8|A*`G(H8z{Yd0seJsWwb)rvU=sgK#o3o)w0^9?jo68a|9hml4p0UdiB*u zdLJE{7>lY0#PDRo?&Qg_%o?QJd$Bx;4N1qfI$rZyB&;jdFDm53-r!6h{#K&VA(5Z&(%i1|jYHC!|rjC!zNJ+X2!9VKo>#Q3ep=8$~}!5;0&KUnB{_na&8S;G)uGoiRvDV+YHosB9+W8(PTHO3H4 z(e>T;XK~)C?xBetQbR?1Oa(p^lQ##(@wt|k4P;qH8Px?g#a|Ds3W?bF1@r4^4>Jrs~S=+6$~hk9=@t@fY>7aFv#WX=^v_`1g)wn z`e=lPB&yL-G1D=ZHw7Mx8AR6R&!)>!CfNp>W|lnGtm2ZF{b`U>iRt8k%{bBt*?V3c zQZ88|6yVw2A+lHY8!Tj|7)_;ZT9s?`WlPNVWTyj}Mz1yeurR&mD23D~L=!D0WRB%c zg)J|T0(RSNGJbb_@Y3P_Hd@DFdrmaC^=R4V#FJYpa3|?1Ba}_Vj9yV`0dJ#Ul-p-(=q{9#_qV-J=euNFCWAC2j z`%2-*gGc$XO@)CAIhj9CL;F*Vb8m{{s;LmXxLJWa$q-NXei<|&8NcTq7?iGxr6Wxn zTdJ7Y3z~JpNj4$-_ERu7PRqMZ8OXi5tEZqgNFTYaUxvy@SM|vw{Vb-bPO+<7!{3wf z4ciQ3W`_6V*R%B_-9)i?@U-aI z%q1N{L8Evf8x*n%x~HZ}2Vey5>JS!Y16wx}PCuavVP1Yjplp|GwD zWCbP#C%@j13PRww_ZqRS?%!)AhA6alm!yN&e{ip?`78~>h0en4!h&u?=+1h@EfiYT ztSw#(>vE|vgx8KIq)v^?l=c|G??ke}x^ABiN{#+;h3reE-ka`G1<5e2kGIFRWpN>k z2?7(@)2rb=y|q$-R?o1mdhw!|;BBJ|zeA-LRZPufAhAHC*y#*x4NJhc{p1bacxS={ z)rex>#y(RtzRkQIZ35?l%_v1s#T@RR`qd*iz|pkP9OdEs?Y%vQ5o(k_DL?lHJw@a5 zVJvh&+=C{jTMa+-^XiK0hqJDO6!qP`lGqYIhoWKo)_qKvqQb1|)*io&f22%Un}=Qtng4@1E3$Xg`NrP%>J|L2#oSCjipV0?YdSe{#~n}96CyHnn+=Mb{3kz<4!584RBOTAH)+uj#FUQ^af$%qCSKi?fthg6YQlj$s zDb9si?lv7aSO=yce3STbaXR+RH>uZrQrxpwt?E2;10k`v`t`=#rQzb`bMOy0^nIqk z&`o;OO!%fQ2alH96S#jCYn^O-W|E-L%1{UxN|tgAZdKfBQ!y>QbAG-S1k8PQP6m4D zz16WQkD6=gib2=rbQbW5dTf_7Csz`Cj}EuNTIxJfdTVNZdb?NIk6UnJVn&Gsph&=q zMP~8F&(&&vFnh;ETZzTL-l{w{k`5bLClJ@0mWAw^eoaSx)WMh}t-}5?lSte=$BW#F zwGQizc##=;EGrpCYaS-CATS@a)h>u|j{9;pLd?#GKwPUAclZ^vt&=FcezhLIjU-I? zHI^$M|N0>3Iiq#IR33oQh4typR$coP`U|A>20Rme4#lhaVyRBJ&w(9pX+t!a>fgEQT0`=47hN^^vZ$e_rE~z-*r^KG- zmitR8?C*Y~Gkb9fr_H_Wr@uUxponBpV0wUj9!N!X?Sse9i?Q}h=lqiZ<^+L=GUg!| z7-mgJd#QyiLOB8iUaGavAA7R8ZWCX#gO53ADIjETlZJg$}`qHY*kR0cC7MCJ;pl>7<}Q zMhWi=m-3psEbV3$$D@~df1gD_XDL76*=g>AR(16%hfFN*%_6Jo^=z7LWQBo6F>FdsI`lZ$W?6k5j|^^Y=ZnOzSxHKn#qw!LtvIfa-Ft;* zHe9O8{12Nj0PPwE312o@`yMd3IFO5cJenD}BvfVBZlri|);*MHIhFLP*s{i9q1U?T zrm3SI`9x`d4iObYJjOM#{JAB@tlpPf5zn8I*p-Ch!Q4cM?z1=Eeka_Hn)YJ2VHp2u zetzdDXBV(^a@x1;7To1w_eOLbHZlEVlZyj3KK8Zt*l(C{64z>0{TJ8Tp50LS{bc0+ z)Gs^V?$NPc%&YU7UmU`b@Uey7m5LbpvW5O>2S)|ki83SfIEs^So`C!AeB4561qe>s zSmGL@jFwM`LqQKG(hMjQC9@kPb02qS^r^-ELkGunX8s#%so#~Q@Q>Z#zsa(CWkl^! z$wB6cO2bSD)r4k-IEk@+r-k0orA70I^8P-_$~200`V^0X^6zhu|5}23fIa^z!Bc?O zKg<6xR?-2|r2envAAbF({4f0a57+*mS$4BN=~Dk=^?z3Uzh~K`TaZzb%6~rjKX3Z~ z^5p+NZVe6lC=j%F&Z637)Ob}9z~-d}LqJz**TEerW1MXP0b2U<+^`C)OJ7XqranD3 zNCv|-x09>k?&ljwBm(38yuJ(U_DTL4<*tiz4&0wj+}%I**QJHA<8k*Ur`FfJhE+rf z=V6z5TQg34SevLA@h5U-KjMT%W=%jyn>{bXNL7(4Lhe4e0}Q|lk#B}%3kxIqsn_b8HBBI~o2U0Y#IfG$yX%w6Z*}2RyI(nM?r&{ZIt>g0B9PJwJ3g$>@@pBb?EA6|h`flRg=R%n)xnG-C2+5|Q_uxwJc% z>Mqx6)8Kzg@nGRYaO> zNw=?TZZtkLk$AW>V7Oc+c-RUQB66)4R51v7KUow@essX=_>LKzP3WqL=Lhmg20a2b zo9*-$ZM3bxxg_LxjXTrj?eAW_vM6)TOFX`7e)xb^xOx5Vuw{BjT}}T8Ht2N{*%y#-5^L_;YYCQf&l+=Oz9dgRFFjE#*jf=4$wXjk z0w#5Z52_vv`3fIbhaPWd3L9oLc-Yh*>id^WgjCP^v6=KlW9i1=*ua;45I)3Gc!$Dv zm(-(*{3_RRv7L<0fLU4kz`Yx;d;l$m|v8Ne676ae;>km z&861ZJi$FpAi66_;yO;cU9h?9{)BR2Eu;>DwvO^AF%)dQ4Lqd~gdgRd`8=#Tu$ zr-E*X8JiOU7}*UNE;9en8}MsG*Fctzt&BV}g4Zj!FU)VdyD1{9kUN5h9X6ACe^ij7 zeHOnuL3FhDoxu1UWL~%tJ%}=|3w2O^1eW9*)G+{~EfF26!74jem&lg^;ZyoGMS&n| z+Mkn6F$wQpuK)tmyJDLDvjR9#obS{}GRGjWic?TTR$8>l&K_m4i45!A%qe;k9|LgC zY3YYku=&Opf*HS_xoO-kx65A|$Sqk8@vomA#WmV@CM4n9l*d&qpfi5_qPz~N(e1I0 z<~@SFxeA|ckh>_nps3WRsn$4ZVjA!Q}RAJ2IAu)Pz0 zlia_$kvh~dd6IMEJ{1TD8u^gJ2t59SW^WtBv&t5>;#~SU(_1F)shr7go>Q4CNw+L@`I zW3Otww>sjl3PhX7UC~opHRn}yM>26CIZyl)IqEI%0kI){Eg&Zq(I@qwqs=j1%ogux zXz^UmZ*2wROIrD~%OLj-o_F?cb%)%fa>k)2(3z`{t&-3qk@CW)iUuFO6U#-MRP3`R z1Nx5rDI)i+uGMqF6(0BLw{7saEA`sGR`fM}-1n0XdY8>Qe=3O3F0);}jt^Sbj|WU3 zPjl6#_TfACRPXT@!nQ5vN6+)&i7Q7)YE=9t>s`Wd$gA$bFMHK*on;7pjlRUuJ+rCq z^|okPy86bftgMd^7Lyw-@2(fWd98eA+l$`Gr>x#RAQKC;&kbG|Qm5r}!Lm|s-K1(Y zF|w#}WBnVJ6>c9&!L?)EpF6P@$YKa7Yn8FmhAiTGl{XO7+=48yv6^*e{|35?DimV_ z!eNw**JVCwSrqs4g(W%ixikeN%y3GK+vz=JxI-v!r3e5rob#;)GIga)zfAA;Q}s3d z_MJS>ejuwKqY>1opc2KU(dzoE)GtvIh~*UtV_(4Dg9Iq%yF(UR1jg;iDJn!4LdHcU z`Xv{@!by@|AY}ZeMCd44Qgi2&p$*j^yVv? zKXR#P_4&F5l+%`AGZ1>n_`gy_#^k%NuJL-!_Y5d^!VvDW{gll4ha(kqRM^BFtPIiq zq5lm!sA|s5s#cbfiS}tME?GJ(Y0>5LUTT@tR&C&6))NXrVy1(SvjDUo&-}mR$CyG!n-=5CIq(C!H^=~0={*`QwoE@V!pjfyFYvVQG;Gy~iwZMxUE>QQ zO^CW{WM9yiQl1PnP`m_TpfI60O$@r{ou!(iCoRR)uH4O_1qWIUg1G`~RksCXn=GnJ z3l?@5W2xCSl*tSIY_v&!KY&G-t%FnXjXZu&Aw|!}#gN9OCd{O0rPNg_9sI^&WTTY_v@k_8q}aO-dOr+F0ea@D0jEn3hp*SVJ?giFg?A{9=>3mR%) z3ez&@j25@s6Rnt#hfR9VOBpwGRCP#5qDsxL^mkPzWROP^#_T5;h-27_FWJ73*!-ed zceR{?gyooJ31U|EH>E^9mOI0Pb=y5y4C+QlkxkPlp$kz)Vt9!Mj~(D0JRj#L?&uC4Nz zjLV*a1Lt&|Z{7&J!JjmO3n@mcHQ%WBUG*Wf`oBut6zb>WD@NkQ9+EgsxVI`Mvt7^7 z$U_R{=5!u|U)LQGUIkMhFihc*sFvho0`LV$?he>T3)Px{Yz>#ySXEG+f&Hcje~8dO z$5nk^|E2Xv3v-a%@DMR5tCcfR3w^1_b3SxN57Sb zE>@#qItLC-UEHH_90GdMi}H>?vN)$GT=kt!gBT_)ul36%Xaxy`OK$3S;RD z`E(1XUo99IvuMVs)7>Gz27Zxqv;!Qc0v3f2s5@vwc`p6Ftdx|Qr{Y2HXz43v z*n7(E;IE$n!GQ1&Gm2d3d8z>>D5+Qf&1Qe|PZ;p{z+OOZ$!UM{K1V+;ehWRY0vhIR9sUT?#gh5L z{sBHrPO) z`=dY=x{>f&7<6pRH;T4Y_s0BiYoRkRhpx0-z)e4Zuy@`2 zHm$YLF!RyUQ?Bw~9nvl%*DIsT9J<+u-fMvFaX1C-FW&%O3Rte{n?nzOc}V-MDfqN< z`{hYF_AIz^h*{kP@RaHDH~?a~z;kp2KeT=bjOTX#BrodhT83b_liH!qMdEQ0DtjOOkK#JNP z_==By8ockIL4lbLocGngnK`fE8v+g~T7EJ@_kymvj=g}yjZ(C2&5G<)lf2pT!&ctV zl{dT+_p{t6mqC(}sBl<^}X)AiZ;uH?cRZC$Mf zGP|Y0{Daz7-k|RsFpvw8^3WW#A-UEVC9|4C-B|_yEb$27^P5UL_48?vw|#j&03v{5 zSP~U&_LB<*htF9vB#F6s+OzUqWAu1kb5`&umzukJ_yGU?@7f~ap?8uyDSu*RC* zA02m}oN3TR2@kQHhVEHhxOM(HBu<+=^77?hKuuU&mCREpY7ip!$gkH{D+AgbSkK_V zBx0Fs!0kwH6I3l?e665Ch^tr6@^_q-sqZwYal@*9Rz;Kg&&aqDrZB-gyPaoe##iVzhEny+;<;02%u8Rfy}{lF4JRaP)PBa*UbQ_ zsG%R9#ig& z-Q72Jo`|ldQm?%Kt%Z(OH=)Q38^m<8JEh79sA0D{6H6+nVI&ii2^Dfbpnd5FHsD^ zZ5}e1x7og1mG+2?|3zhy7NJC>?$6crnEk;oL6UtO_Yu|t&bpQJlMzuqRzU+J^9qM3 z^JAS(aCN&GknHm2%@UdVVBRhHy5^>FR*3KNCbxaZo2bh{KVMP})_TMbfpCqS{fseq zLKP$H#V$aTz&}*y^Eqs+o9+c?EhcK>$%o&wlyM&HXf4 zuR=gbm$xKC)Gl7dm=O|4;}#%HNWW4TEvE~Q^pt`CSjb43KVavSFuB$Pz0bP;&I9_h zxc`O$|A7a;H-6hh|Aqu4*XZ}^{%>eO0sthW@lR;+udjcEzve?jXMBrLb91 zR%3v9x!upZpgke?Vgf4$GqZVImj;cRE{3V!Z<=b$6oqCNE^sADIlBD;JJk6y%j_{T zftC5jx<@GxmUk`#=X<&zNNr1C?vid#=i3zbpM&Rv*2vMv4+KPpFNA?(5D#kReC6?7 zr+gb$*uDE#rcWD+XlbM5{j7ONX^GH?!NhF5OHGItR_+4wsWUqaz|vJ6*P$_i#jBdM zq^mbKYYHLUtCznSm0S$_qWJgGeHg9K;*K*|WT6$d;EX8@|Lz^fk9B7K97ED!a z(u~l3-613FaHi*kx|4yVtt#nBta!HX`qg6;cGrXu0AKRwJ)C6+%X_%ilN;D{AJQpd z%g@m?iD+O?TnyGyl)F&9awwo^6~Qm#?njDYkPsvHo=8z%KX06NhW@KvLsLflj$fP-L|7{P&WifSO`%Y%y$fU~OwT3=u?1@rK@ka@ za^6XDA&0evT|nv^1fXSzQO1t$vhjO$Z68oG?}XTN;)M!2+-x;{c~;uidL96S!35DD zlksY9sYA6Ihi)P5sDLOTl5&@D z?s|~+DY7hwZVOUA&!bSst)*`tej5dL4UjF8Al=dRug+KAO*Cl^McYb#n=Z9MSUKfQY z48e1UzSnK<$b1IW+Ke~k*Sqb;Tkq^ojwvwB!_}_x0MU-`*0gPPq{|z2b@Wv_c}aFX zHhl|IL3Xc>`S69=Z9uj@(ePgHUC&VTTQX||B4!@wmHd;#dF3VG(J;F1lO|o5HzzD> z>j><~2k>`XhFV=UA^?OtoKS_w&l_0Rql4YXZS28PVc;Zy%4(ApK*3n*&(=M-PA9(C z2(km^*Y`n2AmZ^){XT#V9~bIn>!8xnwk&olNVTU79?htl_tgLvVGUK`CeGm|Pl4ye z<+Tygx92bN3`FaC2QvPG)CmBJ)O$GA=M84kHhG+R{j$l_Lz?nET=5&hDGLHZpNjglDz9=#xC8tk+x|V0xc6ut zSn6*F!q4nwh+}u+lx{SBVjNQjqOMI;3rW=d{OtTBedqGKXM6R{LtmpfhQPY2TB6Zt zngAcU9kVzvUy$mSvN$%3MmQo=EXwX-!7qPi>>(}Zv$ST?^%8KJJODuJdw_{VQROrs zW)tuO`-+Y=rvQwNyyvXB`>)agj6r+Pw#7Ct}{;C8q9>-*njCL6A7q_JMq+cwprVdD8Aj}9Uz z0imC>{;JoU!lW#_m{rChAzpk#)@iaaYW){LSgj3Bz=600AOU@miD9OansPmf@`z>{ z^*#W6CXS*vr#6Fgbhye{yvk+N0_OqHn%*&D)(D`im&@m(I7;+RSAPg@MO)T%`!!hE z^i}z+j7V<;fLw~)-S6Ff#O93ioD}SX>1Rl-&lX2N34llm#pkDtTp)F%s>GkYNC|l$ zhaAkV;#;{cwQ(3F-bnj>vdG-a1|sq4hJ|6X(zD9&k#&(thR)8)HOge=&_*SPzPBok z$o*}OL0jdNt4?Qb=f`i9`mUNG0*Y^!>1@>{EUg;ditmUvv5R6C&9G8gDXuY0mm8PW zob4EjTPOHpk?nCq1ummoU`4+80N?O6(DE_$OC*`yV!t$IOW^U3>}F}7rEUWOZHxO^ zaTd7-NC5nUa^H(7pW1&qi4LR7p8`O|#I)E#Z6a`42V3mF83+Fh7Hv_rv_z0Z>Cr)a z^DZGUMR>7HjWyeRBYW>Qsaar<&sIh!d3DamE(^oa@6TR;n1sLfaO&ZF8p}sLja$Nv z0B!*PW*5#Sv$j*!bJZO`RyV&Dgz`$6bW{NTLmAy87P}dI`#RA5LIP6{@dC`Fz3m6P zehVFb$&#}>^SoE@*W$$cP!V%|lr4ma61CimpjPU7d!R-$?3{ag2wA5qZANrmi-xTky1UI5Ve5en0Z z>WejVHsW@yk4Sv0le|A_(vU@-B(we!?Y=#m>b=iChOX6x)LE&xFS^9j$5OJ4im@H5 zW9|er`6Rrkba5W5oMoelX++WlSd@!BV`dEzn_R!!sM8qiz}mte-FA)c%&o~+x>Wt& zdF$CeE1`|&*BZT&+&8hP_TA;0RyG*_-!~A6_a_@G)@3;*?xl%b*3GsN?uKtYc~q&T z!=V|V9rj92Ng%_t{;O67D{-Vf-f=PLtT!+J_Y_y(QyFKURorizg~kNK@slc=$tgvT?M6gVM}oMJGMjc!^t_#Z*@Gc4_JHXz&FWc-ApPL~(HO85Y>g zxQ#m`Ri0pmED9b^(}Khfv#!e!`^N^FPWnXXp++9t)|Rn+Uyo~F_4Hg@oE9vG;J4dq zzIm>6ptz1NSt{&U>tvVOwfFZBlZTny^I{0)z2^x^D``fuuLZ+)R^@-acGIy$5)&@h zO)TQ)kOo;_MHUo*)2hD^qT88u^ui}tN1-ss)ZlngvD$5dy0P@Ua_8hL`30t$H4Rgn zUtHH*^rob>V4F(0EZ=9=Kea&IVuoctsw zL7c~&6nquNqsw3sDI4%B+Gii`(qmf>N)o%h#iK&BzZr0wqbQHF&>;Olxb)QUSe3I{ zXPbAE3ZhibcZGdy@;iDkAa@R=Sex2q8wmHPCsQajQY+J<6BGz}-RbnZM*i z^sQ{+=XEi%$+p;c%hlswQvIF|HE+IpA#e4DMzOmNw!D2r@a~qJsI=45zy>lR z*3Zw!H6Gs;^UHj|0*sL@PD4m+LQZL~yr*4UVW*)EC366J^@)uA(xQ5Zve14}M-r-( zC?ZNJ#0|4!Fub{3X!WeF)l1?Z>BiT)tGaYNifXl3|M(@loZ@i>S^yp{Bt>lz|z zzC61+CiX*(dL&WQvIl;nQ6s@eQwgMK^l|#wu=JCN03kkmN#f!gzXva!ep+@T0YQw3RgN zsmr=E0lU_jl!!&p@ueLVf}%ll7E43UUruUC5%~>t`JqDbwJ=vnordEkcP{+He^ks-V#w;ujJ!xrV)C)&tZjZ$__$BOE~qteOIUlk}Z7* zA_3a7tYPsLy-x+wW6K0T(M&Q;Iu(Yu8KO6a7I_N)#jd}Q+Y zA`2{Qn&(<0e=LL#C!hj-z!0OD2X{y+C$vYKI5=D;`nxYbQtqZ3al@v5STA2g!*0m?i5`p8`J4|ijTz@kz`8ctT zJ=t78{KcWZRYLknEEyol@Fifxe?z6gtpmO$v*}PHJE0!iaj4Gj&HnE8E-`7(nY^36 z0+ZuoWk92tM}j|dynFmuPxL|f^9a6ma%Uip3=@0O<#XYNiBsR2P9PJCmord(vtB^P z)}i^E&Twv)8_rj8Gr%!HXHh3TKWq%eQ%B=4cPW2mWR_%HX~(VUNDd@0j3m5B-j4?6 zB#t^@+YH>*Y<_ZROkHWS)=}{}CFxF{R8=8^ccNEHa?&&%lqG@xs`c8|fL^Fs zeq@Syc9#8@%CRgvNZooPQT3$9ZfOPBo*;$VGn|w+5p?mV7eJ;Zf?_elmx5ZV-!MSu z+*O)CQ0i^Vi<*~5OUDn-F2_5^Z>r24%#7(dC)YLR%}-{~Tf9na;vAT^cZjUuD0-8? zajZ!VCZiQqa?dtM5ccdY_1c;d!ouDixt5t!+s*FyTi+s6Zws&=vm)+}IhP2~yfk=+ z6MCeUGrnu>*mo7CK@*>~HIss_6&?Jd z3o9cuCpFEazgzVg{s}E;-EudLA_(6Emj7ImPG(K&8}3(KFt-mDRVkstwi9Z_*4VGR zt{4+fY?L(6V+Z}L^Zj*npCQxqempQFTREy8j|mcNYE?H=O?2K7i@S7J96!(h4Z^aU zBD0=g!U%mDT`%#&szz1d1pW9TuU0-pGUU*N`&X83s*p&i2mr*V4vY4xlAoWkQ!s51 z4P)`P*XK+QAF+=kOY`K2vOm0rLUtCBA%9RYvYP;yga*Xo|h%SpRuhiVAi+` z{(tq_8a3hl^?CNqcg^jn#Nj~cUBERp_U_i;^t4=AzGU6Iz}D!mtZT99`|gE?aALoH zOQjek_l3+`N`>b*dZ*ny8yNT!=o@RI5I1XM+`rhS!yC^hdR*H3J6=7R5V!pWgYHtl zXb_YC=4bkI5do`=z&gDyH$#?%u3bboQTy*$D#i-O-u9CsQe+vNHp=ewQ$G0YTb0<@n;fSNte{tFi=OU-U@NfNK%lTbB0lnoO2M6oD?KT4x%JMz#$D` z043*Ia|w*6Z7RaWA9wC*#_ zV`p!=IlrN{FsuNuaaj#Tsg47C!($4Q`N`YoIlsB(;)#f7ODmF#Es8?aF9S0Ly^Y#( zT<6aMuF;en=^oMtsysHV12@!F^k*7U2Ex8Dq~2!oUvjPfTEe+1;y+hu@U7@gRY+KL zq6twuMeK^-g&CnG;_Krf-P!QxBCRkEJ+*#Fc~1Gbn?Jn+Sw^LLAt z7z(-x{6_+Oz28CpQyVMu9oB#Lv2O#%K$6Bp)cib5<~L z1A)kyFki5u7UOTAkj6$vT?g0#O~8C0n85*z49phu-C>6fRKEj(e%w-&k<{^?-pUS} zdD=bRFfZ}bFNMC1fp-02n_$YFe0pOTto&fEkj=Qf%`UoFKe1F%g^#(K*>;TajLJNQ zt^S_h6kW%I+6-Q+BR2KL&u+sWvlOCM$5XY(CLI}{CN74ruh5Fbm?nT=2EQeA{L3e8v^a+Fi+nm|H?Brw%L z{emOd-HIwHX&}VGmX0u{2zDx)`R<9^>m?>wsWYYk^zqdiF43Po82rx$1i}8NhyE?s znEG?%KYj8~k3nJoqpz@W|L^RuI8`LP5)_1 zAuiE6_P@>gTNnKL?*M~;A%;$~{nIij2%{WAjtpdLz!U?}rEr4PwKlufWuOU2!CpE8d!Eh;3sOLfO zy}#wpK0Uk0d#_jZikJbIKhfn+D_o@B*&+DXr(=z*%-sf2ejwKO#PQ`Tf|pUiOM~Av z@qWu{#z0|S?G?H%Yv(t)t={ z!fW|GI+T!(*kcDJB)ndj6c=@t{lvwrpP$ceBKjr`D3S)9mjDWAc^RL^0#1A=MYWiS z1Hxf|#kS_M)5G?DeYw>ur^tj1h`t|S((f5`J~O{3d^70WsDCv#=>BZb^(Ep*-dFr* z(XAJI18l2e_LI){ok6U+-@{Oe{aGg;_0u@UaU38Zd|5ayw!(dsAG$30wF{51*FHQJ9K~y#ZE-&r$gAzROF*`rlJ8)Q`*W3MERv1Hz48q={i< zOgY_OrK5nzEHB1M#0q9~UoXVRjrZ@<2?Un0YG?lt0KMbD z4o6*n041zF*ALqg#X0K5t*Jp8ROn}Tci3T!PZ-5Rm}w4X%`@=C)CKG(yuv1nfmPo z4=Mps{^bkOkleCZ(uzF0$rdlCgJ8Og$G&IlB?T?$W|8Uk0Qs>3`Qff@l&@j6nZ+}) z;Y@y!WzidVM58Du{w_i+T`d#%cPK1kRU;A7@TJb&>x)&=0@tqagr4UCtJz*!f#|7P zJxTod?x?gvhvT%43nq(fCgs#(jt+GDImxl0=T>9g<>l3J2Vte(w9N;e=HP6%At9ez zL{0H;`Wxm0sb~H72Q9}WL)#4*eG`uwoPT{;`-C(Z@Xi?)-H%pu*mBxL8rIXhe2uyV z!mKCoyQ*;Ov9BI2_+BT8p@J;UUg!ifSo`suU-3my%wVPkBS_GUOs2Uu_1zj+Zv%H~) zC0};Yb!uh^I+>K=#{+grCg}Qn-i@c9X+y_r3cIhyV!%z{U^y`bcmkfc`N^tT0uNw- zwZ)BhO%IWywH(8Pt#Ih$#+%V&jSJ?F*X~sApKVr^aM1B@pk9+ed0H>`hJ&s~yIhis zK|=0p8qU~~N%Xd)FZPzA@&~g7S*SMJK{m6^st4;Vxy2ujFVWY~=A}dfx!dBuKVu=R zas$Xw^HqTY(K3KS)*=I&MtA(MF!n_dLUoPN1tR9MB?t)v{f^J83FOGT(H2?0r`Aj$V-H# zu0JrO)?nd=r<%#(|r_uv1J99t%T-)GDnmudT&Fv$DCc3b&zgIwvQM~Y8xjxu^K zK!t>qiO5AcjnjDH(4$?c!pZm*8MVPD+fVNyg-Fm>;%Zol)mEdnBzIYMLeI zULBM$8$>?324+k_#T{cZen%9edE3rR16T9K9(#V8iSIXeVH3B(PokS}kdS7E!1h9N z+7Pu<>xta_W?B=5dDiJ1DvVP zvI>32*IFf>4cFemjlWAe4^OFh%K5eS$fAP6$N-n9Th_>L+rtLs{56J|nO^6B`1fp{ zk`la;4rGWr6_?_3nxiiOX%Z$Fj+v)^sN~C)PXr-oSR79mqPV`xv)ok|JJjhHsA@lZ z2zUm-P<6R&(MBP0@-ui%UgAviphV)5ZQ1ywSK!<_^C5rN|gCHX_2fr}L}hH3Wu8KQ>xI|qGrvdVqw~cpK)Y2b6&%^hT8BM~q2j;O z!lJ6VX@<%yYN#3O2QKINvq5=1o9V$6{p=zP{I zh|6TB*~y|@4}{%^*Y7qgu19~vwl_8#UkZ+klb7}eWb@y+TSmWi<%w3#59ExZ~w@$4%-FCp!#4NZhcjZI*xl*a!x1BaUZ%k=o?fwd*v*d3j z+j5+ZcknM}hu!!8-T9m&^ijWS20iv$1XVyBDqX!{ZDs+jH^t4QVvoRXIGZKV(bJ+f zC5pg!V{JGn-;^2hVS%?FA%&8`a+*oZg9XK^S@Hzb0ngFIy1;{4C`NfjY0X+ygz1_1bYHCWII>zYy3@Lb)mxw%U4)SA zpIx|MwqvCp`tEh%8%T-6-(L+6KT1h3l^fK0jy1YyGbqUTkU-nHN+Px92hXzb&{1u(;Q|gH1cqXaQfd^G93>; zO3u{H@1LI+T77#LmBtPmi&~^0Fg#Cs5}Cov_05J}D&}Vh^S$IJG(xV)c!5Y1P2gic zVDSxW`=FrIFIz=eAW?od6I=83AlGcO$13IlB;iY}u#l_=?741ix4^D54h=ZQA5qC2 z5+|L6gXsPnE6r!j9vfsuCg4~*FnvQ3BiNNOSbDb9arSKa8C*eUsriG}@oBfmHX8vr z|Ca;hSMiOqdBBV7f9$~wrZ+WigI?Cb+rgJt=V&W3D88?k_WfzsVX@;(6~>(_)ImrF zcDROIl2KHa4K$@fUoVileG~-L5^_&6OG}G zucq=4Zj={>_4fQz%(IFw2c+N%){WqOsf+0itJ$$o$HzH5uUYq#$;bk) zJWb)agEw;d9;DR7texO9dj$P!cnH_40Whe+es&S~s=rVA0tDs8Tc zvfEOxLh#=%b>nE9KxAAJ3>$XT@;o*@4H`Db%Iu~+clMZ+bxR(p=fC(Wx2y%fO)Jc? zY-pi9_l$4aA&SQ)r3h5bomhWYX$~t>0rBYQ8EMu+AlIiHk&#NFGNY_c0)Kh~h=n+Hqv15NwMEU# zzrunNj>Rw1Z{R2^uaUh5`2fAB66iWl(9cBLL7e&Sn`+WM5~ydFl%rlo{{UA9%_qlM z=0^^W%;5ZQ*?eE}a=2AFm>ND}T#~}4b#w#-gDu^;+{jCiB?+G+B8-6zgs8C@Eq?wU zAAi2_q`f4(Ib5Q z>UssDBz&1{dQy=BU^r;EJ7oCF8}=H5n;Z^}WjP?Zbs zfR_64^XI5<>9_hPi=3i)1kNfgK+6vas!)!=wDLU;Uyzt8Tzoow3xbW0vutv#Y^`gc z8nV{nR<^YN3IH*}*m6PWh zJ%7E`<*;=&T0AHP2Vh6WED(ZdHNoJAVVAYQ0T6npv}F9qb4Ii>2A2Oc>Su@(Rk{Sc z>PRM-@6nds6_;Rz{hxRV1BZYURpI)?`Hh5wA&%OeDGg9}R)0P$pGw(R0ZKCi_DjI6 z7x0Oi*wB;J^Nj{?j)JCb9KJVG3pxsJuF%5(D1!EIW1Nc^z=huahJzVrO zZ}0i7hG>#_P1r)t57zB!3^XNx+b5Dkw`X!+T$$8by7e~$*b1MG8{z%d&MtL&O#A-; zO4th3IN{W*+@4!YCxX=OffEM7gY!-JL}ve64RWxnv?yDioH*d_+%1{?VAvoIxH5l2 z4G;{T{1#+T^U~wOuS9J&f3EGk1djlCroZqTV3&x&JkCGgogA-^uFCAMa>>Gh*Zv2> z!;K#yh63JLRAuX)?f!X=n4awUFpLk_Wv~H4{`{DV~{p51VA}~Lp12O{;qFIwTiU!lhlMW&RB1hON=vkMErZ!7eP_1 z)wjIgU(!@Ot?=x8tGzw#kuMn>Dtn8ZBaXAfUmZX#JS=lEQrNkoN10I7-=eYSy zJ?+AlgVkF^ME#q8uO&6Z>JFyw%yUD?q%p|n(0#J=YHkX9rdy=+sZ=%VXvH7-Va9es z`=K>9E~bsZn+Ggj-zD=m9Qhr!(?+@F&HkjiYn5|6ym%F)2FWUAKH#&ih^BHF(2iFH z*`|6_$Pc7d@oo+*vyC>BJ=FPrennQ)?nVYZt$XGF?f9AUo>70n3?G&YY`Yr3BTQGc7mNjvWUxRf|oeZnOnb3%l#oxh#6};;oYDZAGYwd{C7G=i80%F3WlT{7GQR zGo+T?j5EA^vEM7pAQvsrixB3u8sbG}I2>5?#$K*WW(lGnAAdA#$m#R|f)HQt>s485 z0izc~LHjG&2*9rvObQF#$(rxck+&_#6DamD{A3&TZ>MWz&i@A1CCabra@j_&Q5h(U zbiGO=rc7ZE%V`cYPh&u^Z+GNjC*`lz?*_@t`Y)ja?`Xv@P<4kE(1FKwt1An=>iXb( z2gc`8y@Zt4O*s{_9UEqo9}8Be^JOc%X`6JvJ=QaBk~=I^q{tFZartlvMA{A^nqhaW zdcH_XeWqaLDV1Jjc0p9=b@1(0JIu`eJOkdg^2~8pg!bq}(ho}hjo0r$HUOEyamPRO zwjzbP+X@F?=*4TCfrUe0%4T*QA zrpI$zSU;Xy7=!;}q7xPvpY6Mx{Tl>o{Gs)?ZQ1!=LN^2{$2XBqF){oN4c>FD-Piny zRs3ef2DPchy(ZwvGh?xXcIO{~OYKmHzXDX~~lUuYmu4q#*YCaxx zGPgB76C3NX&pMvD_s@C>z;0}~M4DA5!6#l`A-~?bJs4 z!u={AykhvZ(8B+;%H%6Yl6Nh`jSX3(ldIu~>*WsB_>Rv8`e=Lj@pYAEL8>`+mBM?9 z^O+Dld3j#a-#e8O4%6?wGh|W&YZ)ld^m=PHm2?z7hhQs=TrD?S9WUP^x~$X;vv+Ws zZGO}lu!efCFXB7z%UsW`r_Xkt9yE^+d;;CJFkNnZ?^IY|dxzYj2c^ zYXO8r*QKEs(N-hYRIO>7Vr-g>)`moD z-TY~(@^+Ca=?6IBMmYx!kd;ecR~)r8PBAU)a5;;bPa=4kDyxg;##Qc|dCQ(-@(!aN zPJz_5m7&0&?Exoq02c+wLX6=ng2MbM%5uccdwSAq1eylk&%|-TYO3w0rlY4SPywe` z*HWC)iV#M&{p4u#M`_^7j^)^-fu>Mwg*Bt6PCHlQew7_$KXz_D+dO_`=vj2J(-S+1 zzzULqhRE@6`4G@NdD1wvF-|?`db!!``6k*<@0~t);XcD%*I-wia7b5ec86hA)4lVf z_BSNNxDoWna~91`6U*P7%`X|AieoAIYu?5}#<8IAr7&s=UzX?@?w(f4}P-5WAdEp{ni zqeSZ=mUM3qneu!OCpL(?$6i{5H)K);uf!8ocO+8*Q&Q_Jxr4kS@#jHB)89Dj6@Xber|4?JHGu zJn5*w)fr`^UlAq4aBtJumSfMF0Dipt$~!toPLyZQ^mgReAw|z<-aa~hTr~3OJas0$ z<0_r|?p<@(MYL5$-&1%g=l1T6+xJM_89?SjaQ@`qCt5kZhq>$eQzxSBRus3jtpmc<06_ zyM~EDWg;-p$m}1t$!2`esfzS*S(Q;FfeN=T=%N;Tt?KvAi&32Hc`-(V~xMrDExsF&RkL}b{)wQpT{OWQXDtifSxY( z+-@#nhHcT@S7K)`csfrv*zTi0GWHPn=^qPt_npa_xq@|i-H>Qhax z&aAbB(ud87PQ@k+;7~pDa{tn9$Ezl@hI!aL zw?SqY>ZeOb4x=#YR0=hpv=i3#TI=`y54{De#p#3+ZQJW5UeUELraZQ;`7;?L7j|ZH z`-UgRPj)GAa1?rDjkTEZb;!B=Zc|=e6$YH*5()0>t+fy4v_0OQ{i&9^ZqhLA0$^Qk z%}%)SI^YGn(D?e_FR+0|tFhXb82HfPR2R@qz@FsnC}#d`C{AUj%rI>yHgnh3=SA~QdT8~XZLx{XvyeQRcL>Z|8=A_LfgJgkX49w1vk3T1x{=KRa3wy z<3bd|S(z7PNEn*Qjgw+d<7dbi=s<7~&_GuByMsLt_dow(+=l;Rwg33-z>z=hD#jTG z_W!PEe|G+_@;`0|AS!_WpUN>#Kk)gl`v0^1kMj*w{6Cgs>;UljuloP9{J$vuQu{wA z{{PzKpCQYINoujc{JNJ7#6UbkIk8VidPm#00-H)e8qdZ)7;_!vct-|Jn90@+gpWyZ=L6phY;?kY68wlWa@>#NQC`|1!c%(=< z&b!5C)G}r2RKRA0&v!9KaN+1+Z-7f)&)CDG&T=sG>#FNx*%1GeUtyX+fEJe*gMf;s z3C{aR9bxP)>=%A~PfS+#>R8ph0jk866^0*?kN=lBaNW8n3(Dr zA5LA(3E%{RHxdXo5K2Qn*&$VLbB+(dzQ&$nqb5o<2X3#fP-7cnB32-{>j~qx32y{? z0^qW@s}z$~G{?)w_nSj42cMCz3majSQn}gyTtE^Wx=iA>Q5G{;t!!C(qF`)Ep+*YMU)6MYaG+BDNWgZi@Hvjc;RgR; zxcY-uq_F2!GI?AQ0Zxa2^^eC@4gg!~6#YA2lsbl5ko#4=tK_#~i|WL6Jiv}`{#vc@ z976Nc%J5w^lGBL8z!_FwyHet3aURe znedWdO&Mr_ZLS66#d4l!KV%1+q0@4_8XT6sPF622c-HwH?{pmkK`dp!Za2klbB0m% z0nLVxZp+}R5g^o{;dh#YfFS_-7W7^N-OtBAgW5QpFKM?jPY*#{UxIFQtj=NM+G27;oLj4 zLn;>_Mh6C|!0L&{@eUvwO{fX1GP(RNEaG+lanOK9{meRwm)7oR>kTs}eVU)`W=-J+ z!gWYUnm1<=4qj!PxCX+%0>z{+$VTto^2tVBxOcJnd&36)50#(h38*;qJu+NiymOzctes=Z_~TL6X0wzDa%CbZ z&P<;4W{R>%{qh?M6I{+~WXBa*)#Ai1phL(oOK$~q3N}}Zra_k;e6CBSKY6de(#<8U zd0kCe<WPIb0Z1_FV2K*sTdTuT%Zl+ z)VEJaUG??*z$n=RN5T|#SY6IgiQB3pb8STIuOrv(`Jim>Pyni-98BjHL^!(=`~*&q z0`S+P-#7irraZgC5!I{-P+ZF7BjGLnz9*hlUpsCbV_6^*G)v>g5}7IrM%B^xqGE*H z$cv*HyXBT%QQwJ*!9jd}6|6!y1_a|IW~yxe;*Qxfh*vy9~Zv!YLTG4?xqBPV}?l$FOHR^F^b1gwe=l{hGO@#&A_i}yMnKSv7X%AbMQwuoy zG}(T63_ri~dmal|S`}=*QdvA@`346EYqe|pdbR9`Rf4KFPtjh7jWesT^H1}!+~&+( zuVsh56yR%FKgFwpZx9I1*wT4Y(+IJ{*Vg1X zWwjlZSLu4=S>+KrsGc~ulJ(ni`UXcIW9vTU`o+&+!i7uG!!hlil+CkECk0}rusgI$ zQ#*hTh^?AQyI4gGm1-#`IP0z*d7|rCIg~9z=U5>!$kVVUSna;fbK4Ve?--Fev4Hg~ z^zSm3{7KkZ&N8=Mv_4s_+`6?@8`jlf>NzE_pYP>I!PWFKu=OH8%98PZmBoN=XIlIG zEzW~ZpwneqJWEOl;*{ejcZy6 zM3G_KRhZIY>911_v3_SD0@Sa5tz^c?pQLq7iZ$7+a&jo)wX=|HQu07r?nca_zwyVA z+lPh1v;fMb2I(>hlgr|nEif8op?C@p$&tTqKjS1;95zn`u>}(OHiI;W--}Q&EOvPi zxOCfc;@wk{f|nkkgAS$vo}2n|+bJk2&Di^wW;GMmoa!N41Ho|yk~P$cX5AK*;g#~=!T;r z-$|T5ja_$}0o7GK*G**O$n|Af{fuGd{k+2OS{c|1lVPad6w6Ybq3B zvdO`z%82D~wHNxzW`M81)_r}$8h{iHF7sxa4>mQIp2gjwsKm|56_3v|-#~2%yWYed z@7Q#;G&ep1hy#wK&o0K__lTi96Vfh1o_^!ToDdnYG}r6+@V&+0m)6t!-^UwTpFkLw zGJlCDHHpVru)+9x2NB5~BEghD84oBpjqYdr?WFS%L%rrN+v>LYl%7XjIRznI>iMnF8Ab|~@2?X?b zyTX+Nv5$gpmH-9=kcu>n_Jsz}HJ`i6W?20C@`i~X#P{&JD4iR-9`$fc4Nym7!|SnB+qsJG0=V6G6kD|;WnpNtv{?TMqKLrc>V zOKI&!w z<0WyLf3=6fTVI`F-L;Fvi(`Hcbeu-hAy*#xwA#X#%EhDfRR&n1Ou`Jg8-Y}VBXwY0 zc3Zpuj-3m3__b*BF-z`hH3v@kZCw#9&#sgYR@Kukin#Id>|Nh8(z*u5H-icr>U|=q zpK5(PdnXwjU7=7Z+3osDEA>9vVfzXLEM$S*{;jM6+_QSlg1N!`AS9?UR zqhASLDnB;w2*_q_I)Ay2x|-8Oo{2k*l~%iCYXQR8KzYbQ*vcaqvwC|U$8{%RU^+95 z)GJ{rkuAu}kWcEDNx{WWQ~%v=Xe3LuY_BIeSk5<*hpWj^UzQ9ipvKCF|8_u-xr81rAU950Pw<17#;W9xEt-~Tv!CPmUp4};e)z2 z0cv->WBs_9e4Q#XY2m5O-B|=4MU!At0ag<}=4G?PP$usggL@Cv$OH3f-tj zu*8jKR!CRmqb8|I!q!TI^LPanB|PI12xD?HQatHYG|u7?Oee^-^OnW$5tS|Ju$X?K zPwtAiL`8=O4b%em-kG3;DDC>3D;3s>MX!!?W3%E?z-YuYmWp0q(2ZdC*;TcrHqNF# zGF%K`Z=C4pG3rlbQr6!2I+Wc)5_b#;2+JlRT{k?u)_zng%zyl`|5g#cMq3a9b^PfU z&Il$E&fy^hxb7{HX)@^9)zbbf!aAyc^c)s613h8%`UUXQjvoVy{VcKel4?|+C8u!a zLfzZsNqd(9dr=&^U&G=QP)8W?s5cE$+{sds`bZ8AP7(WPs#zZfpv6{rb&H77?)3W$ z&1ncD1qBDec8kk{DWi`{ezACzSFS=ISLx>$jD|`IaTLm149)$cttBD_5EYk!ZM%vb| zFiidO?ar6peGoK_Sd_mmViMUG7sR2H%4Bs88oFxpxcz%%qGHMd6 z{MGr`nnp<1jv=ku>DbK27xHpyq&LZPcnj3a0F-e+e?)B5?WG8VtBaxkR&}o{IdrB0 z9gb|-u;^Sxxd>ih}Sl6Zti&EsqnV2H-LiCT{4{HUGd3&ax@=*-!1&fj4&M z2EJ=WXKH9cy5i*79KZ`XNpd+`t>mX_FdtF8c#R)^?GTw)%aJceT%=yh^*#Z7Yt~Kf z_X+QQYIJ6B6>^K-%=EyjJC=N{{^}&d%r=q%mOS+>RyQn%-_H8n zO_Nj>-e~q@iK>vpNR}8HNX{Yg;>~pYX7iG`7m2=ru57mYFXHN7a&Vi%?kh`?vN6rO zO8yJQzd6f6cuaiN$LvXAueA1_>8%*W3VH3I8fo8jwaQ8^C>1zhbjw$MA4V7E8L6ZW z^X_09E_s&DGpnROhkVZsI5J4`6q)632xyHV;S0eIuW@=&$N-qZUJZCDOV?o1j#b_d z?iXLS`l1;aWcs+V?W4r?>F}s; z5W4MnP9}}$_{_dlcB8gh^nUfn*S!?b$fCSF{MJv)TtTPG z_N@lQg?mw^7d%CkhIQ5%Jo12q2Y@*&QM(*dHIQaBU5C7JM!`$bu(@TRH+EX-Bd-;* zt%?HrMUdIYEEM4USdLB4!d0KH@cr*b;#4?wovQ-Wd0ZF#h=m0AW{Q#+ttKRo$A$_^ zX|r0t5v}5|cRTPBB3X}r{ft#ZLLCHKcaf! zuC3AH20tl)QgQnO=m8*stn!nw_fX8}(hh2wv7uK((hdYn*jtK{i4or00M?W~ZXlbW zc=ne=(V|Yj^kDYStDRLIE(dV_Sh=skL$@$r&x3`AyTfeH=g>HARbNel{q-+5f3J42 zmfuRJLE_x zTFg@0?wfbon>7pT{ z#U0uNOZ9BMI>Vct!8cW-!Pv*INo!dxz-q_g!n@ zr}Wb2NY)5c-x71taczJ+;QOI`0^ZxD&kNuxGQiW|t4n?fN7 znB0H36|HJ8#5x*|@{W~hTUBA%!1_aieT$i(0uIy3JU7F&+*{_gMP8L`!`DVj(7(`A zFa|BgJ_n!Qoj0{(ON2bkUR$*L1hI7%Mn-V*2{8^T^m_AGkkx?h?t#l%CL z0}JN{z;fC4HZw@FGQ_ckU5%)@QvGi0X=w6vvW!7r5qDBm!#1M9;`-`Rj73syiP5c}mUn+1=k==S&pjiY93gw`3L z|FF2JEw(k+CNemwMI*13{g7EkvL&#dZZMe0)hA*$n4D87Lw3%xXQXAbILoo06&9<) z_l&K?ud?1=o^ZB{-1XP3CKodPTd8uZ0``rf%TK5%tILM@o!-oWsl>P3&fFwbh%fqT5 z)lF{=^7n;>gjCr+8)YR6R`&q42EfR{O#0GOqh5*BdM8d|v)*ZX*@+KO3{o9()XI7& zMYcGWgDYoag7Ip4Jvz#T4gcCo9V)yfA%L3$I5Ox57WKAVfq8L2k14^oQJmv^_#>^v z&(2&F72$kI{Mu+_1cG7p`V&Ax3+*3;&;}V`BB;DM)LyP+&pRjuMNfM70e7O)1m=36 z&v?;$Xm&eo)*D#w7&VV_c4qzV?MQ4f>a{`!8nhUz!mhDS^*_(~|#B<^SK&l{8Lo-ABFwxf7}d2@LYGCAooe z?)_{}t-Ub~!iW}=Ie--+YNFDZy6tquFu}0>bR}yING**hR&ALKzf1K?5do~^gvik> zL^v@+8R0()8WhDZ!4|U&vAHe+h1O{^Wh_D_2 z0+!m!>mF*SbdM613@RgW07&O+_<)IvsU!mZoi8yd55B-deZRg*Kt*~9B+v;+DJ%yQ ziicxzCTju#cy#dW%cCQ~7A~*(IMKb*Q@<}Ec+8HAWhFUEB5^?SuHYGvf2e(Q3jCF? z(5qr|0oVBK$FBYCq$cn?G9m+#kDu7iUqxGw7Ske@)6fxsNRq>W`m)^`p|pQpY3wHl zU{O!}J3Na%AOUM{4M_cVueyPtu7K0n7kgN?ccfhw0Cq=G)ie1Xne1H60barhAYmxK z-;N$F08sjvI3GZxh}fFCSmIjcy7=kKgEn-4go%8m8I_GDqOY$fhPnV?F=dgaLDC1n$r{kV3tx z!0H!3^WT2`vdY1LZ#iu8ysP)>5@|&WJqrNXm6dA@s|9dz6kI?GZxyA-$Mal(`T6?# zW%`tt=tGyEH+X%uMgS*HB8l19zS3rE@*83(`{n{7ZN!xq3wTCgyoaG&2Ojmrd&cbf zuTOmNI{-4GyImNsTt&`)cToi72RfeMo|WYJ_4 z_MRQdk2InNe*XdC#=AiYc;)8n5A65`Qv^%%>O z?oD>@8_fJ9%3)F&Wt__>hjN(JyxXLhQU^*QHc~$z6KZFUzPu>}{17zkqg>(@w#~#=|kT(GQBm{>^E2Bfx zf|k7^{)7pTuxW7hop%^0zkOen-nuVLMvfWmdNd^sL2@%$EHTtMb!UXcnvy^YWur!4 z_ttH`H)%@%Qm8TV2w)X&T#OZoeh_fJ-9TaEci7dk?qgVgo4O!V(-@Q3ekU@d@V$0@ zUv|Y4NTEsFnau!Q;0c9L5m~(~gBSqtxTZbC18aSF7E^cDz`&jl&qFQH+6ROhDo;hhTC;8p6{#dqn$>1SKiik-Es!q8g8+2pzA+Y)_MeAQ zsugC79oA5qv)>$eI^gk13tk;_t>MC}+f-+Voa49#5RBLKzkn=e)2qFNR_t&yDNMZg z{5OD-0(!Pxx{Mk%Hs(eBSU8`<^xJ>0%0gK%NyFOKsqZ;!KnYLAUGW$tKGXeqM_Ot);dXrhySu76oYO{vOsWPFfE1?YotoE+PmT-q z6I;+%IR=5_c@;`V@H&T?N(G2aS+Ij5PyFg|u2X6Ukfhj+FNN$2p)I@j__@aP9`Dea z&P#B=v)@}sj2uP3&O%Oprkc>1L$)O1n8)(9P8sELodC$4`+&|wK-(?J|%kJu~Rkc>F z^@KU+)9~wC*3P7Y3*;&5fanYO3jgM%{$*wJ!ZMqR-~S9rPMo%YZE*co|L~oSS?->G zIx%P6O`vWOE1d)i{?!x$`4!#6azJH=!XkIUpVtx6ENZDz8Gxfu412S7kvU1&LF0Zw z6wS||*7cdIe)w!wiaV6G9++E+HMbONW=}-t0kU2zDOZUgc98!eXH!P}t zr8tB)g<w8=AFG~BJ?93ZsZ(U3 zbAcdW1B?p*QjDk&+DWw7Rg9i9bnZUOe?3+Gr%jtlgk*d<1>JnPBN*C z(!84AU&v=i1d`l&TqHersv77TF)OuME%@>$=>@Jq?8$Vb^81|%dr9?IM&b<*EjJY! zCf4KwgA~;AwCyrnmo&VVOkCo>5($P zi;F;fvMhJ0w={*t(EsJ|TaT6br<=-yO;7SIh{931_nOt1=jrDPuBR3vM6bxdPy!Ua z8Wr&y&c6hFf)(_>8wm4Ux9E_K>2aGcx}!%FT%6vXjO0|;;@U{?xpoYo!p$32eINDO ze#jP3g+QDrap~5|kKw`jt_kOuVr}Io@WRI zZTdUT2jHguM+~Z%o6YF13ALDmD=3fb*gE=y4~9tleiykL^SQiVqb!+JHllEk2JU>f z6|d=+_MBf*O(=K?FcW$lP&$}^=tBg(%MIJE3cKO7Uxk-Z0>{%0L=i|XvI6&*%5Jz^ z_^bN&$$x1f42t8we_DR!!k_>DKj5Ea z8gzX!nVT>4@hVY!wp=FsDqM^w>%pLVm^I&I;$V< zWPF>gpk9zl{lofdeL+TZW!(?=EimQAL6&Ll4tyCbkZyRcPp7sSv)0#uchwDy=?uus zO0ukMw8rI4+90?3?{5sbLVz_(il^?W7;lzI%k=k{cCP`j?Hk_4*j*?Ew+nY^V8*er zKcS-BC45U8jR3aI$oxj9^ew83I97J4`_>yv2t9N=fY4B^nX&io%wwNaT3(|wUJ!zN z0cjbYLc7A6^_!X$Q2_el%4?@$y9~-9rGvdwlTK;1xO+M+Ld{(D z>Sg3k%e~5rS8?o!(fLug@vu{I5mI}c^g!4gatFPZurZJ^dRj|u#+E6f$o_Xv;O<%< zk#Y~4->xb03ec`WP^VcDr%z)uDHYw%fLocjP{v6v4#?&5yD!CY)^1<%jMW_o){~`6 zYUOE$m0F-&4xhFAJ}B zU8VVz;C65ZwpYT5Gi=aY{~$T@#xhQ9-!=C2V@x%DZ*{$y!sq#37!BJX=p}KV`+kVvj*b&9~CGH@-((cgyMld7+iyoL6 zYL4a7oN_6*ua#In&$rF=^cNm$t5&HGU3J{m)aZM@$}&0LBk5D~Vj)i=rcYb6ftO`= zXjJNW=Vo$>tA5@C!=S|$!&@pRR=~E`)y=Ir+(Umm&_KP>!pIhQ9In&xo1Yl=>*zcz zz*Ym$cHa1CPv<6sBxcD;;T-cBejS5P-0l;Bw37#3fD}*+Hno-;EC5{Oj$LDl|FBT> zP;*zB+!S_UylvGRgQQDkl@zw`-dd2@r)t~itpB_uR}gKFEs@xpFWh+t(+>XhQxe%u zHrbo9o-jlv>B|?1I?L<$6uq_wEzFbFrkaz+d8$mc`%|>H&J0gguH5Il$RAhIAtr8T z42aikJ$93h2*d6V{)UYDN^NBkAp7vGouG!j%^j#N7NCCR)jn%zRF$74+39}kjuH)7qSiuE)AAsE8DU5NjdWx-uX4&mU^&c z@v<}Oj0_vi%Vvd;bJ>J*7FT@srWoEDwi@do0>nJW8OV~8Pudc4l3d;?jZrM z`iaFx>3npk*w#RG{Wy4Zvlt%$Z2@HYe&|}M?ckH%3kBx<$-?lyQu`MeD{@>^N0?2e4`9-y>J5ap+9x=(QOJg z5V7f27al#!+dCc)kMY_KBHXjL2J&Ao115tbOt@uM>aZT74jH9o;L?>o-??sF z!7KfZ1ZO#bw2-pz&ZSJYKO@| z+NQtLzNd?r`WDu9MJBqRl#~*s9IvY0vn9RRC1-^%Pu(?m$zNo6Yo~TgO5AOw_50Hw z0KP;W{cfxePPqC@%xYZwc6=$~yFi4R6qjv2V)rMgYq^|Nnc3{^7?euk`^D@k$q*3hxB2!v4}=YZ}0M)E+HEYg#Y^1F|rLSn$k_)PSp z?JW>)pi7hGxkPL57kg{=2U=0usq3_?qAI>Pak#@}n8wv=ysNJ5Di%*ZT<2=djLiu* z)G3-IiR{`!9}e(&?gTe^9ZgX1#=b&;^@1uky2UOxO3Pdql7jQr&aG4wkiyK zWfdb8k%YrR^S-{p7z$g3@8T~@bZ2wBz$2**o>{7*o^TFrG zDBseO`E{=T$B?Uw8QeF!?NTg%~0H=K1ucGq`6O|#rWEGCMz zf9r5d^{-BKDkh_VB~Dg&B7(~ z2QiX@ z?99{UmgR1S<6s)KE62AF#c!%z`C1m%c2_*sV^D!seZn6i^(2w9lVzwe5%x*BX32OI z_opZ;ZChb&wwS#5StrL+eQH)#2O=+Scc1JsoAER2X7lQIY36;?XAdFqmx*tH*O4ls zS^mnru>&*4Ir@qFNk|V{kdWe+b96hv!DEr@54-+5q}XhCBb$V-I4xbQ?J~(|`-#~E zgE{5LM8QMBiUYDU84*%9&kCqq?v-G!Di6!tPQyCj7Cg`*xP75rD_W7w5G7+S3*HOB zjGjz|Ra~UTOP^vy+vs%eyPynHd-4`L_B?@iLq~9J*n9fXWPGMp}?yCTcT! zg2@Z7?8`anil6A@86q{7zQo2}(Jrv=r)fWTkbmcW`dCu63p(+dNu5nJw^*SkQSj|w z#SS%S{d*h*!$}eBQO-(aMo%Y*VsvFPNCpXm`+q72@_L_Ly&W4R{`J=U-fGp{SnS}p zd%$6Ry&*W@_Xg1xHYvzs>i=4>$tVeg7nq4i}9kXJhD0Fkm^j%mdawB{Pu(*<4^vtft!DR64q<@`{(%o_WnPoIzH|{xQPGt{r?sr z_}B6O?friOG|&CgaLUvGrf0Gz56i&mo`^m7Q&=vM4Sd!8okNE+W@T9do$l;cOCM~8_mSdGzJn97>JDeXY6b-1M2XTNH5Nbr*i3hOfu9-NNd`>X#ZNA1 z``#>pp7=bG{yiI%J&!J4PnDB!Z~RxTY%Q)H%g^ZwS9?>Cpll$Lg~@&NwROz}@~2lT zJHFC(hIi89-7T+l8Lx73Tf}MIIC?Tq`Ha`uOhvp7z|o&6F1WI)-P(erQF9xR5zj+F zu%sz`^t~!Y$v6=69`jN%!%U2mW*Dl)-WGT~*~cg9#&FfS2IzfFohr5Nr`E>`2}4H@ zKR>^a4&;pU^a3JVugCI$?bb9GC-4Q5Z#^IN$Tbsk#Z$R>Lv^5fxnEF!D(&j$6r8 zbdFn}2(>U)>sHeN6f*vmLY(X~aFo>$9-v9yChyzgRtI8WcQO>!Gk_@>r(DbVm-Y)& z@YFV*WaQk*(8fhx&z$-gr+NznztREeH8UZm`ThLLqFFpuVm(^@ePgrjn=Qs_RKbR( zSI()k>4)$7GrhmYnR>oSL zyI0_O^HZu_7=E0o>`83+O)=rB7yY^SI93m4);rqz*yY;F>ebfVfHb};SJvn`CO4D? z97EA&bG+jqp8^;y>TATdpM9Oqplo0oc#P^HzH*kF&vu~dj{V*f^otSw?F(yt!G=4nC_WvuH0M} z|I4W0$YH=yVc!j?b&ne5cpMT79z(R+YR(BXp6)7s^vnY7gYICJS43h{p&AX5Cf9e z7|kxwK<@3%H@iG-NqwcQBZku^&bYgp z{jAEKoBU!sBbe9eXo8I5IW^~(tz%p2gb-` zzbB3gy5>fA)m*sB^^TuYZ?6QHH?>tbjMIi)eCFK;+3>S#@0JbZK>5>ElJqU;(={jB zehW{Y_8hQyZL|goQ%MnZ{io=iu<9v2dnfw4a?Eu;^B?m3 zVlEw`jp?Ajge_ci$vxKp)7Rd5^^}6}vttjt%Ned6xShJ2%qZ@;J>H~}}VSf4g zE`vS^s{)-lLs5r6TLNn5ybJ4UE6N$2eYSkc$|6NJN56Qo_8P@3a`kz$Jvoa^uhuf; zylBPUD98hy zq3%#RC;n=oLAS9wFVy9_jeSCV1-qDn^J~MVizOb@dLyM)$Y(2MOju8{TJ)%$p!L)% zKD*u$3*(ZM`@-%8aya%9-(^UN3`t--H339JLfLW*yP$BerWkW!N5_r06^C7FIo0Gn zZR)wxX6WBBu5-t*s4vC>!{smiUHOL~_Xq5qpFt=$1x>?B$(Qzs(gQ=uhyur6(VI;J zGqi>Rtei3*qh=zm*o{g zi4q-O{Dc0!eX=fFU$j9qfIkNIT!vE?TWRX~@JH?PKlj!jdaV&DTU9;?ELLxS1S%C1+8n%Tr0DhC^~qk1xNPe!};yc8Bf zo1uX(#iO|%qU(6~sV*2MjB23ht54ji_m->6fHj(sA-S z#W4;>`lw7j+V6r^_0k92Yq>uchcT?f!0_8OX@7tW@G^l{@3=0XgpfAsr#c92N3 zm$vCN^N>%Ijd-u~*F2#{J7jNzgSxG@maX!+b8-&%DtU(HqKuTP6}imFf8hi zl_JvBeQKmG^agxGOAgN6#rVL5k2YO@YyMJ2MewdyIe;#zt?=Z@F-CJ@x`Q>y3>bDvG~Ai{*3u9%9e``zi<7qiuS;eH!Xg_4BM!GBOyeF&4tv8+%@-&&h0h z{dS{f_NA1GTu?9Bq9oHuu0OK zC7v=l{od;nR|?Gqwhc|}G#Qs_-)v8d-PNfK6j)ApB)095kMbrSDwV^&o!?6HI=|HU z*=r7~cp~zFlbU6=rD35L=LmYqTYlv^oxLK$YOvT&9#1}LzDoN3a*|UCzOu8kU?-YG z7tzhGk;RYn_5snuIYQ3-s9?T#C|+o>vKCvtnE5HB zm-LJ9g0)sqsSE90b;RR{k(hJKnvS(H{6f=A>zjm>7?L|#+?At!KT1_A{C$xn>swKF zPj1Grb0^Cd*Xi)LYUsn@{cV%2$>4fV+}*?Fl^celyR$>$+(-6i0rV5$WaV3Uca!`M zBst_dB2yca&&kw?ows1~o!_$XGbw6dc4kIUw=RdbB+|)@c(D{;@|`y^`8Kk8GWMNZ zuls1baE7pzW_}Wt%n#^?dbc5gzQgvmMMV1CSDQ0kE2G%GR1?L`Vk}LS_~3Bz@3Q{> zL!=A>kA#_!(v%FV@p{|r_mvOxObi~TIWXa6({r)wX3LKn7qMJuFCXf=6m24utfdt8 z=CyV5c0>_@@`qVvBOk4@_NsG!OIzEH)kEeZ5(R>%wqNh)KloJ9>-do3yVVz2xMC%- zVh)*;+E2@ag!)lJPQFiR$~OyYxORrU2z&1P3PcduNIS@;BUK_9WYW{g~Qz#PiHf8ME7d&Xhd6{$_K0X z4~+rip@>J`6`LZm>e@MT?Z=ltStn|0Exjb_yQvtwF#f0$Z?bMTv!2$xBTo6v1FK%$ z<;L_&4C{0Vynq-l;%?b@V^kGN$evk6cU1Kaf5<`Tx=IsrU#wg$Uf9Rq9<_(AdfEYyeG$4qH)$-n5j#=!#zQFRmA#6yZyN=pU7V8D-(?RcURf0 z@%W{rxE`20d-1x8bbpCPSrw3xHHrZjAb8o3>{Q#Vl*pyrR>e0sA7sf zTMPjzZkd4+Cgk9O z8z0E3Z6(??G@q7qT@Dm7{FG|Lu0;Ozpz1aLRQ?&6O^wP?HQt`8Gd*O?vjxr{&^P3s z7Ig}-;x$P=&PuH0&BG5Cx+g%*dmof;o>M_yh@t(|6mULWfD3 zfuTKRszp7?$$Fe0pF{@1;>)Z$&!n0>*T^F_x>v<5j2@5hcs;*ddRwo~^Kv$2<^e~IdH*S|sCdWn z-o%Eoa<=I1v`g<}My)I?xz(&W3<}xl^pP>z_e~4^o}Zwy+fECdavbJEGZ}Cr=GWxi z$7H+xBE8w&uZca>7x5X`vab~S)Me5wbvf;eiDDe*omNpHyTw_TgnLXGe?>nlz5PLb zY;rRC(0VVPgPPj>>*{-~@T#^5kK{Rq^Q_V1EC;n+tDovp9}uV_ zaBcQgi|$Xx)p)~=QD$G*f%k?ZgoL|hwTD7OHXzOgvnIU#_m%~B41D#^mQn`y`tMs3 zX54#!ZwVpd4?)&{+u|RG)BfJ#LH+__ZU1>2fWU>xAhvP5mHoE{Oqk*jxHuaTUZB~3 zw*SM||93y~?^l8Q3Ea)Vz5c!ZyZyNL{@(wet^#))?jGV^@us)8B78XFDgwJ3Biu`e}=mATjlvlL@ayw_Bjmc-91sF30n`qQHDThHoaMRZiS}#^K0WgXMn=6UjMD;*@i=+lX-ZK&k>%19ImCs*??C04hkY%R@@1^#_@o!9_=&X z)+wj~tgbx0kZlv~*vkVA*oYt$tyY=Zsv?>5ckt5T&+Hbh3`W z4ohp_R3&C?7!VH=@NU5fsE3A zCzS0m&SGqYN;=$Tt+7#=nQU_fHs&WNmmBZq+%;3txT%5qbeWB<#S1e-s(&Hlz>F_% zRo&S+bX6jD%XTqd9OHF{mX=r4X^eVXl-sbnRI4y$a$o0&VPQ=x=k8Cg0CQKcOYiL& zt<;G(bJNMZqUB}znfq0APU334hr8j|W{w?ihgV7$WysEQa=v`|a$%2Wwv%bb0Oejw zC4(CdBe(Tm3#y9P{gU3Drk<+;{LGfLcAY2D?*2w61BZshYSk#z^;!9dxzc5Yw+oPx zEv9DROt3>emNy_NEBfmh6u)uWe#krP@)P(Fnu1rwpZ7=jvzaJ zfbE@iBp^P$E`0=UUB|QTnPw($=ME=;1^KPiYvuH;*B>45{xUcKm`oh#dAUo~yRAxf zd@8m6IkX#E4qDISUYO*g+Ij&0Yo*NtUyenZg4%raMmmVE;5BddY2H!+p{(AM%=Bz! zywQX8FK!lMpt|7YBc~pEnFa=#C(|ul08S#CKgvW!8j+_JBml`SOTRERwIE5-qkPWB z=4qFcjj^2jNq4&!WX$>|&sIjNd{;vb@1rF5<}(gWdTf5wF8ksUB(KKTN??~tQc%$= zjfX}@t zFa^DUE=K#i!^H|9ZFD3|&Av-MbC*$TZwV4R%r>~FBfRpOfPxu)A- zM11!zRW?Cp`lqR$>nfd?^yjzb42#eJuqepc&Y!rE0QJl=^|KE?{BZkHdr`_$#1yJ& z;Fp9lN#6)(lw3TjK;G(re)jj)-aDwM?{(!4MXiG-%zxabe){=!7#7(cOyC;nCqZ=v zX_1IjX&)m-fGvUEPE0SR>8$-$K~dGl7;qy&(me;tybvFvdVMZHAjCN(Tiv77(LNr! zR3z{x6{{j)z!|qBAEZo<+kXAt;#G@!ef~|L{t((`yCs*oW-3K67CVfa;05v*ezE-E zWKOnEFSJ>(tIAtDZJb?3dB4KmNgCqhx6+@Q`lsx*PBn5WUEeSm$V;XZawLU(1Qw5Xx~n`q;pmN0sXC( z4~?WPxPK3!N4!F6@Rb3+a1cR+G~X&wy@j|=n)J{GPxo z%r3iXe&OKc)=&xU#^9n$3ll3$>j8*p9(~7dT&<}33Gjp;Cw?7eySdt9Huj zx%K6!pryze9%UORX3daDUgJYwmmvdAU3I0XOA5)dr968^{xnU3i0TL+cCdDc6|x&d zZQ^y~*N&#jC8-XoPnR0YK@{d;p8!Fh0`_E8lJQ_6jSAwrAj9RGu_rCBSkxPt6GnpW z)oz8VMnTGzxASE**L+_;sB@ar$gX-_y8`U+Tc15X5d>S+VF09-8I@z{D3{oV_8R(9i=_J7o4S=G9g$agQCK|J1ir?V74Sg&qplDtz2QEgm^9CCer zcen>h{zCsLe@F+@!$OCi%$Gi+07;{KjCd@Z(Cy7)-YuK$;j!$KrV2c)d|t&h-!C;N zv~~S?!&1d&o~0ze9RL8t=RU}TX>ax;s*aP6l-a0L)O)|ur?yT*Ke=n!sl;=4{at&Z z-#!-pI~a2(a=;l3 zgpq0%WIjN&tDC?B+DiQ4a%mUM3OewD%t&(2GL`R=g3XB$qMOSfR>oE!kGx*!=616` zDsL&gI_fRI-HX|kU-|7OKZ<>hp1%{+0W9doh+@)Qrl57)LdHYdCLkf5E$yx0(f5dr zLQHOn4tsfg3#UsmM|`poExm1h*puaW}tlVe#>6v&klG<50)x z(?daD46)ZiI-#h*!b2M9bIM(FiS!)H$ml5WjJ*ExdVGnLc^0_`>R~5FJESbweJcsv z^g~83M*eAguhj;7FB`|(-OE3e#fDJK5VP1bBuu&`XLGfbpwwgf3U^JF4Kaj1hD3!2 zuwAoy<}hdmS$r*(u#RNC+re5DHi+&z-dX+YWqhv7N<}R0azv32;E2+g~@ zqkG)ztm^GC&q-|0IJK9MhIC|8Uhy3u8imzGy#0yV!`9I|!Xt=1W=PPJ87QFw!4>)Hr<9O968qi9jF;OBJt?WY%@n&q}bUL!5C zg5T>NOgUJvt5Wwf?kJp*Q9EL=`(pOy)X-9=v@Z3v!d-1yoQ*1j_}BEAUCl zKuO#%k$b+%V~6bJj`m0R!DDeh%a`aa$DU4|>O}Dqskt4UrIs0C_&T~e2h;!N+w-@B zbOl2+BG0cdEZ>*(omX3*bt37nXBH}d<5Sx~8=Z*Pv=Z@z6+d`1sKwrUCZ+RIw8p#< zs{VA@(od@qM!$ipOV&R0g&Om`_4`j+H0HOX6@hq>LNzm5Zu0OOGP^ zz30P-JfzNv`v&w@kp8h8uWbi-h>=CResGn}h71fIze~^+(`u!GPjgX4HZn`iwc%}N zca~*yBqz0s*spD0WM1)m7)VBwjIlhIti*yOj70mBy(MuEbzf2Ux!}g1@`D64P#ijo z5tAu|T>-iq7f*dM*LD=wRiKyId{c}SS*UZaqbfxAjrB|3P3#{UY$sWE5hGSA%6cS* zpGbFQ5JD@grdH|^DZHnYXD<$I{CrA8ng9J|csQxM$qoOUe)hq#gzXk-Hm0=QjQdL~p`0$6WCI zd){xP^Fpz&jb>qma)Fn){Od2sj==6ZFXHUkcE(*b8>S^Y)%a^OYHXGi?zgiw3_1E1 z38QD4#9R#nWUI>faJ>uu4K%v znpd$MWjRnvDI6s@2 z96xy5!x7`w(oyOvhux^8T~;eNxKk;0?CQ6eipAP)EpllXzYzaK;ol$7S76voH@f_s zn9=b~xjqN^mZ|@P2mil!zAq*4yT!)l{M3xbO0g~L7{%~hr~^;` zGlN=NfV%lx*VZ6?jlT6uI3w)guIg7&S{h?MI7OyHTQ~#*l&inG={3>9)gq|6`Pks7 zGoW1Y-P-dxyc5X-Kk&FU26vn;*}dGW%R1g~7GF0Pol2ozLI_L{L;$s-8f7_0U7WAsE1#$g8v5z#8G!qmylh)IihSlW{$8M^UJRr|NMDkEmepq9KwofuQDArH5LHGEEp6e*taIBe?wAQF(ls%*p`d1JH7oa(ghWxXG zi?AF=QvS=%aa0BO(ZBEDl0e5J<)57qTkz9`3FqGY{mGv%9fyG60&s^PkHY`FZ*PDY ztbCl|{~w+&^TDcTU$X3EZCna zX|jR*r4gI9a7*l$5x}~o{6u?zS*FCr4cMUAlIx-B@p)xAA?#v+bX5nL1BOje@G2AL zQapHmHU9#|;si;LZIOq}Z&6&vb$(DloOjX)D)dsXmn|;~+mFOrGfmjze*XY5d73He zMWkc6S=YNLKDQM@jXdc8ty~%0e)~l`&)_K^o_48J@518oR)CB>v#Y{?bd1a z&Vc?pDG4ji7#yk7&L3Kb&qO&P&=q$Jk#CvExh5}$5UC#&+&;!nF%9@g?*s}tAP;IzEujp7{yz+ z^X2IMHW@R3m$nPXiyme`B(2cL@NoZ5ht!XCfPuHvVrEYd92Ou~;;-ry?4)XzT7L4~ zavM*Uo9#6IMREP{)ymZ_XL^hLoc;NKEVfGsZw4C=sGCA`mT9%z$8MvQ`B?Ev5WrvXr1H4C6)?`a@_gR zCn6mil|+(z9OMyPnprhRkMloOq6;pj3}coCr48|>SLga-@8t>Yq$a$6tp)>ZBn598 zSG*gvDE6#k*b5QqW#l*w<75}8Lrhv-4|uL!pR)k{I9s#=A{Og~K#=wh7K;D*b`Hm- z&}e_9gHW--N+0d>&%^xid0CK5Tf2iGx*KtZ!La>kXK64*ZnGqR-X}#oGk{t~Ypz2Q z|Bdy`kHipoa;gr*)1)_k@TWljH3hv6E}}=j_j!m|tOpUE!pnLYD}>sEBC`8vXP>n2 z#%oaWb3-}{OX7yTC4KP23Hax}2(UG$;puAir?B5HylN!zG?AN~U86OjFm?rzp05sy z9%!F}@L~nHRMTMuptj`a)_{U0KD{maWZK!9;Rg)rr24v26})>{UtiHsT=l(nzx>DE zBu8Lv?Sk6s7yx;heNA0CoEc_XKjKzUBdbKtkqs()#2r?paEx??cK#l2`iBk-q2+zd z=mWH;-jQZvCeL5I`_rfK8kN@uK6lSZ(CG-9_y+&N2$y{RxM8`?7Z+q8t~%rMV~O?cGL!+E zt%4y2olJ{kmGsE1J#O@;u=Ce>K7R4>KM{7&i5_+t7Uj>k0J18xakm4_vmF z2Hy#?R@a6HGFW3DU|}__ut!vN^QQqnp4YOMGs$p+k~U#}PypRPEUePt7wErY@+H8k zW22PyStbmVecexwYLA|+c;QyKK|bFByw5+J3k0bMPMnMdHXg@=(MT3K$%<8|GT{@y z4ie%F&Wb+iqhriPOTMrvtUZW+ez$etjxtCMx10?I=1RB-P7iZsE- zox8@*GV;KZGhXbonit5|FM9CRI72r-;T26@=8%z6T`>nq-w~65WdIuoDiS7hY%3Ql zKKs>^KS>R-ndhOHTntQ5gw798-DRb>R20eP;Q4B?qmdD^RmK)HLHv$vmmU=G6UKdx zB&VfhN1wyP1e1Tj2t2EmTS*Uy$XO-=B9sACk@VJ3Gfdm8_x7D~+rW3j=#%r1VmMVr z@w5J@D-w_Ge7<3~`2);|lfw)0zfYouC`!^FY){rNx22g)e&L=X&8eSp9ptl%%28BR zes(^XQPG{}#E4%)6wJUsT)W0QKLwAP8Z{R(YjX9=kvW%Pwe6eXxi;F%{=^Z{_wpDu z$_`)>^cPAZJ10~6{pa-e63;ZVV4yij_5QguZW@^{?^z!%(BAidbq>lRhJ`<60t3K# zcKbX;9i3OlMlmz0QBV;%3u3T?kn0&4O70qEVH^1!m4Zljo?RapAk5Z_7ixGF7KV}3 z{wv*Ux;1WCN9dKycAA&)lbLEv{FS%V(BTzk2$&iwsqo|jhZiLH@s(W_k4+31MtE!| z=_M|mVmdaej&1D70z`D&2ADE9`w3?X{jsk8?Bha~|FWX~KH%SNvj6xUT=MFV(f-fQ zpRd4K|I5y?!S(m)5dZ8P=Xna)`89}7MBa@fWri;*SAcD7%=;GG8kZHCW2ON=-4r!~ zN%KYAm3mKb+?0iQX&=07iJY06n&0~PIG_!aoSJzSG6*ZM&gZJUbTvj4pM_=ZyBC2;o<%+dJIetwg~Z;jLMXM?*bA(;4>{nw-J5GCr5 zqE-6u6~}QIOv}XEN=QESf*QZ(t?t4OQ|xC2#CK(NoPLjRRK|@5<|1N z6i2T(R9N$k_T;}@6X*?aO}IJCn_r$d?h)uN_|9>s;a-nTZrC1o=|9O-+$Ydggt*s# zZT}~K?oWF7-@UrO-9g+>{JsD8sd4Y&Mh))u-?#tXjDKsupT_);%_z=Bu6D3MMsRAU zS$bviE&M!K_+ya#k}#}vypi&B@*OA=D#9f3tT6%&)`&>+T5Ft6`^-sp&u;LdL0jVeSLe4&{6Ln^MX3O4ArA z-txZ340NL;V#)owntO5nZ?qXiyGzg?^V^@8SlCA{QtheSx zLk`o;0F*KhS&l@zWUX9xm%tKX#^vQQAXi5iSq~+Q+UwhK$<+xWFrr{}dxhY$AKB4D z!xGF5w%}3 z!1cKM{^rkMV@+{G+N;_I(J^_%HTK%XkpTgObFWvc(JMt2V33BgUB3JsXIOh&u)A`O zn)*UFno#@}q!ZLOE(S(R`^0MQP+)X7%Ua{P=RAeJh9r<6{XHkXD7@XsM0JFnW(JEU zemc{1?)*;Li9Zd0fJlVg<mK+P8{isqVbOG#n`57=6&W%j%4Gbsn^kZR^w z09&LBkxtb2KotnU`$vTh-)l!f63@>b3p$0F)*DRL5RUWE4O~STA?ckVeZCfiv%P=1 zLZbZi?$Bz@hDNTPz{MB(ry&&odmm(h_E4ZEaQVR~hzm<#)^J{q*CX%4U5mN#6_dYE z{z6mO1I)N2Chd7E+Q6!dwA^x|9&VF|JqBvexZ6W@+NEH?fI01S$x#qXfC!p<$1X|v z(l9cD$?kY8M=BEpj|G-v{Xrq`nkK$-XNn|&SY;=a0%6yvF%D;vW~o2i%VX6=43^s^ z)&KQTy=iZA;VKlah=qSGICb84cult1ZIDLhhTy;k&8CMatKA@ryp7@yA&BFh;?!Dn z-Fw}mpH$kh`pKkufA|o_QOphh6Kzn8W%~1Tj{S%u@GEny1H5-8h>!M&8K264kPRCw z(~f>YdP{GfYb1*i2fTqizlhs9C{0X)p7<9WWN_3ll2On)7N=^j@w>~(^ov3fB0WRH zsy-d^&!Wxc4;o~Ts=;Q?8sago+h2!qEQ;Q$hvbQ?R1%nCd(yqV*|)NDPjwIRmUx0g zrXj-!%i+>6=Jm$ws&I(nyMJWz{C(pyRyfrS!e9rTAt50NIE^~Ihe}Bt_K+!Oj{Thh zG%A?-&#?JQY!6InG_-f&PfauiC4c$I3tTHT)`D^(bEAzxT7g3(YNKW0f|V0aut=FiNeb*apQgQ zPRU0`c&l7}TfgkXGX*(j-LKq!_uz9iXfEy*%DFl5dKKt_UlDiF}uGTFWgObIAv9Rl0ht#$^Or{aZEbDunq+yzHcuNzX$!1l;La+-%`tN zz6YE64_F1Bf!(Q4J)i7VxNPKeuq8mC+?%DYX{Q58X`cyQ8_$(04xJYS%IJQ(wJ7Weq~pi}xZ?L(C;h1D{xbx(=D5_jflmxTBw`*@XW{7reG&SE?vu6TBo_sRKow zl4eG#oUDX2$y_9#+T}I>Dp2XA&wkL*bT9~_eNKnX&hkPf$vWqM>c9I)!o0l)4*4bK zzQNQe9`H0KgFvqaH|anMCmxanJyvg~?D1n#5RYh|lv#zK9w+Kkx*;GLen?&VIR~`q z=ILZMJL-7Gy|H#(9bFDJYcG86BYBmzL;c=2Q(m~=ZtPo*3Z(8D5r|AwAm5#^ouZLZ zQxD)7V(YMhw3$IBp6Ogia!2(ilW*?*Pv5&FTQ7&^uym91xpe|gg zUgaknPd_gqS0yK>)1}X1c<8PD!hV?vH@1F6L~Gl{Jlrd=l)mC!g9qynrOc)CM5^NV zlkiJir8+DT*O#|ltbXQ}&DmJTe1JH)CZ`KTG@2N`^Lg(FsF5<`7D4BxLs$o7>r!;; zgQl7zJfw7zwknnK`BGz0Xv_TPLWaHJy?{}~mXMy4ac&ue#Db=#32`JyXvuF5T8`6? zYKXr@r>C(G?D~MTHXr1Vf(^_81b(mU;B|LrsbUC|!=Eusf*%Y)uQ-c_yW%QI?>%?P za>{$7LPp@Z`N$V?dJVnhp2b>@B#oz2L`hyOT7yV|`-w>4p6!Yk28kESD-wW&j^_Tb zA#&R)x9gpg0UsypQnUon`263gs8bLT&fPDu8-l zAf~K-I)^XkI#F?rW&hV20{Y)29RyvhhBMT6uZcnA=bi>9h@jkX^8n3Q&6nvB+dgs) zbt?Jpf==@HZJ=2hq7uBLG%}_i8{YzF^J=)Ys0r|hov)9%aX#thCv)0CeN)2j;_uZ> zJT$;hX>0R#T}&No!^YxX`m9;0T6lOa&6VEE0QC`F`fSoxZ}Bpwdmh3_WE$Y52iNLM4@C} znf7PO&CUamarrum{nW<|+0$IpVR|=^LjbGGYFR>2H~_)qk=RtdHzVM1c3(>{yH#C7 z+cMTLa=gv?{-cJtAs6L25OnVM2>CeL%Z1AtqsiX*6NDYG3?2 zis6*xMz%9yM5Q!VGfahR zcs@6n6-+(*$p(T(Y}9PcUpNXzO*PMF%zs_?#?oJFm2GT%^V{v0)TlR4!@Rl#@uQ#n z>Y35J;)H_pay6AVUpewBtj;RAlKFn6la@{HY(9XSVn1|^=0t{eDEuy)eMQF+pg^W_ zD&@Bc_JWe(z94Hya60NNk%ZOV}}7z;C%c?2|~f~y@dwl-TjO} zQf#*{bdm;|p(gX7b%}p%7X7VBpHX%>(@7$$UgfeA{W!^x)1M!xtR(ZV-kr*o_;D*} z@I{l)!nq&e6B{*oXRa`0kz$Jf*6g^X6;TczsnMjf-|GUv;3F2)XR z6gtiEcN_QjE~3;>ST4qC90?cujkJiO@c=3qD`}ytT_{9ws_4=_5Ftu&v?#@MMN#z7oudp)_`*%eB^R{@gh=kr zIwzy#qB2eM((Ea=K7RZ-S$<0|tK5X$CN*bWd#HhA5;zIK&WggS<&m-)d)`nNbyAbB zoX52OGNp3Zj%K7{{jvQGk4g)O86h_v2wusZ1FcFrRZwv7jYPs*O#7cLxT)(JNt|_o zIzSTh&pGTiOJ!FAl?j7Kn`0@6m%_)&LLSZUJ5o%YL4i2u#2$C{9x#uQr-B~`%kbah ze>#K6&;IW&;eR@V$TR*Qd{PJc+Kav9C|U(QI;HGyH%NT&&%tby)?0V(b0M8yqzj%v z$P0q`Ed)rqzL14`B+{4qK3}(g*tGr_m0(iW6_(x$zWXPihcTZ&386&x)GypbWCXz& zTo;sn=|+aMQr#=)TamhV&#m~P*UN<6zwl)010x96LUlG}It;`yQJyhNEYf)kgWuJ; z5Yk6}lAUK4MEA|?R0{;5X+|=}ZQC#UBPsJ%`v~xcd#)zch3^wEZ4q+{yaMNDSP50? zNP$_NE!E{XxOEB=xz}?(`CQ&Omo3Pqay1McI;JP*J`Jc(-!xX*RN zq>=9TBq-S737v-EW5bu-Eu@Slli5Cy_iOJG`0I-vY>$X)O@zlwo5)B)y%21~qWu>m zr=8)T&jcsq>F$y1Fc>r}E>U_{krKWPKEEZQsQ>eYUS*JzjjA^KsX2{^XHAq4pSJYS z30Ahz*w$#SLF4N(5=z}fn(p2n-PN4D_->+gj&33|&TdT4ygnm(jJ_?Y-5iB&;{u)1 z?3fp7D$?gqUOtR1eQ0PYynzb_6mk`uHzWvgS(I1U4Rp~9E>Tgdug<6To}G%nCL!uR z=|F@1ri@-4|G?#N{*!m?=vw!CM+ZP{J=_oHxw*=% zGIw+rB}kL!#dERDsoLr}qaHPBbr0KyQk5#v^(4_=Rgs@Bo@Ic52XZ*uW`uNnb%{+) zM8_f@L$8TikU{TTDU#?txzhw27HL~O`cMFkV3CpMBrLu!oYAn8KdK+d-w2Vnz53MUs9#(TfoK(5O?rO5-RK(D0s=%`==;EG{gpeTx zTamkA+?9Ypp@jrxX$#$1z>_ zRdEw5TYbHX>?T8vYYqp)@7=dM3gG8odf+>sNg=x*;vinKMw&c1O*>li3LO1gG+7N( z&L_V5AO#`iV-9{4xt{Gj7EJabM`UFigg({eDrnl#99OI!vZe}DtBzpvtnaJq^6dM< zNCIog6o+^?Icst~S9BRFWhOX1jjIX6idRn~c^;@%6&)H))H|wpY_bqD2{ADBOqgtj zju)#jiKLADiB2I0G{*z-Tmi|=!(7eEGk}8`a;H2JuYD3W$*b9d-z$57G#J(H14E$*|YfL4sjBd1OaCZ zZXaP!q!^a*ZiGg{Qbm;P##_ZgbvhaZ>+nG`%0IJ32sQithZh#HI>M&T49peY_^xFP z+7dLqrS=mh2JykHWyKOO6dwjEn$=-5;|;?gvH$ z^7dQ>QAT{o4`3nPK{O`Drb!UEU;_kFs8DJBES1X9o5IK^?>@vtG;=2*;`v+`P0!k+ z18BOEyf`}}^>J2+vizWQs%~Q>pyx{qz#uz6Vx|gvsrh2;=s9?)Q#>VPIZb>fkBC@>>ghz|D9&M^M zeLpJIRQb&Ql97@qKJLe{bazWVwej19W!CTVESu^@-(H6>y}%1002{FXKLcbH`vG~t zeNibc{j+>LUEx_ZW(u2SOsTHy=c6@xQkz;$-zr1sscgKtY4GDp>BMqmjx$G}mj-34 zynJpFg6DJ|F?GVCEBlA5!nDs>w+u9(SI~S>^4%soWg%T8@G4AXZws$54z)AN|7Lcmi<_ zVw+JwOi>Cr{ZuqGY9MjIsjcr?=eR@*q*dYoRPkca%-*LK6(wC3xrfXHz+$X`bYToV zUCEY8>?WU~32fLBVxsf0m<*rZ%KKzcHj=V$yJt{J3L+c0zWWQ48qGUn^;&dBAn)@| zY9a$sS9_1}QoR%jefLJ#M7GtKn^YK6e8$W$+l3bh;Yk8945q14@0TZVpl8_Kun(mN zSs&L{DVMs%#Mc(jfpC8|lJ$VPe+s^f21p1=U`-$%ozelo#C~Ee4ah;Rc>Ue}nc@IJ zH2@PYs$NRfSR;=RF4+7;lkK?>X^N`y$)I#3meGpI`q38rvR>TbI&uwuIr|yn=nz%> zjF>X1tFz}r9sBC1{qhwYju^AJ$yk!HD3b#)y&BY26F6Uq(H!Md!({LrE!AyZP(Y3~ z&Io)a zu3D-C`wS{ONXMoe+8=MJUgx7Cm&k|U<{yxYdHu?gYZFai&(B~WQk03q`V#G3VbwD- zfn|I%p9nFMjw05a5Y$^7)6x?d*3S~K( zd2ikt&HTb-kiih|TmDcEaWi+`oa2@%jAI7-V3g*15%3e?V+sOyo!*T=lVQ`bUm#Rqzha1_z#&dHW;J;|#v)9=0ABWznWAW%c1O%V^vaIT*qqXcutHhkS zpc$b$vkUTzZiNkAEX5? zW8Q@Gow&6eayxCjNb|j}y97l_uGcr4L!)W+AMjXPII``UlWo-ja*#tRl0M{Fs^4te zbO{2+m~Zxwpb-EbnoS>?TIo*{=-t$ZBLge@Vph6f>4>#V0#!t~=B7igQU?x!RM?7d zvCW)|5{Ng_zS?L8Y30<66&kPwJivOehmX67MOJT~j)Yy^CFiFO#*fXR-0skB^c!qr#UU4 zKTz#k0?7hO-OWsr^09i%vRt{OwQ*u|n;_4HPxo*|P6~d{>VtHIi0xRTJLZjJl^?04 zI}p@u!zRzLWZ%(>0Fccdpih>3tEox;hQyLiCI5Zl6(EHkrdH0xdyWhV3gCDqA(QUK zj-bO|-58zUaGU#SB9+LdBiM~;aFA#gLk2`7V$|pl5 z6c?nDk(@?MV0sd0v6a!70vK8<+G;lljm7 z4YbUG)c(xFr0mlxQeL;=rOJL?qx36{%1KcIEMlN+L6G9uuBVafUJ?WZakK;_;qu3|e zUB`Tgwi-#Dk}cLm^`)%XfU*-|XXx}*p_oc7)ZS8f*=yO`AX{ALt1Q`~iXIQE3bdYA zdQo6RL2rJ(mB4nZrzcb{c}kN_>EdI1@9(f4rA&<0vRN>g$s@5>x|d? zrhtext=a5_aJVtjy36Uh(o{X-&Mlwpriag@6GMk88b~-PCUIGRu}8ew7#Do8jwK~4 z`vAdBSo>O|7OflV9Ufp!3KB@I4*=Aj+TOKv=R-9m>= z$~4@Du4#5MF2=H4{$WA<6WZ;y%J^{k@Q84E?3noKnz+39sdzT+#J&;sW0SFV(S#{x zadDU6cvk9=h3_a1muL3tUF4L9x<~+@1>l|v?(OAe$a%{SNP&Q3t54GrdIVs|B4`O^ z_df*#@XFnyt?8nfgnQ&a5`z<9AnnHbMDVqW>Dr&6yNN}?N%_xX(P=~SzwRZI50xGT z_zw)Mc5j_KEN9TbJC*rXD=OMx$CPcxYu)%!#JJnzF5^;P%sr!|%ijl9Cu9d_3v63oz!^#V&&!HL-a?1Ul`y*{-3FM5Y70;zqtc zsEvk&mOOH{h{=P-yY#J(_SP;Mgm=YXvMd0D*wC-`#e| zb8$;5%E?kqyOe(&lU%*CM?Jo5Y)#{QiJ3XgC$nYH zrlIAz66|!6s-WxaJIm=#3E#Z0+ z?W+HjE-jU41(V4)AY5h&t$Tf4yzipxf(Q3;>e7g`o=;^J^Tz8~T{dOoUw%&$#wkbY zczm-!@cW}p91=Kd@fhY4CZBo=P$M4K=|G%$SicrXak>}UIFsIIJfr9N3E06u$fz1= zgaFDa$Lxbn+lOZ(1wgd^QImRwf+xC~>)jC_Mqsm@2z2oo)hA?$@_06cYg@(0c3mSS zmfz_DTIlXvO&-0U@oKFc5JRHm1t|3MQ&lrL+CMg(#gh#gJZ6R~ZIvkYtYsLCuLOn% z>Iom26S=QT+9BYrFA!_Jw-M*?+wNE0?-K+vs$Q4IUIL`C%XnGp&g}?scID((tiHlT zGmzQ}Tv2HKk?UQ=a_dvX&vwB&xCSj428_)6Hk4*H?D1V|lTkVTMkbbJqGqGDM0kQb zQn%%NBS?5$Btco`WGI?xzFtK#xAV&hevr^%nk5s(Y~e199i{M)(|8KVd@UT@I6(!G z!}kuIl=ye8GAce;kztWAsmB92ORfj9yxRVGwr5%67Oy%j^Z1I~c9Z2m=weSzJSd6T z$A#hH1&BSdz%F|EQ>XScqY6#yDQ8((mewZA_NR|!O)eA73a2_mVD$K09?;IzIp%#O z;#_neio6|hL|U^Tt3azTJ|(yRs`GDfs9>uHwBu^D(1h zDDy^UnOCdz<|-}5I955dLO~{WR==ZZTIBL`COa`;aLXNnPKX#3TdoQRRkS|Mt0nQ) z)$~ZBu>7Ey=E9=&%Ra5$HDU`<55+?wwQk)dkokm~Z8`ItwN}hs|87vMOoJ8|D!3z( z)vJ}O)euZdn7(=^59(@KJk6P|r$ut3i(4)6!i>K7vBh)e=qj4*`qyRJB3p>-W;Nlc zF_j`0!;fEyhBcUoiFYu`jbK^P{;(?lh1KP~Y6la2?Yi$3vBbcCPzwu+)@)kIdHdlO z=WD-dC)Q4&O3KZC3ncwwV`2ymhLSR}48 z9c|y|_HM*3<0_s$N?m<(Q=@NjxR;CVC$B5G8)B;O18V!QR%9qm*{IfS~gNEhr)AyV01I!*Ta9A#Tb=maSnU()N^nHVX_+UJZUPA3s354MvZU znCq6&PU^cxR2grkKTVit7l*G?0{`((2yuqnPh|vU6q4D$udOOyY{$U7U#Eu#Uvykp zjUKy73ESv$#d?44@L)$t{j*aUu0$l)v^H4+@Vzk+VF%)&1U#G-8PPYiuX` zW9kg)N~&{N(os=8Zf-kBi)6ME=p{&R+>;WMrejZ;{p7w7vY4B!rWW01T0 zKt3v+y6or5g6EDN5`Dj7k71XCYVb~6l4KP4K>-v#gUEm%tu2lg`b4#>6+bVJEtKvu zWnV?;UZo^PO2j$EZcP+QC5f}IPP9##2{*ggejS$&iazTdiF!i9=-62uF_lL20@&=7 zWxM+u4^E5NO3C!6Bwb{S_CBQ@q-6eA*xLo0ocloE0cOZrFH+gRORz3`-_6NJ#4M`C zHFcALjC6kwlfrv-fdIX??Qm8KxXWJ-??jySs#0^&h}YMY!p3dQ*@`;ZagE1X2O$oo zJe#AC&-?DGR2kB$&&I1t8*9tLy__JRs!`>nPi3TXQev;86=CmJ zSAJPP;ezZIbr%=F<4dyZcMp>!4W;9do5 z2jNKz1uOSH0%3kHXrR}np&jCAN6>w}{T)|rc|(8Nu<+-Td|E7}<(yf4cG6w1AKAWL z-0~I0ju_|X!hPge z+-bP;;ho4-`K}`?W~``Fy{A<1ILGl)(C?3Qp;6-sVbR`Jp4|vq^j>X=!0tR|tOy0Y zofYfDJx5*G7Vd6}fh$f~F)cDccFtnYT4skwd-lLuL->50o{4TGhsqav`A;~VssT7v zj*9i>s1{F^MLQ9#Eyby^CI>A~^SCAl6X+bwE%?AHpVJ5_D;Z;69S})bU1-yV`LFPK zqh_U9YYodMPJ~56Ni6#&+C)|>;y?Fv(Ow`XU0u&38n2va-EDk%xN1rQh&45rEIg`w zoRS%~^i}uv-|C@rGSTr_;ze@Y3&@rfT0{LOP2-HhB3CYr9~L#p9?};SXJRb9ejhx5 zswS6Ck}-A9j}B+p@7~W2Ub;r^_R7j!HBC8e;ayf2bA5;5PBA1QTD?#_o7Gsf1=uld zL$?(Mcem;iHx?s-Yh?@p=j%wbxQW;Ww!*&8_6MxZcCmiHM_xiB+T%m-Z)^NB&$}7j z%AYXljL+DC*hbsS=_GQc7cZM*7}W%$=8NvjqamzWo+b%Xd4&vHWQ=|2UEMGB zUug=^EN(Jq4ir>hSf@k*oB86JhpT6EsoV%e+LkYFLZL0?9_C*eHk37UUFn!{XX9d1 zor9%{KD9>aWTeNy@1$KK-~p~)(9_I)sU;$T*I-&9mp?|Qa*%?C?bHA--2mgEr_V#I zJF`@U)(0PzXsc^0^Yx_bC+SyiNBt`4?KwNaGm&1GX01^-Nyze0i<{x|^3c0E{R#%8 zWxO*j-2aJW*N?%U(T(VN%}7g3BoClhbKtgRM|eGP4gq@>6g}wvP^-#nXLUbvXCZ z%~YA1bks|{*t7d$DeI~)G$nhV(7&L*PY}_12hMXCzTrd3g$>sHqoxB9ko*28OH5Zu zK&QkjLeWkiWA0ee2qn*8r!H7+f)2XNr*0cij<)>D-T(24&d2y)%Z<}ZrEHBj=+K|M zIz8V&xh)44aqea;l)&d5IOW!YvwB#wEjIxru#-pC(giaZeA8SGbLQ~ak>yqw9bmn? zG4cOO;&1LXl$}ltul5q$1>bEJT)ovQr2^Nr0w6UihW>N#p{Q2aIO}1`+c+veVN*77hQ&i2J>} z;+GwxXB1hq_z;2;RWMlmt(`Gfz*V3F<{ zyM6iO>=*m!Va>iRL|md8tB)yzF#-vKmyZd<%^!|DH5w11*nb z>h7W~=`5r#r0>3K#Cr7TN#lWZu?9_S;Zyc6o$Cww%6x@xL_?^zUYnmp&In9{)7(zcuqe@59fJ_ z|7>yY&;=-yMZ5;c(br~!x|@K~mV>5Ue}vfa4BO{{x*~theEq=StzI+85RB~!Q=zA` zW4Bs*skAlsBfOa?R=0ho^iHFDG1*sul}Ot(|j;x=WiF>!9juS zfL+1UJ-++R{q?SPzZ1rC^!%F(=cFj;w82OV*`lvXKY*V-1nDu6t=Wymb`SH zBTw>J!bINp6zL^ub-9Ij9 zR}}d=EsqGk4ShVR41KCIri6UFckjR0i2D6`h3N78pj-p^5S(}WZuKKjA_UypQyoL1 zN94%+2d@wH*8jNXPp!pA$gzR?@!u^?i~nf}3=$!~Kf~oen?be=uJD(!rUchN7eskg z+s^S^@Xrtf7v8=N9=zqJgDzm6QSMgH?QN#}W7~S!&7GzG`vRRCY=O()M=fYL*+Bk} z0dI-_@^_oldWvf|4LDO89yg4h(N1o4h(_lw{s>V!g=ED)DWSmBwSOH!9zgt9z zcCU7!7o-7Ge8=Ns?SaUry&GW(MD`PH>tK_W)pH=}8#O54tEB5KfY=4~@t%+;y%B^@ zEgFXG4e;;v(wDvDJ(+5T+GRC0L%nNWHtvl`kW~y0owi~YjdlXglVfTUbtH8nubUkV5`n}99SPwEPb6(~CQO0rvI+-*E$vY7tPr&MFBU3q?Wv^Bw_ z&DoltgUbH?XUl8D{YM27;h)u-1_A7I44E$-aDPO8OhnGybY$rP7&tRVfVN;Zb$-UV z=td7T>~KTFn>kA~${`b|@a2%AQ9GZC?*~hBWP~?zO%-}*t2n-HOam!A%om!e9$Tjt z$6Gabz$^2cCDd7~K~g?u%C`sjfIu7@qzih`NrV^gdzjRfgM83{^{+Po?msnVk(Z)( zZ~$PXULJtiaQ{#NSq%ILv7TnOeZ6bv+jjz0@noK};e4eFPTvF>KF^-*CIChU8j&SM zrLO0jMno8)%rQG7UyiZ)&mN*GVW2wv{D5JS;Q^u;rv5kfE6^z_L68xiEF8LuPLs8` zWI}&J+Aw39Lg?=~RfP`d|8_Z>V`VZTfqP2$JKEVs;e1StnvlxXg9$_KJsj{Dl{EW| zhV`4x(Ys?-WhMB?A#2Gjm^fFY*hpXu@Q<56Fvw)J?XF-^CKaXwmM+(l55{}b_7!|R zn6+KD3U20%mpy&vd@gqBq49TUOuLkJ^O&N`4bg#x@c7$ z0(kCun9PmY%R~5cTRSt3FM0RJ3)8wlmPwIa@Hu}NFFfB=U??cqbFZ$s!~di}xs1u( zE}BMHsly^T$Ze$)?duvydOmR!TSlzoaP#wr z$bC>&a~R{3ih!=B@KbkK1CT_a|;qT{ldqUCG3excyYWALw|zIaXtgq<|zXG#mFs z(QMn)nUuA9A`ZfRb!vUnyiW%9Lhc=3?AG{B@R0x}8qk z?4}6hu+3wR-En`$a}x5q85TE(TUBqY3B2Q== z#svn$suN&{=K0q1!$gGW!??}ve1EEXX1JB^Q zU)oBSt1TxAD}g~tWBAy|C&ViAZbTRf`2q37prGodV`uw8ZJ8Ui+s?NkzlXaozFC7^ zFc_358=JY{fyF3S+}nVqZG)1b!^!9usLhC^vqFQfyO`E*)8KSET)wm0R$s< ztYr^3EKmfG%<_2NafqDqUIV z^OV2a$m|=;NicI4Go`j~0A&->bg#oKAz11sf-#O6)Han%a4_Ni^$8ckM}N3Jq;H4n zFk=#La=HN43dZYaD_RtQ!evpdr_xem)ich@736+yK@aU&L>_+%6T+#i&zT zYv|YB_%*I^5JW*ME|_Y6W)==5=vOt)l16||+q_3wz2Ne$ej`(6G{tps;mpqXebOx| ze*b0IN|%C0(VH0AKU=+|+-*r6f676oiFqT5P;SWYX=ZfU3xd*xM~-a0>+YZ2F3;~r zunk?>x-5YuQt#Nib7hEod&g;1iq>T0$>|lCHocQYuTh(*)51{-N>ILp?5cXsb3*N{ zN9I#iRoDGg8aCb?Y%^$lUwdCPXP2g0yAKu&TZnvPnkA}|*Uj%=o`Bs7%`?t)6KRXH zxgYViv6Mao^C5~kfq&mJCdQ)c(iL9W)T8Am*`WXXND1=#2KLilZ+>h&$KgAA8<1v~ zbnJ0gEm@26BduZHECnNikxBa75~t%@{|GZuIcs*m9?8U&zp~8gy{R>_$K2*6O0?dR+18u*hK% z>Br;MXoYx&iyt_HHt_l`Vn^$W=;4)qJy5z4XKb}6KKB`$?H?lstVWQt-v#V}hF95A zpW2Coxg6J?kScpMed2w*Tx~v*Niza8aOPX#l;OlpgQ$;}wJ7aFM4Ecb3`zVuFI|4z z(bq}d{oaP%w|4=EPA~OX>d!uY>X=gKwwoxuW)kE`ePiQv;2Grp%zi@|$}3->O&o58 z8S(8lzun?x6m02Y@vIf@kIkLEC3t>$$ozr_%WfV2PB$=r^=buZKH(5moAtmKT!_>( zu7MR3d#TN!7k2?;-e+LA*;2=`gLJJo9@5~6ooFC!vv*@SOh&?X2Xy8uCxC}(HZ>f? zcWX-5Ro5l5jmNW(Ykf{ z{$*1@Qw&Ih0sTH%G<0ii7S?6M02T>q>NZ~lHs9#2?+ZMPVd-b4$!ttOZ8nbUj z_*1!Vn%lC}3~%)1HQ>#eL{I)PX%e65bf$B!ski$-LpNcEQ%~2A3g3$7YTN;;;`hoN;Lzn0}=Jm}F9bxm9}R-LY8r?Ncflw<@Ec%aV8a(N>Y2 zaiDxGhKXa~OSwcR0~fsG2>FN~ z)YXwE56Nv5>Hc~P{xV>`Azc>KTYe}%{_!FHI2PGrhG7!@Z5oYdiuV6d_WcYh9Un#|Kw?T%T2z41jl$(4lBI5FUXB$ z{M%qk1HR9f8BF22X&S@1pxx}a#VKGdJ@~F!G%b&OqI7g1&_xR>Y2O^XzlGY2Z@I7- zogbsmsIWKLU*yLlG;iJZbEzjV7CiCH^*lvSyO%JHZ)v|D*hD^kK_Q5;@z8&OG(g|< z`6pg}OYa?nbUA9H&SWKZvwU2np9&o90SuP)toM?7K{lCjOZ#UBxDRUS9?gmJrH~5) zcUkD<(}NfN_r6pZeTh8B44+0f)HcW2=ewS@`6&qbB!S?uFRE+e;(2YSgxAo4_f(q~4j7{-e}f2rk0>CB0J`e`Jd|+u9Tnxj z2R8m&W8f8oG4M}d1N7wo3~&5@h)V$1_kU^Nxgg3v;f;R+Ea^sfB&SM~E?NMpIZO;! zln7WRzHJzi;iOpnPADk(MkBXJ&;hh~@hvKZMtE5a43c#b3=}_Ze7T1wLT z|Mro&KNczZ!7VJTCJ4dKR9Q1KxW6q~C&AZ3l|Rt2BL?m>Kd!1co}4qL7;SJR(+6GT z^qyre{J_^qaPf}SE#nF-GV%D2(gcV_qERj3J|-aR zq0Qyt!3WnqV5tsg@!`kd?Yu%XhRflT*ImlNrtW>gj25MH-Ws2Y2i&7w(XUHJ#A*8G zuheKxvu?UKSX2#9Dkb^FL1taJ#c$Ej`ifG1c$N{-*WQ6`-ABlp?1iQY5 p4@@@PXx%7m^??(9DbC!W&5t(^Hck^AfmTtZ#N Date: Mon, 3 Aug 2026 09:07:15 +0200 Subject: [PATCH 21/69] fix(site/src/pages/UserSettingsPage/SecretsPage): prevent Add secret dialog overflow (#27649) (#27716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27649 Original PR: #27649 — fix(site/src/pages/UserSettingsPage/SecretsPage): prevent Add secret dialog overflow Merge commit: e30a7bcd0d310cd1a291d69e5f03e21aa5901e60 Requested by: @dylanhuff-at-coder Co-authored-by: dylanhuff-at-coder --- site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx index b33420c5b72..9b018c2b05f 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx @@ -275,11 +275,11 @@ export const SecretDialog: FC = ({ )}
- + or add individually - +
Date: Mon, 3 Aug 2026 16:35:37 +0200 Subject: [PATCH 22/69] fix(site): link spend page alert to cost control migration section (#27782) (#27785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27782 Original PR: #27782 — fix(site): link spend page alert to cost control migration section Merge commit: f4110122ec6f0925810729296741d02a72249b79 Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- .../pages/AISettingsPage/SpendPage/SpendPageView.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx index b8877849d27..3cf1aec74d3 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx @@ -235,11 +235,16 @@ export const SpendPageView: FC = ({ > {(userCtrl) => (
- + - Cost controls features will move to AI Governance in v2.36.{" "} + As of v2.36, AI Governance Cost Control replaces Coder Agents + Cost Control. The limits on this page are no longer enforced + and do not carry over. Recreate each limit as an AI Governance + budget to restore enforcement.{" "} From 19be37f2eaa30cdd529009347db47c62a6241aab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:35:57 +0200 Subject: [PATCH 23/69] feat(site): label AI spend as estimated (#27584) (#27793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27584 Original PR: #27584 — feat(site): label AI spend as estimated Merge commit: ec9b0f04d10c3f879528cd708098563bee2af203 Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- .../Navbar/UserDropdown/UserDropdown.stories.tsx | 12 ++++++------ .../Navbar/UserDropdown/UserDropdownAISpend.tsx | 2 +- site/src/pages/GroupsPage/AICostControl.tsx | 13 +++++++++++++ site/src/pages/GroupsPage/GroupMembersPage.tsx | 11 +++++++++-- site/src/pages/GroupsPage/GroupPage.stories.tsx | 6 ++++-- site/src/pages/GroupsPage/GroupsPageView.tsx | 10 +++++++++- 6 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 site/src/pages/GroupsPage/AICostControl.tsx diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 6260b96dc71..ff48b732541 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -73,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"); @@ -97,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"); @@ -144,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"); @@ -165,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(); @@ -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(); }); }, }; @@ -293,7 +293,7 @@ export const AISpendHiddenOnNegativeLimit: Story = { 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/UserDropdownAISpend.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdownAISpend.tsx index 2cff36afa45..17e9816b7bc 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 = ({ /> )}
- (AI spend/month) + (Estimated AI spend/month)
); diff --git a/site/src/pages/GroupsPage/AICostControl.tsx b/site/src/pages/GroupsPage/AICostControl.tsx new file mode 100644 index 00000000000..31f43623ae4 --- /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 = () => ( + + How spend is estimated + +); diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index 953a87c7cd6..fa8c1d7808b 100644 --- a/site/src/pages/GroupsPage/GroupMembersPage.tsx +++ b/site/src/pages/GroupsPage/GroupMembersPage.tsx @@ -52,6 +52,7 @@ import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { isEveryoneGroup } from "#/modules/groups"; import { cn } from "#/utils/cn"; import { formatBudgetUSD } from "#/utils/currency"; +import { SpendEstimateDocsLink } from "./AICostControl"; import { effectiveBudgetGroup, GroupMemberBudgetCells, @@ -112,7 +113,7 @@ const GroupMembersPage: FC = () => { }), ); const aiBudgetNote = [ - "Monthly AI spend for this user.", + "Estimated 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")}.`, @@ -173,7 +174,13 @@ const GroupMembersPage: FC = () => { message="AI spend couldn't be loaded, so budgets aren't shown." /> ) : ( - + + {aiBudgetNote} + + } + /> )} diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 47f782fad1b..e51514515b5 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -365,7 +365,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( @@ -434,7 +434,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}"); diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx index f7ed9fffd1d..79e44597904 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 = ({ message="AI spend couldn't be loaded, so budgets aren't shown." /> ) : ( - + + Estimated AI spend compared to the group's AI budget for + the active period. + + } + /> )} From 5cd6ebb1493b68c9af8a4081360bc589526c3c3f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:38:17 -0400 Subject: [PATCH 24/69] docs: lead with env vars in admin docs and add configuration reference (#26824) (#27806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/26824 Original PR: #26824 — docs: lead with env vars in admin docs and add configuration reference Merge commit: ba4779fc87ad1ad3f75e2a0cd2dca36cb06e1b7a Requested by: @nickvigilante Co-authored-by: Nick Vigilante Co-authored-by: Kayla (via Coder Agents) Co-authored-by: Coder Agents Co-authored-by: Ben Potter --- .claude/docs/DOCS_STYLE_GUIDE.md | 31 + .github/.linkspector.yml | 3 + Makefile | 16 + docs/admin/setup/configuration-reference.md | 1858 +++++++++++++++++++ docs/admin/setup/index.md | 5 + docs/admin/users/github-auth.md | 71 +- docs/manifest.json | 5 + scripts/configdocgen/main.go | 509 +++++ scripts/configdocgen/main_test.go | 239 +++ 9 files changed, 2705 insertions(+), 32 deletions(-) create mode 100644 docs/admin/setup/configuration-reference.md create mode 100644 scripts/configdocgen/main.go create mode 100644 scripts/configdocgen/main_test.go diff --git a/.claude/docs/DOCS_STYLE_GUIDE.md b/.claude/docs/DOCS_STYLE_GUIDE.md index ac3e6496072..1b12fef33f2 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/.github/.linkspector.yml b/.github/.linkspector.yml index 88ce877a0a9..0cc4d240480 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/Makefile b/Makefile index fd1af317c6b..b7ae184a19e 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/docs/admin/setup/configuration-reference.md b/docs/admin/setup/configuration-reference.md new file mode 100644 index 00000000000..3636d7cc1ed --- /dev/null +++ b/docs/admin/setup/configuration-reference.md @@ -0,0 +1,1858 @@ + +# 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 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..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: `/`. + +- 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 55419a16614..a2c4fe776d3 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/users/github-auth.md b/docs/admin/users/github-auth.md index 4d07abb1e2e..8735f110080 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 coder-v2/coder -n -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/manifest.json b/docs/manifest.json index 4ba60651477..7423ce1d4a7 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -423,6 +423,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.", diff --git a/scripts/configdocgen/main.go b/scripts/configdocgen/main.go new file mode 100644 index 00000000000..3bfb6efdc64 --- /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 = ` +# 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 00000000000..b2a475b49b7 --- /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") + } +} From 79a22f63196d4b1055bcba0e518e81032d379a38 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:44:30 +0200 Subject: [PATCH 25/69] docs: note user secret target uniqueness and delivery boundary (#27532) (#27809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27532 Original PR: #27532 — docs: note user secret target uniqueness and delivery boundary Merge commit: fe4a73f8ab1b784e8af6f197c20122d7993270bc Requested by: @dylanhuff-at-coder Co-authored-by: dylanhuff-at-coder Co-authored-by: Nick Vigilante --- docs/user-guides/user-secrets.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index ff2c862854d..df9494f6668 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -55,6 +55,10 @@ 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, @@ -95,9 +99,10 @@ the existing permissions alone. > a terminal in your workspace and run `rm `. 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 @@ -263,6 +268,12 @@ coder secret update api-key --env NEW_API_KEY 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 From 8f24c4a9d3860ac6d7ac7d4a15da624e47ff629a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:44:51 +0200 Subject: [PATCH 26/69] fix: support adding multiline secrets via the UI (#27253) (#27808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of https://github.com/coder/coder/pull/27253 Original PR: #27253 — fix: support adding multiline secrets via the UI Merge commit: f44e8c12d6951d0fd74d8c6930875b067e9328f6 Requested by: @dylanhuff-at-coder Co-authored-by: Rowan Smith Co-authored-by: Atif Ali --- .../pages/UserSettingsPage/SecretsPage/SecretDialog.tsx | 9 ++++----- .../SecretsPage/SecretsPageView.stories.tsx | 8 +++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx index 9b018c2b05f..b9181299dcb 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretDialog.tsx @@ -25,7 +25,6 @@ import { } 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"; @@ -441,13 +440,13 @@ const SecretValueField: FC = ({ {required ? Value : "Value"}
- = ({ } 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) { diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index 91a632afb54..2668ca7e621 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -418,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" })); From a33140de0a34531cd8eae88464de9da17b15e465 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Tue, 4 Aug 2026 09:02:09 +0200 Subject: [PATCH 27/69] chore: backport AI session network calls to 2.36 (#27417, #27418, #27425, #27426) (#27823) Backports the AI session network calls stack to `release/2.36`. Backport of #27417, #27418, #27425, #27426 --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Cian Johnston Co-authored-by: Marcin Tojek --- coderd/apidoc/docs.go | 38 +- coderd/apidoc/swagger.json | 38 +- coderd/database/db2sdk/db2sdk.go | 79 ++++- coderd/database/dbauthz/dbauthz.go | 14 + coderd/database/dbauthz/dbauthz_test.go | 12 + coderd/database/dbmetrics/querymetrics.go | 16 + coderd/database/dbmock/dbmock.go | 30 ++ coderd/database/querier.go | 41 +++ coderd/database/queries.sql.go | 185 +++++++++- coderd/database/queries/aibridge.sql | 110 +++++- codersdk/aibridge.go | 27 +- docs/ai-coder/ai-gateway/audit.md | 24 +- docs/reference/api/aigateway.md | 25 ++ docs/reference/api/schemas.md | 107 ++++-- enterprise/coderd/agentfirewall.go | 27 +- enterprise/coderd/aibridge.go | 41 ++- enterprise/coderd/aibridge_test.go | 328 ++++++++++++++++++ site/src/api/typesGenerated.ts | 34 +- .../ListSessionsPage/ListSessionsPageView.tsx | 2 +- .../NetworkCallBadges.stories.tsx | 4 +- .../pages/AIBridgePage/NetworkCallBadges.tsx | 24 +- .../AIBridgePage/NetworkRequestStates.tsx | 17 + .../SessionSummaryTable.stories.tsx | 81 +++++ .../SessionSummaryTable.tsx | 82 ++++- .../SessionThreadsPageView.tsx | 13 + .../NetworkCallsTable.stories.tsx | 127 +++++++ .../SessionTimeline/NetworkCallsTable.tsx | 189 ++++++++++ .../SessionTimeline.stories.tsx | 23 +- .../SessionTimeline/SessionTimeline.tsx | 19 + site/src/testHelpers/entities.ts | 48 +++ 30 files changed, 1701 insertions(+), 104 deletions(-) create mode 100644 site/src/pages/AIBridgePage/NetworkRequestStates.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.stories.tsx create mode 100644 site/src/pages/AIBridgePage/SessionThreadsPage/SessionTimeline/NetworkCallsTable.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 961f8d36391..3021e5b0fd8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15499,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" @@ -15535,6 +15535,17 @@ const docTemplate = `{ } } }, + "codersdk.AIBridgeSessionNetworkDomain": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, "codersdk.AIBridgeSessionThreadsResponse": { "type": "object", "properties": { @@ -15561,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" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7c8a4950e7b..5a9dd02720f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13803,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" @@ -13839,6 +13839,17 @@ } } }, + "codersdk.AIBridgeSessionNetworkDomain": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, "codersdk.AIBridgeSessionThreadsResponse": { "type": "object", "properties": { @@ -13865,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" diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 874db0e5258..b966afd71c4 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, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5b8036958c5..32333c7bfd8 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2806,6 +2806,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 +6804,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 ef0e45eaeb0..a652c37c232 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6897,6 +6897,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/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index db91a9772b7..b4944e1647a 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1105,6 +1105,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 +4753,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 a038a74957f..cb0ead8a046 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1903,6 +1903,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 +8891,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/querier.go b/coderd/database/querier.go index e3030339624..bcb6b722fd2 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -302,6 +302,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) @@ -1218,6 +1233,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) @@ -1228,6 +1263,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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8eafb9cbf8c..72f3812d4a3 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, diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 63635b6ae22..f3610aa31a6 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/codersdk/aibridge.go b/codersdk/aibridge.go index 66f754bae61..04b54744ca4 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -141,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". @@ -165,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"` @@ -185,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 diff --git a/docs/ai-coder/ai-gateway/audit.md b/docs/ai-coder/ai-gateway/audit.md index 9be227b6597..63c1161c732 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:///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/reference/api/aigateway.md b/docs/reference/api/aigateway.md index 3d8ed690729..18d1f862e38 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/schemas.md b/docs/reference/api/schemas.md index c4e7ad35ce1..aefacff8a52 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 diff --git a/enterprise/coderd/agentfirewall.go b/enterprise/coderd/agentfirewall.go index 928a99e0d99..2773031f25c 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 884bd23afdb..06e305b7fec 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) } diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 00c7b4dde7a..cba71e26178 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)) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 759c0a8cb10..551a358a6db 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[]; } diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx index bdc65d47e89..21299d54c36 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 = ({ Provider Client In/Out Tokens - Network Calls + Network Requests Threads diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx index 62d9af8f4a8..f391d020428 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 a4e621a94d0..2da53ba97cd 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 = ({ summary }) => { if (!summary) { - return ( - - Disabled - - - ); + return ; } if (summary.total === 0) { - return ( - - No activity - - ); + return ; } return ( @@ -63,7 +57,7 @@ export const NetworkCallBadges: FC = ({ summary }) => { >
- Total calls + Total requests {summary.total.toLocaleString("en-US")}
diff --git a/site/src/pages/AIBridgePage/NetworkRequestStates.tsx b/site/src/pages/AIBridgePage/NetworkRequestStates.tsx new file mode 100644 index 00000000000..2e436003e82 --- /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 = () => ( + + Disabled + + +); + +export const NetworkNoActivity: FC = () => ( + No activity +); diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx index 10bc86d21b3..b3e851ee4d3 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 7d6feaf93dc..389afad00c1 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; + // 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 = ; + } else if (networkCalls.total === 0) { + networkCallsValue = ; + } else { + networkCallsValue = ( + {networkCalls.total.toLocaleString("en-US")} + ); + } + return (
@@ -163,6 +196,53 @@ export const SessionSummaryTable = ({ {toolCallCount}
+ + + +
+
+ Network requests +
+
+ {networkCallsValue} +
+
+ + {networkCalls !== undefined && networkCalls.total > 0 && ( +
+
+ Blocked network requests +
+
+ {networkCalls.blocked > 0 ? ( + + + {networkCalls.blocked.toLocaleString("en-US")} + + ) : ( + {networkCalls.blocked.toLocaleString("en-US")} + )} +
+
+ )} + + {networkDomains !== undefined && ( +
+
+ Top domains +
+
+
+ {networkDomains.topDomain.domain} +
+ {networkDomains.totalCount > 1 && ( +
+ +{(networkDomains.totalCount - 1).toLocaleString("en-US")} more +
+ )} +
+
+ )}
); }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx index 4157c12d887..ba12f4eca13 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 = ({ 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 ( <>
+ {networkCallSummary && ( +
+ +
+ )} {/* threads */}
{threads.map((thread) => ( diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 38cf409ccd3..2c543dee09e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -5556,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", From 503124b83acaeb2dc105e6b341b4d13bbf442e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Tue, 4 Aug 2026 09:21:07 +0200 Subject: [PATCH 28/69] docs: add standalone AI Gateway docs (#27794) > Cherry pick of 18128b7b52038084425c53b7de75e51f9bc3ec18 Documents standalone AI Gateway deployment, Gateway key authentication, monitoring, and the updated embedded vs standalone topology in the AI Gateway docs. Co-authored-by: Cian Johnston Co-authored-by: Marcin Tojek --- docs/admin/infrastructure/architecture.md | 10 +- .../validated-architectures/index.md | 10 + docs/admin/integrations/prometheus.md | 11 +- docs/admin/users/sessions-tokens.md | 2 + .../ai-gateway/ai-gateway-proxy/setup.md | 3 +- docs/ai-coder/ai-gateway/auth.md | 163 ++++++--- docs/ai-coder/ai-gateway/clients/index.md | 5 + docs/ai-coder/ai-gateway/index.md | 4 +- docs/ai-coder/ai-gateway/mcp.md | 4 +- docs/ai-coder/ai-gateway/monitoring.md | 237 +++++++++---- docs/ai-coder/ai-gateway/providers.md | 10 +- docs/ai-coder/ai-gateway/reference.md | 90 ++++- docs/ai-coder/ai-gateway/setup.md | 21 +- docs/ai-coder/ai-gateway/standalone.md | 319 ++++++++++++++++++ docs/install/kubernetes.md | 8 + docs/manifest.json | 10 +- helm/ai-gateway/README.md | 2 +- scripts/metricsdocgen/metrics | 2 +- 18 files changed, 774 insertions(+), 137 deletions(-) create mode 100644 docs/ai-coder/ai-gateway/standalone.md diff --git a/docs/admin/infrastructure/architecture.md b/docs/admin/infrastructure/architecture.md index 4d3c85dc21e..79b709a6e91 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/validated-architectures/index.md b/docs/admin/infrastructure/validated-architectures/index.md index 9fa4968bf85..8c037a64337 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/prometheus.md b/docs/admin/integrations/prometheus.md index 26c16449caa..9364583cbe8 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`. + | Name | Type | Description | Labels | @@ -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` | diff --git a/docs/admin/users/sessions-tokens.md b/docs/admin/users/sessions-tokens.md index 8d314266948..f07e4e44746 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 ``` +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/ai-gateway/ai-gateway-proxy/setup.md b/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup.md index d0f735b7227..560bd2c0e93 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 `/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/auth.md b/docs/ai-coder/ai-gateway/auth.md index 0b2f907a5bf..3f2b9e076aa 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)@: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 `. +```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 e3e5513721c..ab1b66d7397 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/index.md b/docs/ai-coder/ai-gateway/index.md index 3fcbaf15f2c..c9755102d69 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 d78bcb21a74..2e446aa4811 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 a9b2b72f452..df648ef8ec9 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` | Recorded token-usage records for which no model price was found. | + +### 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 889689fd3c5..3e4abe7ad80 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 7714f4ec2ef..88dc6392984 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 `/api/v2/ai-gateway//`. +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 ebcb6f6cd77..91f48c120ed 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 00000000000..08fe70c4e60 --- /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='' +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='' +``` + +### 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 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 `/api/v2/ai-gateway//` 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//` 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 `/api/v2/ai-gateway`. +1. Restart or upgrade `coderd` so the proxy target change takes effect. +1. Restore direct client base URLs to `/api/v2/ai-gateway//`. +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/install/kubernetes.md b/docs/install/kubernetes.md index 8954a2b131a..80a6fa6cd91 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/manifest.json b/docs/manifest.json index 7423ce1d4a7..b7f38de4a83 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1194,7 +1194,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"], @@ -1205,6 +1205,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.", @@ -1330,7 +1336,7 @@ }, { "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"] }, diff --git a/helm/ai-gateway/README.md b/helm/ai-gateway/README.md index 40a5643f9c1..a1bce8edcbe 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/scripts/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index 70ae90436c6..6464f6ff94b 100644 --- a/scripts/metricsdocgen/metrics +++ b/scripts/metricsdocgen/metrics @@ -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). From 46dd810fd2fd5c423f3b275bdb3f18e9bfd1472c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Tue, 4 Aug 2026 09:21:42 +0200 Subject: [PATCH 29/69] fix(helm/ai-gateway): render service nodePort with an explicit if guard (#27795) > Cherry picks e819dd4af6a0c220f418128f86dc6f1eeaee3622 6df24634fd17b4a1ef9a66253a42ba1361a14c10 Adds explicit if guard to service in AI Gateway helm chart around `nodePort`. --------- Co-authored-by: Marcin Tojek --- helm/ai-gateway/templates/service.yaml | 4 +- helm/ai-gateway/tests/chart_test.go | 87 +++++++++++++++++++ .../tests/testdata/default_values.golden | 51 +++++++++++ .../tests/testdata/default_values.yaml | 10 +++ helm/ai-gateway/values.schema.json | 2 +- 5 files changed, 151 insertions(+), 3 deletions(-) diff --git a/helm/ai-gateway/templates/service.yaml b/helm/ai-gateway/templates/service.yaml index e32cac88de5..4e17d53e739 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 835a22fa51a..363f55d5de6 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 66be5cd0566..78c3938bfcb 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 5eb707ca0cd..46be6740cde 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 bb41786cfab..efb0cb25940 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"]}, From 17773d3cc59c78d5578eac24ab1dd8494c3a896c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:55:07 +0200 Subject: [PATCH 30/69] fix: remove native usage-limit controls from 2.36 (#27825) Removes the release 2.36 frontend controls for native chat usage limits and keeps the Spend page focused on usage reporting. The Agents UI now uses AI Gateway spend status, and native-limit errors no longer link to the removed controls. Updates the documentation to clarify that existing native values remain stored and enforced on release 2.36, are not migrated, and that new AI Gateway budgets are configured through group settings or the generated group budget API reference. The backend behavior and database are intentionally unchanged, with no migration. > Mux prepared this PR on Mike's behalf. --- .../agents/platform-controls/index.md | 11 +- .../platform-controls/usage-insights.md | 111 ++--- docs/ai-coder/ai-gateway/cost-controls.md | 40 +- docs/manifest.json | 2 +- site/src/api/api.ts | 65 --- site/src/api/queries/chats.ts | 85 ---- .../AISettingsPage/SpendPage/SpendPage.tsx | 100 +---- .../SpendPage/SpendPageView.stories.tsx | 196 +-------- .../SpendPage/SpendPageView.tsx | 391 +----------------- .../components/ChatCostSummaryView.tsx | 118 +----- .../LimitsTab/DefaultLimitController.tsx | 65 --- .../LimitsTab/DefaultLimitSection.tsx | 137 ------ .../components/LimitsTab/GroupLimitDialog.tsx | 183 -------- .../LimitsTab/GroupLimitsSection.stories.tsx | 182 -------- .../LimitsTab/GroupLimitsSection.tsx | 189 --------- .../components/LimitsTab/GroupLimitsTable.tsx | 167 -------- .../LimitsTab/GroupOverrideController.tsx | 141 ------- .../LimitsTab/UserOverrideController.tsx | 131 ------ .../LimitsTab/UserOverrideDialog.tsx | 157 ------- .../UserOverridesSection.stories.tsx | 155 ------- .../LimitsTab/UserOverridesSection.tsx | 170 -------- .../LimitsTab/UserOverridesTable.tsx | 144 ------- .../LimitsTab/limitsFormLogic.test.ts | 10 - .../components/LimitsTab/limitsFormLogic.ts | 14 - .../components/UsageTab/UsageTab.tsx | 57 +-- .../AgentsPage/AgentsPageLayout.stories.tsx | 8 - .../components/AgentCreateForm.stories.tsx | 9 + .../AgentsPage/components/AgentCreateForm.tsx | 11 +- .../LiveStreamTail.stories.tsx | 13 +- .../ChatConversation/LiveStreamTail.tsx | 11 +- .../components/UsageIndicator.stories.tsx | 136 ++++-- .../AgentsPage/components/UsageIndicator.tsx | 57 +-- site/src/pages/GroupsPage/AIBudgetPeriod.tsx | 8 +- .../pages/GroupsPage/GroupPage.stories.tsx | 3 +- site/src/utils/budget.test.ts | 15 + site/src/utils/budget.ts | 18 + 36 files changed, 258 insertions(+), 3052 deletions(-) delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitController.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/DefaultLimitSection.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitDialog.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.stories.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsSection.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupLimitsTable.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/GroupOverrideController.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideController.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverrideDialog.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.stories.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesSection.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/UserOverridesTable.tsx delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.test.ts delete mode 100644 site/src/pages/AISettingsPage/SpendPage/components/LimitsTab/limitsFormLogic.ts diff --git a/docs/ai-coder/agents/platform-controls/index.md b/docs/ai-coder/agents/platform-controls/index.md index d8b7469c47b..53bc04d0a4d 100644 --- a/docs/ai-coder/agents/platform-controls/index.md +++ b/docs/ai-coder/agents/platform-controls/index.md @@ -116,12 +116,13 @@ 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. +The Spend page provides usage-only reporting for Coder Agents chats. The native +usage-limit configuration UI has been removed from release 2.36, but values +configured before upgrading remain stored and enforced. Existing native values +are not migrated to AI Gateway budgets. -See [Spend Management](./usage-insights.md) for details. +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 b6b2d1e5db1..3ffca0273ec 100644 --- a/docs/ai-coder/agents/platform-controls/usage-insights.md +++ b/docs/ai-coder/agents/platform-controls/usage-insights.md @@ -1,90 +1,51 @@ # 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 two independent ways to +limit spend on release 2.36: existing native chat usage limits and AI Gateway +budgets. -## Usage limits +## Native chat usage limits -Navigate to **Agents** > **Settings** > **Manage Agents** > **Spend**. +The native usage-limit configuration UI has been removed from release 2.36. +Values configured before upgrading remain stored and enforced. Coder checks the +user's current spend before processing each chat message and returns a **409 +Conflict** response when the applicable limit is reached. -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. +Existing native values are not migrated to AI Gateway budgets. To change spend +controls after upgrading, configure new AI Gateway budgets. Native limits remain +in effect until they are changed through the existing experimental API or +removed in a later release. -### 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. - -> [!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. +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. diff --git a/docs/ai-coder/ai-gateway/cost-controls.md b/docs/ai-coder/ai-gateway/cost-controls.md index 52c4fa13c44..e6bec820274 100644 --- a/docs/ai-coder/ai-gateway/cost-controls.md +++ b/docs/ai-coder/ai-gateway/cost-controls.md @@ -270,35 +270,31 @@ see [Prometheus metrics](../../admin/integrations/prometheus.md). ## Migrate from Coder Agents Cost Control -In v2.36, AI Governance Cost Control replaces Coder Agents Cost Control. The -legacy Coder Agents Spend page remains available until v2.37. +In release 2.36, the native Coder Agents usage-limit configuration UI is no +longer available. Existing native values remain stored and enforced, and are not +migrated to AI Gateway budgets. -> [!WARNING] -> Spend limits configured under **Admin settings** > **AI** > **Spend** are no -> longer enforced by Coder Agents. To enforce spend, set an AI Governance budget. - -To migrate existing limits: - -1. Record the limits currently set under **Admin settings** > **AI** > - **Spend**, including the default limit and any group or user overrides. -1. Recreate group limits as [group budgets](#group-budget). -1. Recreate per-user limits as [user overrides](#user-override). +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: -- No deployment-wide default exists. Each group that needs a limit requires its - own budget. -- The UTC calendar month is the only period. Daily and weekly periods are not - currently supported. -- Users in several budgeted groups receive the highest budget. Coder Agents Cost - Control applied the lowest. +- Native limits and AI Gateway budgets are independent. A user can be subject to + both until native limits are removed in a later release. +- 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 apply 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 does not carry over. Every user starts the first period at - $0 USD. -- Coder Agents users who exceed their budget see a usage limit error in chat. - The error details include the AI Governance budget limit. +- Recorded spend and native limit values do not carry over to AI Gateway + budgets. ## Next steps diff --git a/docs/manifest.json b/docs/manifest.json index b7f38de4a83..99d5cdab331 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1079,7 +1079,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"] }, diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b87d08a9f2b..46248aceea1 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3948,71 +3948,6 @@ class ExperimentalApiMethods { const response = await this.axios.get(url); return response.data; }; - - getChatUsageLimitConfig = - async (): Promise => { - const response = - await this.axios.get( - "/api/experimental/chats/usage-limits", - ); - return response.data; - }; - - getChatUsageLimitStatus = - async (): Promise => { - const response = await this.axios.get( - "/api/experimental/chats/usage-limits/status", - ); - return response.data; - }; - - updateChatUsageLimitConfig = async ( - req: TypesGen.ChatUsageLimitConfig, - ): Promise => { - const response = await this.axios.put( - "/api/experimental/chats/usage-limits", - req, - ); - return response.data; - }; - - upsertChatUsageLimitOverride = async ( - userID: string, - req: TypesGen.UpsertChatUsageLimitOverrideRequest, - ): Promise => { - const response = await this.axios.put( - `/api/experimental/chats/usage-limits/overrides/${encodeURIComponent(userID)}`, - req, - ); - return response.data; - }; - - deleteChatUsageLimitOverride = async (userID: string): Promise => { - 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 => { - const response = await this.axios.put( - `/api/experimental/chats/usage-limits/group-overrides/${encodeURIComponent(groupID)}`, - req, - ); - return response.data; - }; - - deleteChatUsageLimitGroupOverride = async ( - groupID: string, - ): Promise => { - 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 a23e7efd7d3..751696b414a 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/pages/AISettingsPage/SpendPage/SpendPage.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPage.tsx index 19306a7cc98..1acc4dade0f 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 = ({ 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 = ({ 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 = ({ now }) => { return ( 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 = ({ now }) => { isSummaryLoading={summaryQuery.isLoading} summaryError={summaryQuery.error} onSummaryRetry={() => void summaryQuery.refetch()} - activeTab={activeTab} - onActiveTabChange={onActiveTabChange} /> ); diff --git a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.stories.tsx index 91e63fb29bd..83345076a61 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,84 +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 ( - { - 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; export default meta; type Story = StoryObj; -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.getByText( - /Cost controls features will move to AI Governance in v2\.36\./, - ), - ).toBeInTheDocument(); - await expect( - canvas.getByRole("link", { name: /Read more here/ }), - ).toHaveAttribute( - "href", - expect.stringContaining("/ai-coder/ai-gateway/cost-controls"), - ); - 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", @@ -344,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(); @@ -354,8 +212,6 @@ export const SpendUsersEmpty: Story = { export const SpendUserDrillIn: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: mockUserProfile, summaryData: mockCostSummary, @@ -373,8 +229,6 @@ export const SpendUserDrillIn: Story = { export const SpendUserDrillInAndBack: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: mockUserProfile, summaryData: mockCostSummary, @@ -392,8 +246,6 @@ export const SpendUserDrillInAndBack: Story = { export const SpendDrillInLoading: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: null, isDrillInUserLoading: true, @@ -408,8 +260,6 @@ export const SpendDrillInLoading: Story = { export const SpendDrillInError: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, drillInUserId: "user-1", drillInUser: null, isDrillInUserError: true, @@ -424,8 +274,6 @@ export const SpendDrillInError: Story = { export const SpendRefetchOverlay: Story = { args: { - configData: mockConfigData, - groupsData: mockGroups, usersQuery: mockUsersQuery({ data: mockUsersResponse, isFetching: true, @@ -434,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( @@ -444,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(); @@ -489,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"), }), @@ -498,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(); @@ -511,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 3cf1aec74d3..773af71f2c5 100644 --- a/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/SpendPageView.tsx @@ -1,80 +1,17 @@ import type { FC } from "react"; -import { getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Button } from "#/components/Button/Button"; import type { DateRangeValue } from "#/components/DateRangePicker/DateRangePicker"; -import { Link } from "#/components/Link/Link"; import type { PaginationResult } from "#/components/PaginationWidget/PaginationContainer"; import { SettingsHeader, 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 { docs } from "#/utils/docs"; -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; @@ -99,34 +36,9 @@ interface SpendPageViewProps { isSummaryLoading: boolean; summaryError: unknown; onSummaryRetry: () => void; - activeTab: "limits" | "usage"; - onActiveTabChange: (tab: "limits" | "usage") => void; } export const SpendPageView: FC = ({ - 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, @@ -145,63 +57,12 @@ export const SpendPageView: FC = ({ 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>((acc, group) => { - acc[group.id] = group.organization_name; - return acc; - }, {}) ?? {}; - const { isSavedVisible, showSavedState } = useTemporarySavedState(); - if (drillInUserId) { return ( = ({ /> ); } - return ( - - {(groupCtrl) => ( - - {(userCtrl) => ( -
- - - As of v2.36, AI Governance Cost Control replaces Coder Agents - Cost Control. The limits on this page are no longer enforced - and do not carry over. Recreate each limit as an AI Governance - budget to restore enforcement.{" "} - - Read more here - - - - - - - Spend limits and usage - - - Configure spend limits and monitor AI usage across your - deployment. - - - - { - if (tab === "limits" || tab === "usage") { - onActiveTabChange(tab); - } - }} - > - - Spend limits - Usage - - - -
- {isLoadingConfig ? ( -
- -
- ) : configError ? ( -
- - -
- ) : ( - <> - - {({ - enabled, - onEnabledChange, - period, - onPeriodChange, - amountDollars, - onAmountDollarsChange, - isAmountValid, - isDirty, - saveDefault, - }) => ( - { - 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 ? ( - - ) : updateConfigError ? ( - getErrorMessage( - updateConfigError, - "Failed to save the default spend limit.", - ) - ) : null - } - /> - )} - -
- - { - userCtrl.handleShowUserFormChange(false); - groupCtrl.handleEditGroupOverride(override); - }} - onAddGroupOverride={ - groupCtrl.handleAddGroupOverride - } - onDeleteGroupOverride={onDeleteGroupOverride} - upsertPending={isUpsertingGroupOverride} - upsertError={upsertGroupOverrideError} - deletePending={isDeletingGroupOverride} - deleteError={deleteGroupOverrideError} - groupsError={groupsError} - /> -
- -
- - { - groupCtrl.handleShowGroupFormChange(false); - userCtrl.handleEditUserOverride(override); - }} - onAddOverride={userCtrl.handleAddOverride} - onDeleteOverride={onDeleteOverride} - upsertPending={isUpsertingOverride} - upsertError={upsertOverrideError} - deletePending={isDeletingOverride} - deleteError={deleteOverrideError} - /> -
- - )} -
-
+ return ( +
+ + AI spend usage + + Monitor AI usage across your deployment. + + - - { - groupCtrl.handleShowGroupFormChange(false); - userCtrl.handleEditUserOverride(override); - }} - /> - - -
- )} - - )} - + +
); }; diff --git a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx index 4f1e954f550..622cf1eec25 100644 --- a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx @@ -1,5 +1,4 @@ -import dayjs from "dayjs"; -import { InfoIcon, TriangleAlertIcon } from "lucide-react"; +import { TriangleAlertIcon } from "lucide-react"; import { type FC, useState } from "react"; import { getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; @@ -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 = ({ summary, isLoading, @@ -110,41 +90,6 @@ export const ChatCostSummaryView: FC = ({ 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 (
@@ -201,59 +146,6 @@ export const ChatCostSummaryView: FC = ({
- {showUsageLimitCard && usageLimit && ( -
-
-
-
-

- {usageLimitPeriodLabel} spend limit -

- {usageLimitCurrentPeriod && ( -

- {usageLimitCurrentPeriod} -

- )} -

- {formatCostMicros(usageLimitCurrentSpend)} /{" "} - {formatCostMicros(usageLimitSpendMicros)} -

-
-

- {Math.round(usageProgressPercentage)}% used -

-
-
-
-
-
-

- {usageLimitStatusText} -

-

- Resets {usageLimitResetAt} -

-
-
-
- )} - {summary.unpriced_messages_having_usage_count > 0 && (
@@ -266,14 +158,6 @@ export const ChatCostSummaryView: FC = ({
)} -
- - - Automatic title generation uses lightweight models and is not counted - towards usage limits. - -
- {summary.by_model.length === 0 && summary.by_chat.length === 0 ? (

{emptyMessage} 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 855a25e0dac..00000000000 --- 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 = ({ - initialValues, - onSave, - children, -}) => { - const [enabled, setEnabled] = useState(initialValues.enabled); - const [period, setPeriod] = useState( - 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 3e837469e9d..00000000000 --- 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 = ({ - enabled, - onEnabledChange, - period, - onPeriodChange, - amountDollars, - onAmountDollarsChange, - unpricedModelCount, - isSaving = false, - isSavedVisible = false, - saveDisabled = false, - onSave, - saveStatus, -}) => { - return ( -

-
- -
-

- Spend limit -

-

- Set a deployment-wide spend cap that applies to all users by - default. When disabled, users have unlimited spending. -

-
- - $ - onAmountDollarsChange(event.target.value)} - placeholder="0.00" - aria-label="Spend limit amount in dollars" - disabled={!enabled} - /> - - -
- {(onSave || isSavedVisible || isSaving) && - (isSavedVisible ? ( - saveStatus - ) : ( - - ))} -
-
- {saveStatus && !isSavedVisible && ( -
{saveStatus}
- )} -
-
- - {enabled && unpricedModelCount > 0 && ( -
- -
- {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. -
-
- )} -
- ); -}; 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 b3c8e555219..00000000000 --- 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 = ({ - 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 ( - { - if (upsertPending) { - return; - } - - onOpenChange(nextOpen); - if (!nextOpen) { - onSelectedGroupChange(null); - onGroupAmountChange(""); - } - }} - > - - - - {isEditing ? "Update group budget" : "Add group budget"} - - - {isEditing - ? "Update this group's spend limit override." - : "Set a spend limit override for a specific group."} - - - -
-
- {editingGroupOverride ? ( - <> - -
- -
- - ) : ( - <> - - group.id} - getOptionLabel={(group) => group.display_name || group.name} - isOptionEqualToValue={(option, optionValue) => - option.id === optionValue.id - } - renderOption={(option, isSelected) => ( -
- - {isSelected && } -
- )} - placeholder="Search groups..." - noOptionsText={groupAutocompleteNoOptionsText} - loading={groupsLoading} - disabled={groupsLoading} - className="w-full" - /> - - )} -
-
- - onGroupAmountChange(event.target.value)} - placeholder="0.00" - /> -
- {upsertError && ( -

- {getErrorMessage(upsertError, "Failed to save group override.")} -

- )} -
- - - - - -
-
- ); -}; 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 eb1963020ac..00000000000 --- 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 = { - 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; - -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 b0914b6e6bf..00000000000 --- 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; - 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 = ({ - 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 ( -
- {!hideHeader && ( - - )} -
-
- { - setSearchQuery(value); - setPage(1); - }} - placeholder="Search groups..." - aria-label="Search group limits" - className="w-full sm:max-w-md" - /> - -
- - - - {deleteError && ( -

- {getErrorMessage(deleteError, "Failed to delete group override.")} -

- )} - - - {groupsError && ( -

- {getErrorMessage(groupsError, "Failed to load groups.")} -

- )} -
- {pendingDeleteGroupId && ( - { - void onDeleteGroupOverride(pendingDeleteGroupId); - setPendingDeleteGroupId(null); - }} - isPending={deletePending} - open - onOpenChange={(open) => !open && setPendingDeleteGroupId(null)} - /> - )} -
- ); -}; 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 56ff6084346..00000000000 --- 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; - deletePending: boolean; - upsertPending: boolean; - isEditing: boolean; - emptyMessage: string; -} - -export const GroupLimitsTable: FC = ({ - pagedOverrides, - totalOverrides, - clampedPage, - hasPreviousPage, - hasNextPage, - onPageChange, - onEditGroupOverride, - onRequestDelete, - groupOrganizationNames, - deletePending, - upsertPending, - isEditing, - emptyMessage, -}) => { - if (totalOverrides === 0) { - return ( -
- {emptyMessage} -
- ); - } - - return ( - <> -
-
- - - Group - Members - Spend limit - Actions - - - - {pagedOverrides.map((override) => { - const orgName = groupOrganizationNames?.[override.group_id]; - const groupAvatar = ( - - ); - - return ( - - - {orgName ? ( - - {groupAvatar} - - ) : ( - groupAvatar - )} - - {override.member_count} - - {override.spend_limit_micros !== null - ? formatCostMicros(override.spend_limit_micros) - : "Unlimited"} - - - - - - - - onEditGroupOverride(override)} - > - Update budget - - onRequestDelete(override.group_id)} - > - - Remove group limit - - - - - - ); - })} - -
- - - {totalOverrides > GROUP_LIMITS_PAGE_SIZE && ( - - )} - - ); -}; 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 2ccd1ae44fa..00000000000 --- 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; - availableGroups: Group[]; - groupAutocompleteNoOptionsText: string; -}; - -interface GroupOverrideControllerProps { - groupOverrides: ReadonlyArray<{ group_id: string }>; - groups: ReadonlyArray; - isLoadingGroups: boolean; - onUpsertGroupOverride: (args: { - groupID: string; - req: UpsertChatUsageLimitGroupOverrideRequest; - onSuccess: () => void; - }) => void; - children: (props: GroupOverrideChildProps) => ReactNode; -} - -export const GroupOverrideController: FC = ({ - groupOverrides, - groups, - isLoadingGroups, - onUpsertGroupOverride, - children, -}) => { - const [showGroupForm, setShowGroupForm] = useState(false); - const [selectedGroup, setSelectedGroup] = useState(null); - const [groupAmount, setGroupAmount] = useState(""); - const [editingGroupOverride, setEditingGroupOverride] = - useState(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 99f406592a1..00000000000 --- 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; - 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 = ({ - overrides, - onUpsertOverride, - children, -}) => { - const [showUserForm, setShowUserForm] = useState(false); - const [selectedUserOverride, setSelectedUserOverride] = useState( - null, - ); - const [userOverrideAmount, setUserOverrideAmount] = useState(""); - const [editingUserOverride, setEditingUserOverride] = - useState(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 5226f10687e..00000000000 --- 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 = ({ - 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 ( - { - if (upsertPending) { - return; - } - - onOpenChange(nextOpen); - if (!nextOpen) { - onSelectedUserChange(null); - onUserOverrideAmountChange(""); - } - }} - > - - - - {isEditing ? "Update user budget" : "Add user budget"} - - - {isEditing - ? "Update this user's spend limit override." - : "Set a spend limit override for a specific user."} - - - -
-
- {editingUserOverride ? ( - <> - -
- -
- - ) : ( - - )} -
-
- - - onUserOverrideAmountChange(event.target.value) - } - placeholder="0.00" - /> -
- {!isEditing && selectedUserAlreadyOverridden && ( -

- This user already has an override. -

- )} - {upsertError && ( -

- {getErrorMessage(upsertError, "Failed to save the override.")} -

- )} -
- - - - - -
-
- ); -}; 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 7486a749c9a..00000000000 --- 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 = { - 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) => ( - - - - ), - ], -}; - -export default meta; -type Story = StoryObj; - -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 15b8c4832e9..00000000000 --- 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 = ({ - 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( - 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 ( -
- {!hideHeader && ( - - )} -
-
- { - setSearchQuery(value); - setPage(1); - }} - placeholder="Search users..." - aria-label="Search user overrides" - className="w-full sm:max-w-md" - /> - -
- - - - {deleteError && ( -

- {getErrorMessage(deleteError, "Failed to delete override.")} -

- )} - - -
- {pendingDeleteUserId && ( - { - void onDeleteOverride(pendingDeleteUserId); - setPendingDeleteUserId(null); - }} - isPending={deletePending} - open - onOpenChange={(open) => !open && setPendingDeleteUserId(null)} - /> - )} -
- ); -}; 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 1ab125f2c45..00000000000 --- 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 = ({ - pagedOverrides, - totalOverrides, - clampedPage, - hasPreviousPage, - hasNextPage, - onPageChange, - onEditUserOverride, - onRequestDelete, - deletePending, - upsertPending, - isEditing, - emptyMessage, -}) => { - if (totalOverrides === 0) { - return ( -
- {emptyMessage} -
- ); - } - - return ( - <> -
- - - - User - Spend limit - Actions - - - - {pagedOverrides.map((override) => ( - - - - - - {override.spend_limit_micros !== null - ? formatCostMicros(override.spend_limit_micros) - : "Unlimited"} - - - - - - - - onEditUserOverride(override)} - > - Update budget - - onRequestDelete(override.user_id)} - > - - Remove override - - - - - - ))} - -
- -
- {totalOverrides > USER_OVERRIDES_PAGE_SIZE && ( - - )} - - ); -}; 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 97307cee086..00000000000 --- 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 63da616d9cc..00000000000 --- 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 c3d5f634605..676a89614e3 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 = ({ @@ -69,9 +52,7 @@ export const UsageTab: FC = ({ searchFilter, onSearchFilterChange, usersQuery, - overrides, onSelectUser, - onEditBudget, }) => { return (
@@ -146,7 +127,6 @@ export const UsageTab: FC = ({ Output Cache Read Cache Write - Actions @@ -155,18 +135,6 @@ export const UsageTab: FC = ({ 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); - }} /> ))} @@ -183,8 +151,7 @@ export const UsageTab: FC = ({ 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<{ {formatTokenCount(user.total_cache_creation_tokens)} - event.stopPropagation()}> - - - - - - onEditBudget(user)}> - Update budget - - onSelect(user)}> - View spend details - - - - ); }; diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index b95f88258cd..8bb6602fff9 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -500,14 +500,6 @@ const meta: Meta = { 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 5e72778eb50..e38c344494d 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -814,6 +814,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 = { diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 358a1d70b3c..0a494c01dcf 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 { 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"; @@ -515,14 +513,7 @@ export const AgentCreateForm: FC = ({ isApiError(createError) && createError.response?.status === 409 && isChatUsageLimitExceededResponse(createError.response.data) ? ( - - View usage - - } - > + {formatUsageLimitMessage(createError.response.data)} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index 0cf96976d4a..748f650fb91 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 b4b732a21b9..48efbbd58ee 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 ? ( - - View usage - - } - > + {usageLimitStatus.message} ) : terminalStatus ? ( diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.stories.tsx index 1d177bfbb27..09a671e0641 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 ; }; @@ -79,22 +79,23 @@ const openUsageMenu = async (canvasElement: HTMLElement) => { await userEvent.click(canvas.getByRole("button")); }; -const limitedUsageStatus = ( - overrides: Partial = {}, -): 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 => ({ + 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 = { parameters: { user: MockUserOwner, permissions: MockPermissions, + features: ["aibridge"], }, }; @@ -128,18 +130,20 @@ type Story = StoryObj; 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 3e582864daf..872e20b76a1 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: , 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[] }> = ({ ))} - - - - - View usage - ); diff --git a/site/src/pages/GroupsPage/AIBudgetPeriod.tsx b/site/src/pages/GroupsPage/AIBudgetPeriod.tsx index b716da63136..3ab592efa35 100644 --- a/site/src/pages/GroupsPage/AIBudgetPeriod.tsx +++ b/site/src/pages/GroupsPage/AIBudgetPeriod.tsx @@ -1,8 +1,8 @@ -import dayjs from "dayjs"; import type { FC } from "react"; import { useQuery } from "react-query"; import { meAISpend } from "#/api/queries/users"; 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 = () => { @@ -13,12 +13,10 @@ export const AIBudgetPeriod: FC = () => { 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 ( - AI budget period: {start} - {end} + AI budget period:{" "} + {formatSpendPeriodLabel(aiSpend.period_start, aiSpend.period_end)} ); }; diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index e51514515b5..3556d507d75 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -348,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( diff --git a/site/src/utils/budget.test.ts b/site/src/utils/budget.test.ts index 7ee182c8fa8..a475eaa6159 100644 --- a/site/src/utils/budget.test.ts +++ b/site/src/utils/budget.test.ts @@ -1,10 +1,25 @@ import { describe, expect, it } from "vitest"; import { clampPercentage, + formatSpendPeriodLabel, getSeverity, usageProgressPercentage, } from "./budget"; +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("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 6b14b76970f..78d537a2445 100644 --- a/site/src/utils/budget.ts +++ b/site/src/utils/budget.ts @@ -1,5 +1,23 @@ +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}`; +} + /** * 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 From a300a06a0da0dced695ef8df8404c7c4dffd337e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:11:09 +0200 Subject: [PATCH 31/69] fix: correct native usage-limit docs and spend UI details for 2.36 (#27827) Follow-up to #27825. Native chat usage-limit enforcement was already removed from `release/2.36` by #27599 (`checkUsageLimit` is a no-op), but the docs added in #27825 claimed stored native values are still enforced per message with a 409 response. This corrects the docs and folds in small spend UI fixes found during UAT. ## Docs - State that native limits are no longer configurable or enforced as of release 2.36, stored values are inert and not migrated, and AI Gateway budgets are the only enforcement mechanism (`usage-insights.md`, `platform-controls/index.md`, `cost-controls.md`). - Restore the warning that spend limits previously configured under Admin settings > AI > Spend are no longer enforced. - Drop the migration bullet claiming users can be subject to both native limits and budgets. - Restore the note that automatic title generation is not included in usage reporting. ## Frontend - Render the group members page AI spend reset date in UTC via a shared `formatSpendResetLabel` helper, matching the budget period formatting elsewhere. Non-UTC viewers previously saw two different dates for one period. - Pin a non-UTC timezone in `budget.test.ts` so CI fails if formatting falls back to local time (verified red under the old implementation). - Restore the title-generation note on the cost summary, reworded for usage reporting. > Mux prepared this PR on Mike's behalf. --- .../agents/platform-controls/index.md | 9 ++++---- .../platform-controls/usage-insights.md | 23 ++++++++++--------- docs/ai-coder/ai-gateway/cost-controls.md | 13 +++++++---- .../components/ChatCostSummaryView.tsx | 10 +++++++- .../src/pages/GroupsPage/GroupMembersPage.tsx | 6 ++--- site/src/utils/budget.test.ts | 11 +++++++++ site/src/utils/budget.ts | 8 +++++++ 7 files changed, 55 insertions(+), 25 deletions(-) diff --git a/docs/ai-coder/agents/platform-controls/index.md b/docs/ai-coder/agents/platform-controls/index.md index 53bc04d0a4d..2465624963e 100644 --- a/docs/ai-coder/agents/platform-controls/index.md +++ b/docs/ai-coder/agents/platform-controls/index.md @@ -116,10 +116,11 @@ none, if the template does not define any). ### Spend management -The Spend page provides usage-only reporting for Coder Agents chats. The native -usage-limit configuration UI has been removed from release 2.36, but values -configured before upgrading remain stored and enforced. Existing native values -are not migrated to AI Gateway budgets. +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. diff --git a/docs/ai-coder/agents/platform-controls/usage-insights.md b/docs/ai-coder/agents/platform-controls/usage-insights.md index 3ffca0273ec..a8549e144c6 100644 --- a/docs/ai-coder/agents/platform-controls/usage-insights.md +++ b/docs/ai-coder/agents/platform-controls/usage-insights.md @@ -1,20 +1,17 @@ # Spend Management -Coder provides usage reporting for Coder Agents and two independent ways to -limit spend on release 2.36: existing native chat usage limits and AI Gateway -budgets. +Coder provides usage reporting for Coder Agents and enforces spend through AI +Gateway budgets on release 2.36. ## Native chat usage limits -The native usage-limit configuration UI has been removed from release 2.36. -Values configured before upgrading remain stored and enforced. Coder checks the -user's current spend before processing each chat message and returns a **409 -Conflict** response when the applicable limit is reached. +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. -Existing native values are not migrated to AI Gateway budgets. To change spend -controls after upgrading, configure new AI Gateway budgets. Native limits remain -in effect until they are changed through the existing experimental API or -removed in a later release. +To limit spend, configure AI Gateway budgets. They are the only enforcement +mechanism. ## AI Gateway budgets @@ -49,3 +46,7 @@ for each user. It supports date range filtering, search, and pagination. 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. Its token usage is not +> included in usage reporting. diff --git a/docs/ai-coder/ai-gateway/cost-controls.md b/docs/ai-coder/ai-gateway/cost-controls.md index e6bec820274..6b8b81be116 100644 --- a/docs/ai-coder/ai-gateway/cost-controls.md +++ b/docs/ai-coder/ai-gateway/cost-controls.md @@ -270,10 +270,15 @@ see [Prometheus metrics](../../admin/integrations/prometheus.md). ## Migrate from Coder Agents Cost Control -In release 2.36, the native Coder Agents usage-limit configuration UI is no -longer available. Existing native values remain stored and enforced, and are not +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 @@ -282,14 +287,12 @@ budget. Expect the following differences: -- Native limits and AI Gateway budgets are independent. A user can be subject to - both until native limits are removed in a later release. - 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 apply the lowest group limit. + 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). diff --git a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx index 622cf1eec25..bc1e60ecf0b 100644 --- a/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx +++ b/site/src/pages/AISettingsPage/SpendPage/components/ChatCostSummaryView.tsx @@ -1,4 +1,4 @@ -import { TriangleAlertIcon } from "lucide-react"; +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; import { type FC, useState } from "react"; import { getErrorMessage } from "#/api/errors"; import type * as TypesGen from "#/api/typesGenerated"; @@ -158,6 +158,14 @@ export const ChatCostSummaryView: FC = ({ )} +
+ + + Automatic title generation uses lightweight models and is not included + in usage reporting. + +
+ {summary.by_model.length === 0 && summary.by_chat.length === 0 ? (

{emptyMessage} diff --git a/site/src/pages/GroupsPage/GroupMembersPage.tsx b/site/src/pages/GroupsPage/GroupMembersPage.tsx index fa8c1d7808b..36ae58b6e86 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"; @@ -50,6 +49,7 @@ import { 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"; @@ -114,9 +114,7 @@ const GroupMembersPage: FC = () => { ); const aiBudgetNote = [ "Estimated 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")}.`, + 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.`, diff --git a/site/src/utils/budget.test.ts b/site/src/utils/budget.test.ts index a475eaa6159..923cd15448c 100644 --- a/site/src/utils/budget.test.ts +++ b/site/src/utils/budget.test.ts @@ -2,10 +2,15 @@ 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( @@ -20,6 +25,12 @@ describe("formatSpendPeriodLabel", () => { }); }); +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 78d537a2445..e3fbff4af7a 100644 --- a/site/src/utils/budget.ts +++ b/site/src/utils/budget.ts @@ -18,6 +18,14 @@ export function formatSpendPeriodLabel( 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 From a7f2917cd8cffb8cb5dde824dce006dc34783a07 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 11 Aug 2026 10:05:33 +0200 Subject: [PATCH 32/69] docs(docs/install/releases): backport release calendar to release/2.36 (#28011) Backport the current release calendar from `main` to `release/2.36` so the versioned `v2.36` docs page shows the correct schedule instead of the stale one frozen when the branch was cut. _Opened as a draft by Coder Agents on behalf of @mtojek._ --- docs/install/releases/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/install/releases/index.md b/docs/install/releases/index.md index d89a7d4f190..e3d41a67f63 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 | > [!TIP] From eca838de839f7f5394136b2d62dc3f2652cdfed2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:50:38 -0400 Subject: [PATCH 33/69] docs: fix P2/P3 typos and syntax errors from drift sweep (#28101) (#28156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28101 Original PR: #28101 — docs: fix P2/P3 typos and syntax errors from drift sweep Merge commit: 1d189cc204f935cac6ce16de162fd7b897652452 Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/about/contributing/modules.md | 2 +- docs/admin/networking/port-forwarding.md | 2 +- docs/admin/templates/extending-templates/variables.md | 2 +- docs/admin/users/idp-sync.md | 4 ++-- docs/ai-coder/tasks-migration.md | 6 +++--- docs/user-guides/workspace-access/index.md | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/about/contributing/modules.md b/docs/about/contributing/modules.md index 4c91a04da12..d04616d02ed 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/networking/port-forwarding.md b/docs/admin/networking/port-forwarding.md index a20439b1e46..5c7d5204d71 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/templates/extending-templates/variables.md b/docs/admin/templates/extending-templates/variables.md index 4fa89d40aeb..13a9793279e 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/idp-sync.md b/docs/admin/users/idp-sync.md index a5fe3da3169..5f653ad95f6 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/ai-coder/tasks-migration.md b/docs/ai-coder/tasks-migration.md index 7fe2a01e266..e279317fe25 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/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index 722094ce2f6..b7dcd27081e 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)) From 81f0dce9097869dd1e150d134e86f4e87bd28bb6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:52:56 -0400 Subject: [PATCH 34/69] docs: correct broken CLI commands and flags from drift sweep (#28098) (#28165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28098 Original PR: #28098 — docs: correct broken CLI commands and flags from drift sweep Merge commit: 58de9ab8f87e7fede0a2d83afa2644bd56a16422 Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/admin/infrastructure/scale-utility.md | 2 +- docs/admin/integrations/dx-data-cloud.md | 5 +++-- docs/admin/users/index.md | 5 +++-- docs/ai-coder/github-to-tasks.md | 5 +---- docs/user-guides/workspace-access/index.md | 2 +- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/admin/infrastructure/scale-utility.md b/docs/admin/infrastructure/scale-utility.md index 5da9b0f0b13..075b199f1ab 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/integrations/dx-data-cloud.md b/docs/admin/integrations/dx-data-cloud.md index ee4f486a6ca..0f602ea5568 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/users/index.md b/docs/admin/users/index.md index 0a38fa8710a..a5bd35a0586 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/ai-coder/github-to-tasks.md b/docs/ai-coder/github-to-tasks.md index f75dbe85030..a3462596b3f 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:///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/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index b7dcd27081e..e6a437035a7 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/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) From 2d47088b30a5190feeca985b39a1c5770c98a8c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:54:36 -0400 Subject: [PATCH 35/69] docs: fix prometheus metric name and slack webhook backtick (#28085) (#28161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28085 Original PR: #28085 — docs: fix prometheus metric name and slack webhook backtick Merge commit: 3145cc8386f9b3a6d2e43a78556a1717e2869f79 Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/admin/integrations/prometheus.md | 2 +- docs/admin/monitoring/notifications/slack.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index 9364583cbe8..6c409ef04b2 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -358,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 7509c1fbad2..4d6330b66f8 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 From 73d95e9f4ea22b85fcd3107168eaf1f4da223e7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:55:51 -0400 Subject: [PATCH 36/69] docs: remove invalid `--yes` flag from `coder template version promote` (#28084) (#28158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28084 Original PR: #28084 — docs: remove invalid `--yes` flag from `coder template version promote` Merge commit: f3fd4c4a77f2bd15624310c49d0bcd1e6fad8ed6 Requested by: @nickvigilante Co-authored-by: Nick Vigilante --- docs/tutorials/testing-templates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorials/testing-templates.md b/docs/tutorials/testing-templates.md index 00e5b29e94e..b18214b1ba0 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 }} ``` From e499c18437f6b0ad3f6a6b58f2e2b7ede02de632 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Mon, 17 Aug 2026 08:22:24 +0100 Subject: [PATCH 37/69] chore: ship prices for all AI Governance proivder types (#28102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes the two below commits: - **chore: update model prices to include more providers (#27837)** - **chore: add more known model prices to ChatModelAdminPanel (#27839)** Note that this also requires db88ec3 (#27836) and 5efa7ab (#27923) ℹ️ Note for reviewers: this PR may look horrible, but most of it is just JSON. You don't have to review that. --------- Co-authored-by: Susana Ferreira --- .gitattributes | 4 + coderd/aibridge/prices/data/prices.json | 6718 ++++++++++++++++- scripts/aibridgepricesgen/curation.json | 209 +- scripts/aibridgepricesgen/main.go | 11 +- scripts/aibridgepricesgen/overrides.jq | 15 + .../knownModels/index.test.ts | 2 +- .../knownModels/knownModelsGenerated.json | 595 +- 7 files changed, 7398 insertions(+), 156 deletions(-) diff --git a/.gitattributes b/.gitattributes index 39e1717ed68..58a1bceb877 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/coderd/aibridge/prices/data/prices.json b/coderd/aibridge/prices/data/prices.json index d6aabb9abfb..7570561b618 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/scripts/aibridgepricesgen/curation.json b/scripts/aibridgepricesgen/curation.json index b528c8d0d33..be22f21e86a 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 19778c357f3..51ad5bb8bda 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 cc1b424a1f8..12c3985654e 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/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/index.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/index.test.ts index 53c9fa3a3b9..80a7ecc47a8 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 bfcb8c0a23a..5ae75467a28 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 + } ] } From 2934aa079ad54ded6f8d0c3c568db20915dd8bbb Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 17 Aug 2026 08:23:15 +0100 Subject: [PATCH 38/69] fix: price AI usage by configured provider type (#27836) (#28104) Backport of https://github.com/coder/coder/pull/27836 Original PR: #27836 - fix: price AI usage by configured provider type Merge commit: db88ec3f6ad564c7029e22a2d515b7a031c17ebc Requested by: @ssncferreira --- coderd/aibridgedserver/aibridgedserver.go | 1 + .../aibridgedserver/aibridgedserver_test.go | 338 +++++++++++++++++- coderd/aibridgedserver/cost.go | 33 +- coderd/aibridgedserver/metrics.go | 5 +- docs/admin/integrations/prometheus.md | 2 +- scripts/metricsdocgen/metrics | 2 +- 6 files changed, 357 insertions(+), 24 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index c9906bd35aa..038286555f7 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 f5641a0531c..8fa4776fb92 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) @@ -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", "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 ab1c13472ae..ba56412a455 100644 --- a/coderd/aibridgedserver/cost.go +++ b/coderd/aibridgedserver/cost.go @@ -34,10 +34,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 +64,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.Info(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, 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.Info(ctx, "no price found for model, recording token usage with NULL cost", - slog.F("provider", intc.Provider), slog.F("model", intc.Model)) + 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(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 dfd3cfdf8bc..b88b8789a6f 100644 --- a/coderd/aibridgedserver/metrics.go +++ b/coderd/aibridgedserver/metrics.go @@ -50,12 +50,11 @@ 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).", + Help: "The number of recorded AI token-usage records for which no (provider, model) price was found.", }, []string{"provider", "model"}), // Pessimistic cardinality: 3 outcomes, 8 buckets + 3 extra series // (count, sum, +Inf) = up to 33. diff --git a/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index 6c409ef04b2..408e87882b2 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -125,7 +125,7 @@ The `coder_ai_gateway_cost_control_*` metrics are exported only by `coderd`. | `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, model) price was found. | `model` `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` | diff --git a/scripts/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index 6464f6ff94b..755068744c8 100644 --- a/scripts/metricsdocgen/metrics +++ b/scripts/metricsdocgen/metrics @@ -166,7 +166,7 @@ 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, model) price was found. # 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 # HELP coder_ai_gateway_injected_tool_invocations_total The number of times an injected MCP tool was invoked by AI Gateway. From d295caf5eaf14c562ff3c1534f225bf84761bae8 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 17 Aug 2026 08:23:48 +0100 Subject: [PATCH 39/69] fix: only write AI model prices that changed (#27923) (#28105) Backport of https://github.com/coder/coder/pull/27923 Original PR: #27923 - only write AI model prices that changed Merge commit: 5efa7abe7d8f0cbf0c853a234c972357fce1a752 Requested by: @ssncferreira --- coderd/aibridge/prices/prices_test.go | 107 +++++++++++++++++++++- coderd/database/querier.go | 3 + coderd/database/queries.sql.go | 14 +++ coderd/database/queries/aicostcontrol.sql | 16 +++- 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/coderd/aibridge/prices/prices_test.go b/coderd/aibridge/prices/prices_test.go index 1ce642e2084..ae30b7e449e 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/database/querier.go b/coderd/database/querier.go index bcb6b722fd2..527168ba4a4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1635,6 +1635,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/queries.sql.go b/coderd/database/queries.sql.go index 72f3812d4a3..07ccd2b813a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3406,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 diff --git a/coderd/database/queries/aicostcontrol.sql b/coderd/database/queries/aicostcontrol.sql index 43c5970fe2d..5552e8c7af4 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 * From 83f642383e43bfa61190575fc7d22c0a7c0be169 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 17 Aug 2026 14:39:27 +0100 Subject: [PATCH 40/69] fix: label unpriced token usage metric by provider name and type (#28210) (#28212) Backport of https://github.com/coder/coder/pull/28210 Original PR: #28210 - fix: label unpriced token usage metric by provider name and type Merge commit: 95328f1ead6bdf275664678a92033a771c8a8db1 Requested by: @ssncferreira --- coderd/aibridgedserver/aibridgedserver_test.go | 8 ++++---- coderd/aibridgedserver/cost.go | 10 +++++++--- coderd/aibridgedserver/metrics.go | 5 +++-- docs/admin/integrations/prometheus.md | 2 +- docs/ai-coder/ai-gateway/cost-controls.md | 7 ++++--- docs/ai-coder/ai-gateway/monitoring.md | 12 ++++++------ scripts/metricsdocgen/metrics | 4 ++-- 7 files changed, 27 insertions(+), 21 deletions(-) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index 8fa4776fb92..9b63d66b64d 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -1762,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"})) }, }, { @@ -1909,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"})) }, }, { @@ -2089,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"})) }, }, { @@ -2256,7 +2256,7 @@ func TestRecordTokenUsage(t *testing.T) { // 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", "model": "claude-sonnet-4-6"})) + prometheus.Labels{"provider": "anthropic-eu", "provider_type": "unknown", "model": "claude-sonnet-4-6"})) }, }, { diff --git a/coderd/aibridgedserver/cost.go b/coderd/aibridgedserver/cost.go index ba56412a455..0470e66b09a 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. @@ -71,10 +75,10 @@ func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBrid switch { case errors.Is(err, sql.ErrNoRows): // Only reachable if the provider was deleted mid-request. - s.logger.Info(ctx, "no configured provider found for interception, recording token usage with NULL cost", + 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, intc.Model).Inc() + s.metrics.UnpricedTokenUsageRecords.WithLabelValues(intc.ProviderName, unknownProviderType, intc.Model).Inc() } return result, nil case err != nil: @@ -93,7 +97,7 @@ func (s *Server) resolveTokenUsageCost(ctx context.Context, intc database.AIBrid 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(configuredType, intc.Model).Inc() + s.metrics.UnpricedTokenUsageRecords.WithLabelValues(intc.ProviderName, configuredType, intc.Model).Inc() } return result, nil case err != nil: diff --git a/coderd/aibridgedserver/metrics.go b/coderd/aibridgedserver/metrics.go index b88b8789a6f..c613b2f2f3e 100644 --- a/coderd/aibridgedserver/metrics.go +++ b/coderd/aibridgedserver/metrics.go @@ -54,8 +54,9 @@ func NewMetrics(reg prometheus.Registerer) *Metrics { 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 (provider, model) price was found.", - }, []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/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index 408e87882b2..b67cfb7e5ad 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -125,7 +125,7 @@ The `coder_ai_gateway_cost_control_*` metrics are exported only by `coderd`. | `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 (provider, model) price was found. | `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` | diff --git a/docs/ai-coder/ai-gateway/cost-controls.md b/docs/ai-coder/ai-gateway/cost-controls.md index 6b8b81be116..0b563cc3b26 100644 --- a/docs/ai-coder/ai-gateway/cost-controls.md +++ b/docs/ai-coder/ai-gateway/cost-controls.md @@ -210,9 +210,10 @@ Replace `` with your Coder minor version, for example `2.36`. > effectively unlimited. Monitor `coder_ai_gateway_cost_control_unpriced_token_usage_records_total`, -labeled by `provider` and `model`, to detect unpriced usage. 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. +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 diff --git a/docs/ai-coder/ai-gateway/monitoring.md b/docs/ai-coder/ai-gateway/monitoring.md index df648ef8ec9..b56a6d8fbec 100644 --- a/docs/ai-coder/ai-gateway/monitoring.md +++ b/docs/ai-coder/ai-gateway/monitoring.md @@ -55,12 +55,12 @@ 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` | Recorded token-usage records for which no model price was found. | +| 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 diff --git a/scripts/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index 755068744c8..ccce769144d 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 (provider, model) price was found. +# 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 From 10e6321619114db490b380a703514cab62f38246 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:35:54 +0200 Subject: [PATCH 41/69] fix(site): include owner context in Agents org picker permission check (#28076) (#28132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28076 Original PR: #28076 — fix(site): include owner context in Agents org picker permission check Merge commit: d509e1e6a03ff7d88996cb637acc0ef5c131e950 Requested by: @ibetitsmike Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> --- site/src/api/queries/organizations.ts | 8 ++- .../components/AgentCreateForm.stories.tsx | 57 +++++++++++++++++-- .../AgentsPage/components/AgentCreateForm.tsx | 2 +- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 1dcaac36596..6caf0d6f958 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 => { const orgs = await API.getOrganizations(); const checks = Object.fromEntries( diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index e38c344494d..46fb4784803 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -9,6 +9,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"; @@ -24,12 +25,10 @@ import { } from "../utils/reasoningEffort"; import { AgentCreateForm } 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"; @@ -1061,3 +1060,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 0a494c01dcf..22165f04b54 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -461,7 +461,7 @@ export const AgentCreateForm: FC = ({ const permittedOrgsQuery = useQuery({ ...permittedOrganizations({ - object: { resource_type: "chat" }, + object: { resource_type: "chat", owner_id: "me" }, action: "create", }), enabled: showOrganizations, From 7fe2ec471353a64e3e4314253645169a1b1b445b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:43:09 +0200 Subject: [PATCH 42/69] fix(coderd): send assigned chat model IDs verbatim (#28144) (#28216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28144 Original PR: #28144 — fix(coderd): send assigned chat model IDs verbatim Merge commit: 93c6faf1de78b9cbe930295d61abbffd8db6971c Requested by: @ibetitsmike Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Co-authored-by: Marcin Tojek --- coderd/exp_chats.go | 2 +- coderd/x/chatd/chatprovider/chatprovider.go | 30 ++------------- .../x/chatd/chatprovider/chatprovider_test.go | 38 ++++++++++++++++--- coderd/x/chatd/model_routing_aibridge.go | 5 +-- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 35ca899bac4..1fd901255cc 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/x/chatd/chatprovider/chatprovider.go b/coderd/x/chatd/chatprovider/chatprovider.go index ad450d6a542..5edae77da57 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 "/" 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 - // "/" 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 b1b2a37a885..4245f139b01 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 2bd724869ae..a156481a002 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 From 9548f239a8bdfe74d0f62eafc41c97b2d7adca91 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:46:36 +0200 Subject: [PATCH 43/69] fix(site): prefer permitted organization for chat creation (#28078) (#28133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28078 Original PR: #28078 — fix(site): prefer permitted organization for chat creation Merge commit: bca5d72c1c58b0efcf131c401db9481555d1645b Requested by: @ibetitsmike The original automatic cherry-pick had conflicts; `release/2.36` has since moved and the merge commit was re-cherry-picked cleanly (`git cherry-pick -x -m1 bca5d72c`) and pushed by Coder Agents on behalf of @ibetitsmike. Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Co-authored-by: Marcin Tojek --- .../components/AgentCreateForm.stories.tsx | 562 ++++++++++++++++-- .../AgentsPage/components/AgentCreateForm.tsx | 250 ++++---- .../hooks/useFileAttachments.test.tsx | 216 +++++++ .../AgentsPage/hooks/useFileAttachments.ts | 118 ++-- 4 files changed, 962 insertions(+), 184 deletions(-) create mode 100644 site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 46fb4784803..cbbe9acd016 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, @@ -19,11 +22,12 @@ 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"; const permittedOrgsKey = permittedOrganizationsKey({ object: { resource_type: "chat", owner_id: "me" }, @@ -131,19 +135,27 @@ type Story = StoryObj; const defaultArgs = meta.args; -const mockPermittedOrganizations = (permissions: Record) => { +const mockPermittedOrganizations = ( + permissions: Record, + 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" })); @@ -852,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 = {}; +let revocableQueryClient: QueryClient | undefined; + +const withRevocableQueryClient: Decorator = (Story) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: Number.POSITIVE_INFINITY, + retry: false, + }, + }, + }), + ); + revocableQueryClient = queryClient; + return ( + + + + ); +}; + +const mockRevocablePermissions = (permissions: Record) => { + 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(); }, }; @@ -965,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, @@ -980,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).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"); }, }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 22165f04b54..b81dbe083eb 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -1,4 +1,4 @@ -import { type FC, useEffect, useEffectEvent, useRef, useState } from "react"; +import { type FC, useEffect, useRef, useState } from "react"; import { useQuery } from "react-query"; import { toast } from "sonner"; import { isApiError } from "#/api/errors"; @@ -271,42 +271,96 @@ export const AgentCreateForm: FC = ({ : 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( - () => { - 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( - initialOrg ?? null, + null, ); const [pendingOrgChange, setPendingOrgChange] = useState(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(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); @@ -377,7 +431,7 @@ export const AgentCreateForm: FC = ({ 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 @@ -388,8 +442,8 @@ export const AgentCreateForm: FC = ({ // enough to warrant pagination, this should switch to a // server-side organization: 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 = @@ -398,6 +452,11 @@ export const AgentCreateForm: FC = ({ 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(); @@ -420,6 +479,7 @@ export const AgentCreateForm: FC = ({ }; const { + organizationAdopted, attachments, textContents, uploadStates, @@ -427,10 +487,17 @@ export const AgentCreateForm: FC = ({ 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[] = []; @@ -459,50 +526,6 @@ export const AgentCreateForm: FC = ({ } }; - const permittedOrgsQuery = useQuery({ - ...permittedOrganizations({ - object: { resource_type: "chat", owner_id: "me" }, - 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 ( <>

@@ -526,23 +549,27 @@ export const AgentCreateForm: FC = ({ {permittedOrgsQuery.error != null && ( )} - {showOrganizations && permittedOrgs.length > 1 && ( - { - 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 && ( + { + const orgChanged = newOrg.id !== effectiveOrg?.id; + if (orgChanged && attachments.length > 0) { + setPendingOrgChange(newOrg); + return; + } + if (orgChanged) { + handleWorkspaceChange(null); + } + setSelectedOrg(newOrg); + }} + /> + )} = ({ isDisabled={ isCreating || isForbidden || + !orgSelectionSettled || + // Sending before adoption would omit persisted files not yet restored. + !organizationAdopted || + workspaceValidationPending || isPersonalModelOverridesLoading || !hasModelOptions || Boolean(aiGatewayDisabled) @@ -569,7 +600,9 @@ export const AgentCreateForm: FC = ({ 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} @@ -583,7 +616,12 @@ export const AgentCreateForm: FC = ({ 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} @@ -617,10 +655,18 @@ export const AgentCreateForm: FC = ({ 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/hooks/useFileAttachments.test.tsx b/site/src/pages/AgentsPage/hooks/useFileAttachments.test.tsx new file mode 100644 index 00000000000..38d82886b33 --- /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, +): 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(); + expect(renderLog.at(-1)).toStrictEqual({ + orgId: "org-a", + fileIds: ["file-a"], + }); + + rerender(); + 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(); + 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( + + + , + ); + 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 eb6098c6312..b4b9193fb88 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; previewUrls: Map; } { - // 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; uploadStates: Map; @@ -192,19 +195,14 @@ export function useFileAttachments( providerRef.current = provider; }, [provider]); - const [restored] = useState(() => - persist - ? restorePersistedAttachments(organizationId ?? "") - : { - attachments: [] as File[], - uploadStates: new Map(), - previewUrls: new Map(), - }, + const [attachments, setAttachments] = useState([]); + const [uploadStates, setUploadStates] = useState( + () => new Map(), ); - - const [attachments, setAttachments] = useState(restored.attachments); - const [uploadStates, setUploadStates] = useState(restored.uploadStates); - const [previewUrls, setPreviewUrls] = useState(restored.previewUrls); + const [previewUrls, setPreviewUrls] = useState(() => new Map()); + // 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(null); const [textContents, setTextContents] = useState( () => new Map(), ); @@ -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>(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() : textContents, + uploadStates: orgMismatch ? new Map() : uploadStates, + previewUrls: orgMismatch ? new Map() : previewUrls, handleAttach, handleRemoveAttachment, startUpload, From f1f331b210371dd04ec52c926d8d33a6d9522559 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Tue, 25 Aug 2026 01:34:10 -0500 Subject: [PATCH 44/69] fix: add flag to disable workspace agent context sync (cherry-pick #28522) (#28524) Clean cherry-pick of #28522 (`26de6140fb`) onto `release/2.36`. Adds `CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC` / `--disable-workspace-agent-context-sync`. When set, `PushContextState` rejects agent context pushes with a dRPC `Unimplemented` code before any validation or database work; deployed agents translate that into `ErrPushUnimplemented` and stop their push loop for the life of the connection. This gives large deployments a server-only kill switch for context sync database write load, with no agent updates or workspace restarts required. Validated on this branch: `go build`, `TestPushContextState` (unit), `TestWorkspaceAgentPushContextState*` (end-to-end over real dRPC), CLI and enterprise golden-file tests. --- cli/testdata/coder_server_--help.golden | 7 +++ cli/testdata/server-config.yaml.golden | 7 +++ coderd/agentapi/api.go | 6 ++- coderd/agentapi/context.go | 16 ++++++ coderd/agentapi/context_test.go | 22 +++++++++ coderd/apidoc/docs.go | 3 ++ coderd/apidoc/swagger.json | 3 ++ coderd/workspaceagents_test.go | 49 +++++++++++++++++++ coderd/workspaceagentsrpc.go | 1 + codersdk/deployment.go | 10 ++++ docs/admin/setup/configuration-reference.md | 8 +++ docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 3 ++ docs/reference/cli/server.md | 10 ++++ .../cli/testdata/coder_server_--help.golden | 7 +++ site/src/api/typesGenerated.ts | 1 + 16 files changed, 153 insertions(+), 1 deletion(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 8abc40867e2..54a17275244 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 a8f3d90eb32..393150a0034 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/coderd/agentapi/api.go b/coderd/agentapi/api.go index ce697bc4826..c6a77362dec 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -83,7 +83,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] @@ -257,6 +260,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 diff --git a/coderd/agentapi/context.go b/coderd/agentapi/context.go index f6dd69e4e38..09766bca87c 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 5c724b93560..f190d0dfb02 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/apidoc/docs.go b/coderd/apidoc/docs.go index 3021e5b0fd8..df3743c7334 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19876,6 +19876,9 @@ const docTemplate = `{ "disable_path_apps": { "type": "boolean" }, + "disable_workspace_agent_context_sync": { + "type": "boolean" + }, "disable_workspace_sharing": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5a9dd02720f..556ababc495 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18027,6 +18027,9 @@ "disable_path_apps": { "type": "boolean" }, + "disable_workspace_agent_context_sync": { + "type": "boolean" + }, "disable_workspace_sharing": { "type": "boolean" }, diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index f41921c7bb5..50c829349d9 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 1c4e14c3552..9e7de438e31 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/codersdk/deployment.go b/codersdk/deployment.go index c95d910a0ed..514fa20db95 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.", diff --git a/docs/admin/setup/configuration-reference.md b/docs/admin/setup/configuration-reference.md index 3636d7cc1ed..0790666d701 100644 --- a/docs/admin/setup/configuration-reference.md +++ b/docs/admin/setup/configuration-reference.md @@ -82,6 +82,14 @@ Disable workspace apps that are not served from subdomains. Path-based apps can - 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. diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 4715ff82458..9ca81e264d1 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 aefacff8a52..2d5da928e1d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5857,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, @@ -6467,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, @@ -6861,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 | | | diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index f4ae058d878..61e353573c8 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 | bool | +| Environment | $CODER_DISABLE_WORKSPACE_AGENT_CONTEXT_SYNC | +| YAML | disableWorkspaceAgentContextSync | + +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/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 369b2fe72c8..3fab5d4176a 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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 551a358a6db..d283df627e4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4641,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; From 281d487abf1ae51d1ea6c3e2a55f07df913dfabf Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 25 Aug 2026 14:49:02 +0700 Subject: [PATCH 45/69] fix(site): render change-version picker in place so it clears the dialog (#28490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Backports the [DEVEX-780](https://linear.app/codercom/issue/DEVEX-780/change-version-dialog-picker-is-inaccessible) fix to `release/2.36`. `main` is already fine, this is for shipped 2.36.x. ## Problem In the Change version dialog, the version picker options are unclickable, painted behind the dialog surface. The picker uses the shared Radix `Popover`, which portals to `document.body` at `z-50` (see #23374). On `release/2.36` the dialog is still an `@mui/material/Dialog` at `z-index: 1300`. Two body-level portals, `50` loses to `1300`, so the options render behind the dialog. `main` is unaffected because #27506 moved every dialog onto Radix, but that landed **after** the 2.36 branch cut, so 2.36.x shipped broken. image ## Fix Set `disablePortal` on the dialog's `ComboboxContent` so the popover renders in place instead of portalling to `document.body`. It stays inside the dialog's stacking context and focus trap, so it paints in front and stays accessible without a z-index magic number chained to MUI's internal `1300`. Backporting #27506 wholesale is not viable, it touches ~30 dialogs. This is the smallest scoped change. --- .../WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/ChangeWorkspaceVersionDialog.tsx index d0709b8ba8f..6f36dd8b8e3 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< /> From 7e0ff4c80edbd90fa65021b428ba43bd70758169 Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Tue, 25 Aug 2026 17:35:44 +0200 Subject: [PATCH 46/69] ci: use dedicated release App token to publish releases (backport 2.36) (#28555) Backport of #28553 to `release/2.36`. ## Change Use a dedicated GitHub App token for the `Publish release` step: - Add a `Generate release App token` step using `secrets.RELEASE_APP_ID` / `secrets.RELEASE_APP_PRIVATE_KEY`. - Switch only that step's `GITHUB_TOKEN` to the minted App token. ## Required before merge (admin) 1. Create the release GitHub App (least privilege) and install it on `coder/coder`. 2. Configure `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY`. 3. Add the App to the tag-create protection ruleset bypass list. Refs coder/security-automation#297. --- .github/workflows/release.yaml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 95690adb25f..6e93dec7ea0 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 }} From 7bb00a1ce65313c9c3b35efb4f338a45a1586141 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:33:32 +0200 Subject: [PATCH 47/69] fix: prevent markdown injection in notifications (#28340) (#28609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28340 Original PR: #28340 — fix: prevent markdown injection in notifications Merge commit: 9e8075db0ffd0837d38e7133441893c7320fc388 Requested by: @BobbyHo Co-authored-by: Bobby Ho --- coderd/notifications/dispatch/smtp.go | 73 +++- .../dispatch/smtp_internal_test.go | 100 ++++++ coderd/notifications/dispatch/smtp_test.go | 128 +++++++ coderd/notifications/notifications_test.go | 88 +++++ coderd/notifications/notifier.go | 10 +- ...plateWorkspaceResourceReplaced.json.golden | 2 +- coderd/notifications/types/escape.go | 58 ++++ coderd/notifications/types/escape_test.go | 132 +++++++ coderd/render/escape.go | 188 ++++++++++ coderd/render/escape_internal_test.go | 313 +++++++++++++++++ coderd/render/escape_sink_internal_test.go | 321 ++++++++++++++++++ coderd/render/markdown.go | 78 ++++- scripts/check_emdash.sh | 5 + 13 files changed, 1489 insertions(+), 7 deletions(-) create mode 100644 coderd/notifications/types/escape.go create mode 100644 coderd/notifications/types/escape_test.go create mode 100644 coderd/render/escape.go create mode 100644 coderd/render/escape_internal_test.go create mode 100644 coderd/render/escape_sink_internal_test.go diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index 5dfcc43851d..d5760cfc1ce 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_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go index 2e7dff8cbec..1284cb070e1 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" @@ -116,3 +117,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 ee9b6a3d7a7..c1624d9a326 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 2c76d0e2df4..0ef64fc044c 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{"|` + + // 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 00000000000..8cf2078f92b --- /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 00000000000..5080f9817bd --- /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 ed0c16bc840..7cf9bf266da 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/scripts/check_emdash.sh b/scripts/check_emdash.sh index 4433a6d6b9d..bf8d5fb8d3a 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() { From 36654e67b4fe74d475311f80d1eaec501bebe872 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:31:30 +0200 Subject: [PATCH 48/69] fix(coderd/notifications): HTML-escape the email template values (#28397) (#28604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28397 Original PR: #28397 — fix(coderd/notifications): HTML-escape the email template values Merge commit: 2236710badb55e1fe60109aff32e4408513e1bb1 Requested by: @BobbyHo ## Why the automatic cherry-pick failed The bot ran before #28609 (the backport of #28340) had landed on `release/2.36`. #28397 amends a comment directly above the `escaped := payload.EscapedForMarkdown()` block that #28340 introduced, so with that block absent the hunk had no context to anchor to. #28609 is on `release/2.36` now, so that part applies cleanly. ## Manual resolution: 5 golden files dropped Re-picking on the current base leaves a genuine `modify/delete` conflict on five golden files: ``` TemplateUserAccountActivatedServiceAccount.html.golden TemplateUserAccountCreatedServiceAccount.html.golden TemplateUserAccountCreatedWithoutAccountType.html.golden TemplateUserAccountDeletedServiceAccount.html.golden TemplateUserAccountSuspendedServiceAccount.html.golden ``` These are fixtures for the service-account notification templates, which postdate the 2.36 branch point — `coderd/notifications/` on `release/2.36` contains no reference to `ServiceAccount` or `AccountType`, so there is no template to render them and no test case that reads them. **All five were removed rather than added**; carrying them over would leave orphan fixtures. This is the only deviation from the original PR. Verified with a diff-of-diffs: excluding those five paths, this commit is byte-identical to #28397. The escaping changes to `html.gotmpl`, `notifier.go` and `smtp_internal_test.go` are fully intact. Net: 34 files, +234/−79 (upstream: 39 files, +244/−89 — the delta is exactly the five goldens). The smtp golden directory holds 43 files before and after, so nothing was added or lost. ## Verification - `go vet ./coderd/notifications/dispatch/` — clean - `go test ./coderd/notifications/dispatch/...` — pass, including the three new `TestSMTPHTMLTemplateEscapes*` tests - `go test ./coderd/notifications/ -run TestNotificationTemplates_Golden` — pass across all 31 affected goldens Co-authored-by: Bobby Ho <bobby@coder.com> --- .../notifications/dispatch/smtp/html.gotmpl | 16 +- .../dispatch/smtp_internal_test.go | 160 +++++++++++++++++- coderd/notifications/notifier.go | 4 +- ...mplateAIBudgetLimitReachedUser.html.golden | 4 +- .../TemplateAIBudgetWarningUser.html.golden | 4 +- .../smtp/TemplateTaskCompleted.html.golden | 4 +- .../smtp/TemplateTaskFailed.html.golden | 4 +- .../smtp/TemplateTaskIdle.html.golden | 4 +- .../smtp/TemplateTaskPaused.html.golden | 4 +- .../smtp/TemplateTaskResumed.html.golden | 4 +- .../smtp/TemplateTaskWorking.html.golden | 4 +- .../smtp/TemplateTemplateDeleted.html.golden | 4 +- .../TemplateTemplateDeprecated.html.golden | 4 +- .../TemplateUserAccountActivated.html.golden | 4 +- .../TemplateUserAccountCreated.html.golden | 4 +- .../TemplateUserAccountDeleted.html.golden | 4 +- .../TemplateUserAccountSuspended.html.golden | 4 +- ...teUserRequestedOneTimePasscode.html.golden | 8 +- .../TemplateWorkspaceAutoUpdated.html.golden | 5 +- ...mplateWorkspaceAutobuildFailed.html.golden | 4 +- ...plateWorkspaceAutostopReminder.html.golden | 4 +- .../smtp/TemplateWorkspaceCreated.html.golden | 4 +- .../smtp/TemplateWorkspaceDeleted.html.golden | 4 +- ...kspaceDeleted_CustomAppearance.html.golden | 4 +- .../smtp/TemplateWorkspaceDormant.html.golden | 4 +- ...eWorkspaceDormant_NoAutoDelete.html.golden | 4 +- ...lateWorkspaceManualBuildFailed.html.golden | 4 +- ...mplateWorkspaceManuallyUpdated.html.golden | 5 +- ...lateWorkspaceMarkedForDeletion.html.golden | 4 +- .../TemplateWorkspaceOutOfDisk.html.golden | 5 +- ...spaceOutOfDisk_MultipleVolumes.html.golden | 5 +- .../TemplateWorkspaceOutOfMemory.html.golden | 5 +- .../TemplateYourAccountActivated.html.golden | 4 +- .../TemplateYourAccountSuspended.html.golden | 4 +- 34 files changed, 234 insertions(+), 79 deletions(-) diff --git a/coderd/notifications/dispatch/smtp/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl index cecba560af2..2deb5505a4a 100644 --- a/coderd/notifications/dispatch/smtp/html.gotmpl +++ b/coderd/notifications/dispatch/smtp/html.gotmpl @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>{{ .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 }}
-

© {{ 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 1284cb070e1..187191d7c87 100644 --- a/coderd/notifications/dispatch/smtp_internal_test.go +++ b/coderd/notifications/dispatch/smtp_internal_test.go @@ -10,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() @@ -28,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) @@ -44,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() diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 19533c04942..e17243e3b6e 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -253,7 +253,9 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification // Label and data values are user-controlled while the templates around them // are not, so Markdown structure in a value is neutralized before it reaches // the template. The dispatcher still receives the unescaped payload, because - // the webhook contract surfaces enqueued values verbatim. + // the webhook contract surfaces enqueued values verbatim. smtp/html.gotmpl + // escapes at its own sinks, which it must: PlaintextFromMarkdown strips this + // escaping back out of _subject. escaped := payload.EscapedForMarkdown() var title, body string diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden index 37db6f733cc..94d00686923 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden @@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8 - 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 3927ab28e31..4d5ffdf4744 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 769d5595dbc..b4a2d53763c 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 5d0879bc82d..1a17d690186 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 578e39e91a2..0c4fea1cf9d 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 58a1f098f77..f22fc19c5b6 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 81d2498b579..4d71d3c8ec9 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 21356601f62..b0cc318c350 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 75af5a264e6..3103a58b97a 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 70c27eed186..6eea0d7a8db 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 011ef84ebfb..ff4ee4976af 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 6fc619e4129..fa16f497bb9 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 cfcb22beec1..e52d1b71ab8 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 9664bc88924..99cddd3593b 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 12e29c47ed0..819fc0d0e85 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 2304fbf01bd..378f13e534d 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 c132ffb47d9..7cb61a67011 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 350896eb0eb..175cf87ece9 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 9fccba0b1f2..97f6e720a28 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 fcc9b57f17b..46ebe956cb2 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 7c1f7192b1f..cf1e96a2eda 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 ea9e1b69795..a6ee986bd9d 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 e41eeb19fee..97c4d95bdf8 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 2f7bb2771c8..f1b67ebc39c 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 0e70293b090..d971cd94c15 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 3937a96cd93..a3fa8366514 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 1e65a1eab12..15bffe4a810 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 aad0c2190c2..1f134cb0739 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 b75c2032003..44a0e92b249 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 b86fd4bf639..b2c0fb7c1af 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 277195a2bd4..74dee947796 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,

From c257214a6745f03881eb80f3a49ee919a58f50f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:35:27 +0200 Subject: [PATCH 49/69] fix(coderd): reject agent requests from suspended owners (#28513) (#28648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28513 Original PR: #28513 — fix(coderd): reject agent requests from suspended owners Merge commit: 2f50b2d7408dff4275f81d18c32e2a517a16c897 Requested by: @hwang251 Co-authored-by: Hank Hwang Co-authored-by: Marcin Tojek --- coderd/httpmw/workspaceagent.go | 8 ++++++- coderd/httpmw/workspaceagent_test.go | 35 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index 47867e17b2c..7c7a28c07f3 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 378d75927cc..c18e7aab91d 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) From 3076b0c48f4752156a8bd711f7b209936eec3461 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:18:50 +0200 Subject: [PATCH 50/69] fix: enable Copilot HTTP transport fallback (#28494) (#28715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28494 Original PR: #28494 — fix: enable Copilot HTTP transport fallback Merge commit: 849543d8de4cfb629fc6ca826673c3454b43ac4e Requested by: @ssncferreira Co-authored-by: Susana Ferreira --- aibridge/bridge.go | 20 ++ aibridge/bridge_internal_test.go | 30 +++ aibridge/bridge_test.go | 63 +++++- aibridge/provider/copilot.go | 2 + enterprise/aibridgeproxyd/aibridgeproxyd.go | 71 ++++--- .../aibridgeproxyd/aibridgeproxyd_test.go | 184 +++++++++++++++--- enterprise/aibridgeproxyd/reload.go | 11 +- .../aibridgeproxyd/reload_internal_test.go | 24 +-- 8 files changed, 335 insertions(+), 70 deletions(-) diff --git a/aibridge/bridge.go b/aibridge/bridge.go index 5f3a5fbce92..dff41ba117f 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" @@ -248,6 +249,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 +394,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) { diff --git a/aibridge/bridge_internal_test.go b/aibridge/bridge_internal_test.go index 561f758de12..e92d554ec59 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 d8e9103a7cb..c82ca422534 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -13,11 +13,13 @@ import ( "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/provider" codertestutil "github.com/coder/coder/v2/testutil" @@ -186,11 +188,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 +246,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 +283,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 +293,37 @@ func TestPassthroughRoutesForProviders(t *testing.T) { } } +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/provider/copilot.go b/aibridge/provider/copilot.go index 79dc37ae4be..10d634c7bdf 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/enterprise/aibridgeproxyd/aibridgeproxyd.go b/enterprise/aibridgeproxyd/aibridgeproxyd.go index 0941c2da4b5..fb7506b1d80 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 580b020ce15..99fc40bfce1 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 04b1f5438b0..9dc54c5fff6 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 5ccba37ec7b..537392fc447 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) }) } From ca8ef6c811c162377034e9bfafac632f08e21b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Thu, 27 Aug 2026 18:19:47 +0200 Subject: [PATCH 51/69] fix: add cache write accounting to OpenAI interceptors (#28716) Cherry-picks: * ad100452d4554545e8f7dc1606a102f3713c9bd1 fixes token accumulation logic for chat completions PR https://github.com/coder/coder/pull/27967 * 3ca2ba485e548bc51d79bfae274b46ca0bb4d512 adds cache write tokens accounting to OpenAI interceptors, PR https://github.com/coder/coder/pull/28567 Compatibility fix: * ae737dbf15e06b251739a98f5e92113eea08eaa5 bumps OpenAI SDK + minimal changes, bump required by cherry picks, based on https://github.com/coder/coder/commit/c97f4da3ac --------- Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> --- aibridge/fixtures/fixtures.go | 3 + .../openai/chatcompletions/simple.txtar | 3 +- .../single_injected_tool.txtar | 32 ++-- ...aming_cumulative_usage_injected_tool.txtar | 30 ++++ .../blocking/cached_input_tokens.txtar | 3 +- .../blocking/single_injected_tool.txtar | 6 +- .../blocking/single_injected_tool_error.txtar | 6 +- .../streaming/cached_input_tokens.txtar | 2 +- .../streaming/single_injected_tool.txtar | 4 +- .../single_injected_tool_error.txtar | 4 +- aibridge/intercept/chatcompletions/base.go | 32 ++-- .../chatcompletions/base_internal_test.go | 86 ++++++++-- .../intercept/chatcompletions/streaming.go | 45 +++-- .../streaming_internal_test.go | 131 ++++++++++++++ aibridge/intercept/responses/base.go | 51 ++++-- .../intercept/responses/base_internal_test.go | 80 +++++++-- aibridge/intercept/responses/blocking.go | 50 +++++- .../integrationtest/bridge_internal_test.go | 153 +++++++++++++++-- .../integrationtest/metrics_internal_test.go | 8 +- .../responses_internal_test.go | 160 +++++++++++------- go.mod | 38 ++--- go.sum | 80 ++++----- 22 files changed, 753 insertions(+), 254 deletions(-) create mode 100644 aibridge/fixtures/openai/chatcompletions/streaming_cumulative_usage_injected_tool.txtar diff --git a/aibridge/fixtures/fixtures.go b/aibridge/fixtures/fixtures.go index 7a30ccbd631..9b2e8ad7f68 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 8f07d0c8ffa..2f458c3af98 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 b89aac648a1..61b4c1c3212 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 00000000000..5474c4ac975 --- /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 41a6d7ca7e3..66689d6a483 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 028377dcaa9..ce46145d78d 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 9e4c2716f20..17c180d7dc5 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 cc908d5abdf..8dffa4eabe9 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 0e079d1e7a4..7df2ec235b4 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 95dd43e5433..a6ea96c8c3e 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 4e6db3abe60..415b4d46bc3 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 8d9151fce6e..2eab3b0b45c 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 ccbe1d96ab7..44003e36d17 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 1f6e4195550..b6f36173af1 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/responses/base.go b/aibridge/intercept/responses/base.go index 6513636fedb..ec6e128cf0b 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 60253bc0d1a..dcc8e107e91 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 6038e2e1e6b..fd696a1d562 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 0b2389d3390..ae15b5aae3e 100644 --- a/aibridge/internal/integrationtest/bridge_internal_test.go +++ b/aibridge/internal/integrationtest/bridge_internal_test.go @@ -837,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() @@ -1411,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. @@ -1423,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 @@ -1453,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) @@ -1466,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 314c2d97c4a..a9bcf93e477 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 73fccad6398..2a6edfcbb10 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/go.mod b/go.mod index 06189f1f7c1..bb7fef16db4 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 9761644f7ba..5592a9d1043 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= From 10fd510ada0e3a7c222511dbd9916f83afd1acf0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 31 Aug 2026 20:37:18 -0500 Subject: [PATCH 52/69] chore: purge identified terraform modules via dbpurge (#28802) (#28816) Cherry-pick of [#28802](https://github.com/coder/coder/pull/28802) (`e2a856d42b`), matching [#28810](https://github.com/coder/coder/pull/28810) for `release/2.37`. Deletes cached Terraform module archives ingested during the identified window and clears the template version references to them. Runs from `dbpurge` rather than a migration, because migrations cannot be backported: the version table records a single high-water mark, so a migration cherry-picked here would cause later upgrades to skip every migration in between. ## Conflict resolution The commit did not apply cleanly. This branch predates the chat search work on `main`, so the incoming hunks carried unrelated context that was dropped: - `dbpurge.go`: took only the module cache block, the `ranModuleCachePurge` latch, the window constants, the `identified_module_files` log field and metric, and the `identifiedModuleCachePurged` instance field. This branch has the `chat_messages.search_tsv` backfill but not the stale reindex added in #28585, so the reindex loop, `staleDrained`, and `chatSearchStaleDrained` were dropped. - `dbpurge_test.go`: took `TestDeleteIdentifiedModuleCacheFiles`. In the two `TestMetrics` mock setups, added only the `DeleteCachedModuleFilesCreatedBetween` expectation, dropping `ReindexStaleChatMessagesSearchTsv`. - Generated files (`querier.go`, `queries.sql.go`, `dbmetrics`, `dbmock`, and the `dbauthz` stub) were reset to the branch state and regenerated from `queries/files.sql`, rather than taking the diff from `main`. Taking `main`'s versions would have introduced methods for queries that do not exist on this branch. ## Testing `coderd/database/dbpurge` and `TestMethodTestSuite` in `coderd/database/dbauthz` pass against Postgres. `make gen` is clean. Pre-commit hooks were skipped on the final commit at the author's request after the checks above had already been run; `make gen`, build, and lint were verified separately during conflict resolution. --- Opened by Coder Agents on behalf of @Emyrk. --- coderd/database/dbauthz/dbauthz.go | 7 + coderd/database/dbauthz/dbauthz_test.go | 5 + coderd/database/dbmetrics/querymetrics.go | 8 ++ coderd/database/dbmock/dbmock.go | 15 +++ coderd/database/dbpurge/dbpurge.go | 43 ++++++ .../database/dbpurge/dbpurge_internal_test.go | 14 ++ coderd/database/dbpurge/dbpurge_test.go | 126 ++++++++++++++++++ coderd/database/querier.go | 6 + coderd/database/queries.sql.go | 52 ++++++++ coderd/database/queries/files.sql | 38 ++++++ 10 files changed, 314 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 32333c7bfd8..50db6bd4374 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 { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a652c37c232..893999a21fb 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() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index b4944e1647a..ac28d72628b 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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index cb0ead8a046..be424a0d85d 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() diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index 7c284339d0e..f84372cde6b 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 f49426e9560..a7fe08d1ebd 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 25e780ec3d2..ce25f7aa63d 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/querier.go b/coderd/database/querier.go index 527168ba4a4..fb5434ff014 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. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 07ccd2b813a..a0c4ca93f13 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -14878,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 diff --git a/coderd/database/queries/files.sql b/coderd/database/queries/files.sql index cdf6e37ce08..cefc3a2d02b 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; From 0d7cd269f9a9c075304e18ebcc5df724a0800a91 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Tue, 1 Sep 2026 15:00:58 +0700 Subject: [PATCH 53/69] fix(site): dispose Monaco diff models on unmount (#28503) (#28808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. Backport of #28503 to `release/2.36`. Refs [DEVEX-736](https://linear.app/codercom/issue/DEVEX-736/template-editor-crashes-becomes-unusable-when-opening-versions). This carries the Monaco diff-model lifecycle cleanup and Storybook regression coverage into 2.36, preventing template-version navigation from retaining models until the editor becomes unusable. The cherry-pick conflict was limited to theme imports. This keeps the release branch's existing `@emotion/react` integration while applying the original functional fix. Co-authored-by: Marcin Tojek --- .../SyntaxHighlighter.stories.tsx | 111 +++++++++++++ .../SyntaxHighlighter/SyntaxHighlighter.tsx | 146 ++++++++++++------ 2 files changed, 211 insertions(+), 46 deletions(-) create mode 100644 site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx diff --git a/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx b/site/src/components/SyntaxHighlighter/SyntaxHighlighter.stories.tsx new file mode 100644 index 00000000000..8740b3af492 --- /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 = { + title: "components/SyntaxHighlighter", + component: SyntaxHighlighter, + decorators: [withDashboardProvider], + args: { + language: "hcl", + editorProps: { options: stableTeardownOptions }, + }, +}; + +export default meta; +type Story = StoryObj; + +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 ( +
+ + +
+ ); +}; + +export const DisposesModelsOnDiffToggle: Story = { + render: () => , + 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 68d9e140ce5..37e76006575 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 = ({ 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 = ({ }} > {hasDiff ? ( - + ) : ( )}
); }; + +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 = ({ + 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 ( + + ); +}; From e81dac82ad136c1b40d90db22defa2c168189822 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 1 Sep 2026 19:08:23 +0530 Subject: [PATCH 54/69] fix(site): allow Antigravity IDE token substitution (#28784) (#28840) --- site/src/modules/apps/apps.test.ts | 18 ++++++++++++++++++ site/src/modules/apps/apps.ts | 1 + 2 files changed, 19 insertions(+) diff --git a/site/src/modules/apps/apps.test.ts b/site/src/modules/apps/apps.test.ts index d9ac821cc8d..418efd9660d 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 f987a992cda..86dd2b30ff4 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 = { From 5627dae63cb058e4c0b4f3eb2019437ad3c268ce Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:38:46 +0200 Subject: [PATCH 55/69] fix(coderd/x/chatd): avoid closing poller hint channels (#28746) (#28978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28746 Original PR: #28746 — fix(coderd/x/chatd): avoid closing poller hint channels Merge commit: ced03dc30baecc9a08cef57996374dc358fcb824 Requested by: @ethanndickson --- coderd/x/chatd/stream_sync_poller.go | 1 - 1 file changed, 1 deletion(-) diff --git a/coderd/x/chatd/stream_sync_poller.go b/coderd/x/chatd/stream_sync_poller.go index 11e9171687e..2a87a9af121 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() { From 28c4c8375c05bf96a9c79a5cb3cae6046b736c23 Mon Sep 17 00:00:00 2001 From: George K Date: Fri, 4 Sep 2026 08:46:29 -0700 Subject: [PATCH 56/69] fix: prevent coderd crashes from unsupported Tailnet RPCs (#28429) (#28961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/28429 Original PR: #28429 — fix: prevent coderd crashes from unsupported Tailnet RPCs Merge commit: 156c8b6e41249e35af5dfdc93e867cdcd6579c33 PR created manually because the backport CI job is timing out. --- agent/agentsocket/server.go | 2 +- agent/agenttest/client.go | 1 + coderd/agentapi/api.go | 32 +++++++- coderd/aibridged.go | 2 +- coderd/coderd.go | 2 +- coderd/workspaceagentsrpc_test.go | 44 +++++++++++ codersdk/drpcsdk/server.go | 39 ++++++++++ codersdk/drpcsdk/server_internal_test.go | 85 +++++++++++++++++++++ codersdk/drpcsdk/server_test.go | 94 ++++++++++++++++++++++++ enterprise/coderd/aibridgeserve.go | 2 +- enterprise/coderd/provisionerdaemons.go | 2 +- provisionersdk/serve.go | 2 +- tailnet/service.go | 17 ++++- tailnet/service_test.go | 22 ++++++ 14 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 codersdk/drpcsdk/server.go create mode 100644 codersdk/drpcsdk/server_internal_test.go create mode 100644 codersdk/drpcsdk/server_test.go diff --git a/agent/agentsocket/server.go b/agent/agentsocket/server.go index 605feeec05a..aee3a685a0a 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 0f5d83a98f9..457da28ebc5 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/coderd/agentapi/api.go b/coderd/agentapi/api.go index c6a77362dec..369aeede04d 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 @@ -224,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, @@ -277,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/aibridged.go b/coderd/aibridged.go index e7c8295fed0..0ea322e455a 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/coderd.go b/coderd/coderd.go index b01efaf9cd2..d6cd586ec2e 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/workspaceagentsrpc_test.go b/coderd/workspaceagentsrpc_test.go index 1595462d191..5d083c131f9 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/codersdk/drpcsdk/server.go b/codersdk/drpcsdk/server.go new file mode 100644 index 00000000000..6fc9a505d93 --- /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 00000000000..66298af68f5 --- /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 00000000000..e906daa82e0 --- /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/enterprise/coderd/aibridgeserve.go b/enterprise/coderd/aibridgeserve.go index 09c48086a6d..44cd4dd16af 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/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index c2ba568b969..110ff2e13c8 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/provisionersdk/serve.go b/provisionersdk/serve.go index 4afcee96269..013626b22a4 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/tailnet/service.go b/tailnet/service.go index 6ae02876a4d..9773530c76f 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 0c268b05edb..21a290ef358 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() From a682c9dadde17998cab7804167454c4d5138eb00 Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Wed, 16 Sep 2026 12:38:30 -0400 Subject: [PATCH 57/69] fix(coderd/agentapi): reject missing RPC messages (#29150) (#29368) Backport of #29150 to `release/2.36`. Reject missing lifecycle, startup, and metadata sub-messages with `InvalidArgument` instead of panicking. Validate the entire metadata batch before modifying or queuing values.
Backport context Cherry-picks `9c58283d8e7a036a45820f76da7f05ffcf81d611` without changes. The automatic backport failed while pushing the branch.
Generated by Coder Agents on behalf of @dylanhuff-at-coder. --- coderd/agentapi/lifecycle.go | 10 ++++ coderd/agentapi/metadata.go | 12 ++++ coderd/agentapi/validation_test.go | 90 ++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 coderd/agentapi/validation_test.go diff --git a/coderd/agentapi/lifecycle.go b/coderd/agentapi/lifecycle.go index 5003a16f04d..d9b1aa296a1 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/metadata.go b/coderd/agentapi/metadata.go index 12efe362abb..8baee8eb3f2 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 00000000000..2a32556b219 --- /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)) +} From 165de21fdd913a0ae9f742de732f21dcc26b75b2 Mon Sep 17 00:00:00 2001 From: "J. Scott Miller" Date: Wed, 16 Sep 2026 11:39:47 -0500 Subject: [PATCH 58/69] perf(coderd): build the workspace build fan-out maps once per batch (#28074) (#29371) Backport of https://github.com/coder/coder/pull/28074 Original PR: #28074 - perf(coderd): build the workspace build fan-out maps once per batch Merge commit: 62f4afbb6067ec14ee27768f3b97c84327ecdf08 Requested by: @jscottmiller Opened manually because the backport workflow's push was rejected by GitHub's workflow-scope check (run 34996286869). Cherry-pick applied cleanly; `go build ./coderd/...` and `go test ./coderd -run TestConvertWorkspaceBuild` pass on this branch. Addresses PLAT-386 / #27205. --- Created with Coder Agents on behalf of @jscottmiller. --- coderd/workspacebuilds.go | 204 +++++++++------- coderd/workspacebuilds_internal_test.go | 299 ++++++++++++++++++++++++ coderd/workspaces.go | 9 +- 3 files changed, 415 insertions(+), 97 deletions(-) create mode 100644 coderd/workspacebuilds_internal_test.go diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 9678defee55..73a8d8241cb 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 00000000000..f7e9e55c04d --- /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/workspaces.go b/coderd/workspaces.go index eb89dc723cf..da8339c9ab8 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{ From c3bc41ff139eb63473cca0db5196596f633a996d Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 17 Sep 2026 23:17:45 -0400 Subject: [PATCH 59/69] fix(scripts/releaser): scope breaking-change check to commits since previous release (#29487) (#29496) Backport of #29487 to `release/2.36`. The patch-release breaking-change check listed every merged PR labeled `release/breaking` against the branch with no range filter, so breaking changes from earlier patches were reported on every subsequent patch release. Detection is now scoped to `prevVersion..HEAD` for both the `!` title marker and the label.
Backport context Cherry-picks `86a050de439cb04dca9bed546ce0c0a0b208fd7b` without changes. This branch already has the GPG prompt removal (#27422).
Generated by Coder Agents on behalf of @f0ssel. --- scripts/releaser/commit.go | 15 +++++++ scripts/releaser/commit_test.go | 70 +++++++++++++++++++++++++++++++++ scripts/releaser/github.go | 51 +++++++++++------------- scripts/releaser/release.go | 52 ++++++------------------ 4 files changed, 118 insertions(+), 70 deletions(-) create mode 100644 scripts/releaser/commit_test.go diff --git a/scripts/releaser/commit.go b/scripts/releaser/commit.go index 37bdfcb7b5c..e182e25d2ee 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 00000000000..ff1771b3796 --- /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 75df80960f0..e63fec5a8eb 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 4af28da1033..dd0988ea0c4 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 From 5caa200d5d8d6998e549a56cb032e53e5a808094 Mon Sep 17 00:00:00 2001 From: dylanhuff-at-coder Date: Tue, 22 Sep 2026 17:40:12 -0400 Subject: [PATCH 60/69] fix: prevent tailnet coordination panics (#29300) (#29745) Backport #29300 to `release/2.36` for [PLAT-595](https://linear.app/codercom/issue/PLAT-595). This includes the full tailnet request validation, panic recovery, peer cleanup fixes, and regression tests. --- enterprise/tailnet/connio.go | 16 ++ enterprise/tailnet/connio_internal_test.go | 89 +++++++++ enterprise/tailnet/mapper_internal_test.go | 55 ++++++ enterprise/tailnet/pgcoord.go | 11 ++ enterprise/tailnet/requests_test.go | 33 ++++ tailnet/coordinator.go | 29 +++ tailnet/coordinator_internal_test.go | 212 +++++++++++++++++++++ tailnet/peer.go | 12 +- tailnet/peer_internal_test.go | 119 ++++++++++++ tailnet/requests.go | 31 +++ tailnet/requests_test.go | 100 ++++++++++ tailnet/test/peer.go | 9 + tailnet/test/requests.go | 116 +++++++++++ tailnet/tunnel.go | 6 + 14 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 enterprise/tailnet/connio_internal_test.go create mode 100644 enterprise/tailnet/mapper_internal_test.go create mode 100644 enterprise/tailnet/requests_test.go create mode 100644 tailnet/peer_internal_test.go create mode 100644 tailnet/requests.go create mode 100644 tailnet/requests_test.go create mode 100644 tailnet/test/requests.go diff --git a/enterprise/tailnet/connio.go b/enterprise/tailnet/connio.go index 7c186dc1a04..0803aa79f5f 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 00000000000..77649c2ed83 --- /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 00000000000..f5c2c70f564 --- /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 08325e567fc..4018b8aabf7 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 00000000000..32ebeeb2ada --- /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/tailnet/coordinator.go b/tailnet/coordinator.go index c3df906b4d8..79db8ca0a22 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 9f5ac7c6a46..654bf28015e 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 1954534a1d1..8631c24e62d 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 00000000000..1dd2083e82f --- /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 00000000000..94cebe151ef --- /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 00000000000..8d50a507719 --- /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/test/peer.go b/tailnet/test/peer.go index 601dc748d42..9dc44f431ed 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 00000000000..da84432604e --- /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 c575d0b986e..86b4a846afa 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) From 52ce649d80765916c3123a5b7007ec5a7f615c97 Mon Sep 17 00:00:00 2001 From: Nick Vigilante Date: Fri, 25 Sep 2026 14:11:54 -0400 Subject: [PATCH 61/69] docs(docs): document RPM install on RHEL-family Linux (#29295) (#29937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/29295 Original PR: #29295 — docs(docs): document RPM install on RHEL-family Linux Merge commit: 9afdea48085b35883c2ac3f6ebad0a81dbed2494 Requested by: @nickvigilante The automatic cherry-pick conflicted because `release/2.36` still uses the flat `docs/install/` layout (the install spine restructure that introduced `docs/install/server/` on `main` hasn't been backported here). Resolved manually: - New page at `docs/install/rpm.md` (flat, sibling of `docker.md`, `kubernetes.md`, `openshift.md`, `rancher.md`) instead of `docs/install/server/rpm.md`. - Internal links repointed at this branch's existing paths (e.g. `./kubernetes.md`, `../admin/infrastructure/validated-architectures/index.md`, `../reference/cli/server.md`). - Manifest entry added under "OpenShift" in this branch's flat `docs/install/` list. - Pointer added to `docs/install/cli.md` at `./rpm.md`. All internal links verified to resolve on this branch. Markdown lint, table formatting, and full pre-commit checks pass. --- _This PR and its conflict resolution were created/completed by a Coder Agent on Nick's behalf._ --- docs/install/cli.md | 2 + docs/install/rpm.md | 139 ++++++++++++++++++++++++++++++++++++++++++++ docs/manifest.json | 6 ++ 3 files changed, 147 insertions(+) create mode 100644 docs/install/rpm.md diff --git a/docs/install/cli.md b/docs/install/cli.md index a8e5429143c..66f24a99c9a 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/rpm.md b/docs/install/rpm.md new file mode 100644 index 00000000000..947fb02c983 --- /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. + +
+ +### 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__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__linux_amd64.rpm +``` + +
+ +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 99d5cdab331..7c0e564c001 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.", From 5aec9d328b31ebea88737da049f76afcec157e50 Mon Sep 17 00:00:00 2001 From: "coder-backporter-app[bot]" <335582564+coder-backporter-app[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:00:51 +0200 Subject: [PATCH 62/69] fix(coderd): validate GCP instance token audience (#29765) (#30192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/29765 Original PR: #29765 — fix(coderd): validate GCP instance token audience Merge commit: b5ce79b9e0f8e394322d0987531dcb5ffa9e7489 Requested by: @Lisa-Fiander Co-authored-by: dylanhuff-at-coder --- coderd/coderdtest/coderdtest.go | 3 ++- coderd/coderdtest/coderdtest_test.go | 2 +- coderd/workspaceresourceauth.go | 3 +-- coderd/workspaceresourceauth_test.go | 31 +++++++++++++++++++++++----- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 7ab7ca8fcc8..cf96de00b08 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 e245898de6c..aa776b84fea 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/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index 5a2c36f7ef9..674bb60956f 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 b327c120bc3..74a146a2d2a 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")) From 2fd403b65eef45e3c11feb7d25e73e3a7fc9313c Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Thu, 15 Oct 2026 11:02:52 +0200 Subject: [PATCH 63/69] fix: reject encoded path traversal in ai gateway before forwarding upstream (#30178) (#30416) Backport of https://github.com/coder/coder/pull/30178 to `release/2.36`. Original PR: #30178, fix: reject encoded path traversal in ai gateway before forwarding upstream Merge commit: bf3db74b83e0553127eee8ea5b1552aa0819f6a1 Cherry-pick with conflict adjustments: - `aibridge/x/proxy` does not exist on this branch, so its changes are dropped. - `NewRequestBridge` has no inflight middleware wrapping the mux here, so the `xurl.ContainsEncodedPath` check runs in `RequestBridge.ServeHTTP` after request admission and before the body limit and mux. This is the same position as on `main`. - `coderd/util/xurl`, `aibridge/routing/path.go`, and `TestPassthroughRejectsEncodedPathTraversal` are identical to `main`. Verified: `go test` for `coderd/util/xurl`, `aibridge`, `aibridge/routing`; `go vet` and `gofmt` clean. --- _Generated by Coder Agents on behalf of @SasSwart._ --- aibridge/bridge.go | 7 +++ aibridge/bridge_test.go | 85 +++++++++++++++++++++++++++++++++++ aibridge/routing/path.go | 24 ++++++++++ aibridge/routing/path_test.go | 32 +++++++++++++ coderd/util/xurl/xurl.go | 51 +++++++++++++++++++++ coderd/util/xurl/xurl_test.go | 57 +++++++++++++++++++++++ 6 files changed, 256 insertions(+) create mode 100644 aibridge/routing/path.go create mode 100644 aibridge/routing/path_test.go create mode 100644 coderd/util/xurl/xurl.go create mode 100644 coderd/util/xurl/xurl_test.go diff --git a/aibridge/bridge.go b/aibridge/bridge.go index dff41ba117f..2275304a889 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -29,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" ) @@ -438,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_test.go b/aibridge/bridge_test.go index c82ca422534..cc42c9fb0a2 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -21,6 +22,7 @@ import ( "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" @@ -293,6 +295,89 @@ 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() diff --git a/aibridge/routing/path.go b/aibridge/routing/path.go new file mode 100644 index 00000000000..bdd3a82baf4 --- /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 00000000000..7268babb37f --- /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/coderd/util/xurl/xurl.go b/coderd/util/xurl/xurl.go new file mode 100644 index 00000000000..2db01ec82e3 --- /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 00000000000..7c81661ff0f --- /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)) + }) + } +} From fbafb7feb67c810c49632965df6aa9307055ad53 Mon Sep 17 00:00:00 2001 From: "coder[bot]" <137810501+coder[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:58:52 -0700 Subject: [PATCH 64/69] ci: bump agents-chat-action to v0.5.1 in doc-check (backport 2.36) (#30848) Bumps the pinned `coder/agents-chat-action` in `.github/workflows/doc-check.yaml` on `release/2.36` from v0.3.0 to v0.5.1 (`9b3ac42`). Same change as #29732 on `main`. ## Why The `Analyze PR for Documentation Updates Needed` job fails on every PR into `release/2.36` (e.g. #30810) with `Coder API error: Not Found`. The pinned version calls `/api/experimental/chats`, which #29548 removed after the chat API moved to `/api/v2` (#28496). v0.5.1 calls `/api/v2/chats`. v0.5.1 accepts every input this workflow passes (its inputs are a superset of v0.3.0's), so no other workflow changes are needed. The doc-check run on this PR is the end-to-end test. ## Changes - `.github/workflows/doc-check.yaml`: one `uses:` line. Related: [ENT-210](https://linear.app/codercom/issue/ENT-210/ironbank-critical-bump-go-to-1269-fix-by-2026-10-29) (surfaced on the Go 1.26.9 backports). Opened by Superblink on behalf of @droopy4096. Co-authored-by: Dmytro Makovey --- .github/workflows/doc-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/doc-check.yaml b/.github/workflows/doc-check.yaml index c692b7e2a8b..935bd77647f 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 }} From 7b527b4abaaa5500a976a946de7440fb177fdfee Mon Sep 17 00:00:00 2001 From: "coder[bot]" <137810501+coder[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:06:54 -0700 Subject: [PATCH 65/69] fix: bump bundled terraform to 1.16.6 for 2.36 (#30553) Backport of #30516 to `release/2.36`. Bumps the bundled Terraform binary to 1.16.6 (latest stable, [released 2026-10-14](https://github.com/hashicorp/terraform/releases/tag/v1.16.6)) so all supported branches ship the same version. Previous pins were out of sync: the shipped binary (`install.sh`, `scripts/Dockerfile.base`, `mise`, `flake.nix`, `testdata/version.txt`) was 1.15.5, while `TerraformVersion` in `provisioner/terraform/install.go` was still 1.14.5. All are now 1.16.6. This is a minor version jump, so `maxTerraformVersion` is raised to 1.16.9. Checksums and Nix SRI hashes come from HashiCorp's `terraform_1.16.6_SHA256SUMS`. Verified with `go test ./provisioner/terraform/...` on this branch. Opened by Superblink on behalf of @droopy4096. _Updated from 1.16.5 to 1.16.6 (adds an HTTP/2 resource-exhaustion fix). Branch name still says 1.16.5. Tests not re-run locally for 1.16.6, relying on CI._ --------- Co-authored-by: coder[bot] <137810501+coder[bot]@users.noreply.github.com> Co-authored-by: Dmitry Makovey --- docs/install/airgap.md | 2 +- flake.nix | 12 +++--- install.sh | 2 +- mise.lock | 46 +++++++++++----------- mise.toml | 2 +- provisioner/terraform/install.go | 4 +- provisioner/terraform/testdata/version.txt | 2 +- scripts/Dockerfile.base | 2 +- 8 files changed, 36 insertions(+), 36 deletions(-) diff --git a/docs/install/airgap.md b/docs/install/airgap.md index 06e99571c37..4f3c32e4896 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/flake.nix b/flake.nix index 3057b32d2eb..504e03ef68a 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/install.sh b/install.sh index daf4f598369..51350857a76 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 b96d6b9ae62..f14c3e5fa91 100644 --- a/mise.lock +++ b/mise.lock @@ -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 db02df3a168..2c5f164a64c 100644 --- a/mise.toml +++ b/mise.toml @@ -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 2137e99cb92..f4b229ce56e 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 d32434904bc..de646d2fc11 100644 --- a/provisioner/terraform/testdata/version.txt +++ b/provisioner/terraform/testdata/version.txt @@ -1 +1 @@ -1.15.5 +1.16.6 diff --git a/scripts/Dockerfile.base b/scripts/Dockerfile.base index 7ed3b76276c..93146ce7226 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 && \ From 523009b3528579c7da7082c685faa38cbb58ff33 Mon Sep 17 00:00:00 2001 From: Dmitry Makovey Date: Thu, 15 Oct 2026 12:09:38 -0700 Subject: [PATCH 66/69] fix: bump golang.org/x/net to v0.60.0 (backport 2.36) (#30816) Backport of the `golang.org/x/net` v0.60.0 bump (#30772) to `release/2.36`, as a minimal `go.mod` / `go.sum` change. Tracked in Linear: [ENT-211](https://linear.app/codercom/issue/ENT-211/ironbank-critical-bump-golangorgxnet-to-v0600-on-release-branches-fix) (Iron Bank fix-by date 2026-10-29 for the Critical finding). It complements the Go bump in [ENT-210](https://linear.app/codercom/issue/ENT-210/ironbank-critical-bump-go-to-1269-fix-by-2026-10-29): the Go bump alone does not clear GO-2026-6612 (Critical), -6603, -6610, -6611 and -6617, which are fixed in x/net v0.60.0. `golang.org/x/net` is v0.57.0 here; this moves it to v0.60.0. Only `golang.org/x/*` modules change. The extra bumps are the minimum Go's version selection requires and cannot be held back: x/net v0.60.0 requires x/crypto v0.57.0, x/sys v0.48.0, x/term v0.46.0 and x/text v0.42.0, and x/text v0.42.0 in turn requires x/tools v0.49.0, x/mod v0.41.0 and x/sync v0.23.0. Also moved: golang.org/x/crypto v0.57.0, golang.org/x/mod v0.41.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/telemetry v0.0.0-20260811182544-a038080d80e5. No non-`x` module changes. Not built locally; CI will build it. Opened by Superblink on behalf of @droopy4096. --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index bb7fef16db4..ce9f5c238b7 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 5592a9d1043..4160f34b705 100644 --- a/go.sum +++ b/go.sum @@ -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= From 676a147faa669ed9eab99b3c64610f8683aebb2f Mon Sep 17 00:00:00 2001 From: Dmitry Makovey Date: Thu, 15 Oct 2026 12:34:03 -0700 Subject: [PATCH 67/69] fix: bump Go version from 1.26.5 to 1.26.9 (backport 2.36) (#30810) Backport of #30807 to `release/2.36`. Bumps the Go toolchain from 1.26.5 to 1.26.9. Tracked in Linear: [ENT-210](https://linear.app/codercom/issue/ENT-210/ironbank-critical-bump-go-to-1269-fix-by-2026-10-29) (Iron Bank fix-by date 2026-10-29 for the Critical finding). Same approach as the Terraform 1.16.5 backports (#30516 and its backports). Files changed: `go.mod`, `mise.toml`, `mise.lock` (toolchain pin plus the per-platform archive URLs and SHA-256 checksums from the official `go.dev/dl` list). Not built or scanned locally; CI will build it. **Companion PR:** the module side of GO-2026-6612 (Critical), -6603, -6610, -6611 and -6617 is fixed by `golang.org/x/net` v0.60.0, backported in https://github.com/coder/coder/pull/30816 ([ENT-211](https://linear.app/codercom/issue/ENT-211/ironbank-critical-bump-golangorgxnet-to-v0600-on-release-branches-fix)). This PR alone does not clear them; both are needed. Opened by Superblink on behalf of @droopy4096. --- go.mod | 2 +- mise.lock | 46 +++++++++++++++++++++++----------------------- mise.toml | 2 +- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index ce9f5c238b7..30081da81cb 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 diff --git a/mise.lock b/mise.lock index f14c3e5fa91..d98f7d51d3e 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" diff --git a/mise.toml b/mise.toml index 2c5f164a64c..6005fe2f040 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" From d3ab3fe6b96d07a1fc6b71cff60a06f120ee96f5 Mon Sep 17 00:00:00 2001 From: "coder-backporter-app[bot]" <335582564+coder-backporter-app[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:04:02 +0200 Subject: [PATCH 68/69] fix(aibridge/intercept/messages): treat Bedrock Opus 5, Haiku 5.5, Fable 5, and Mythos 5 as adaptive-only (#30692) (#30748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of https://github.com/coder/coder/pull/30692 Original PR: #30692 — fix(aibridge/intercept/messages): treat Bedrock Opus 5, Haiku 5.5, Fable 5, and Mythos 5 as adaptive-only Merge commit: e6d24ff30aa04597220754a1b55aa5219778461c Requested by: @dannykoppingConflicts resolved manually. They came from #29762 (Opus 5.5) never being backported to this branch: the `anthropic.claude-opus-5-5` line and its test cases weren't present. Took the #30692 allowlist as-is (the `anthropic.claude-opus-5` prefix also covers Opus 5.5) and kept only the 8 test cases #30692 added. `go test ./aibridge/intercept/messages/` passes. Co-authored-by: Danny Kopping --- aibridge/intercept/messages/base.go | 10 +++- .../intercept/messages/base_internal_test.go | 56 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go index f8143b63d80..f3b70c55a62 100644 --- a/aibridge/intercept/messages/base.go +++ b/aibridge/intercept/messages/base.go @@ -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. +// "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-sonnet-5") + 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 d70d2fa8795..e82fa5d6fab 100644 --- a/aibridge/intercept/messages/base_internal_test.go +++ b/aibridge/intercept/messages/base_internal_test.go @@ -780,6 +780,62 @@ func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { 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. From c2380036596eb2d79477b9eb60eb3c0d1f441a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Fri, 16 Oct 2026 15:11:14 -0600 Subject: [PATCH 69/69] ci: add coder-backporter-app[bot] to CLA allowlist (#30801) --- .github/workflows/contrib.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/contrib.yaml b/.github/workflows/contrib.yaml index 2292246988e..4d76882c5c1 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