From 3deecb481e291e7d757ced0cd68519b814b69fe9 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Wed, 29 Jul 2026 14:59:58 +0100 Subject: [PATCH] chore: remove ai-gateway-cost-control experiment flag (#27579) ## Description Closes [AIGOV-443](https://linear.app/codercom/issue/AIGOV-443/remove-ai-gateway-cost-control-experiment-flag-once-feature-is-stable). The AI Gateway cost control feature is planned for GA on the upcoming release, so this removes the `ExperimentAIGatewayCostControl` experiment and all of its gating. The cost control API endpoints remain gated by the `FeatureAIBridge` license feature (the AI Governance add-on), so this only drops the experiment layer. ## Changes - **`codersdk/deployment.go`**: remove the `ExperimentAIGatewayCostControl` const, its `DisplayName()` case, and its `ExperimentsKnown` entry. - **`enterprise/coderd/coderd.go`**: remove the `httpmw.RequireExperiment(...)` gating from the AI cost control routes. They keep `RequireFeatureMW(codersdk.FeatureAIBridge)`. Affected endpoints: - `GET /organizations/{organization}/groups/ai/spend` - `GET /organizations/{organization}/groups/{groupName}/members/ai/spend` - `GET /organizations/{organization}/ai/spend/export` - `GET /groups/{group}/members/ai/spend` - `GET /groups/{group}/ai/spend` - `GET/PUT/DELETE /users/{user}/ai/budget/override` and `GET /users/{user}/ai/spend` - **`enterprise/coderd/aibridge_test.go`**: drop the experiment from test setup and remove the now-obsolete `RequiresExperiment` negative-path tests. - **Frontend (`site/src/...`)**: remove the `ai-gateway-cost-control` experiment checks from the cost control UI (Groups pages, user dropdown) and their stories/mocks. The feature is now driven solely by the `aibridge` feature visibility. - **Generated**: regenerated `coderd/apidoc/*`, `docs/reference/api/schemas.md`, and `site/src/api/typesGenerated.ts`. ## Out of scope The dogfood `CODER_EXPERIMENTS` config lives in a separate infra repo, not `coder/coder`. Leaving `ai-gateway-cost-control` there is harmless: unknown experiment values are logged as `"ignoring unknown experiment"` at startup and otherwise ignored, so no ordering dependency or breakage. That cleanup can be a follow-up.
Implementation notes - Verified how unknown experiments are handled in `coderd/coderd.go` `ReadExperiments`: unknown values produce a warning log and are inert, so removing the definition before the dogfood config is updated is safe. - Noticed the group `ai/budget` routes (`/groups/{group}/ai/budget`) were already gated only by `FeatureAIBridge`, never by the experiment. After this change all cost control routes are uniformly feature-gated, resolving that inconsistency. - Removed an obsolete `RequiresExperiment` subtest in `TestUserAISpendStatus` that only asserted a 403 from the experiment gate; with the gate gone it would no longer be blocked pre-RBAC.
--- _This PR was created by Coder Agents on behalf of @ssncferreira._ --- 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 1a856f59e3..388c35f114 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -20315,13 +20315,11 @@ const docTemplate = `{ "minimum-implicit-member", "workspace-capable-licensing", "ai-gateway-seat-exclusion", - "ai-gateway-cost-control", "chat-advisor", "chat-virtual-desktop", "agent-lifecycle-hooks" ], "x-enum-comments": { - "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", "ExperimentAIGatewaySeatExclusion": "Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption.", "ExperimentAgentLifecycleHooks": "Enables chat lifecycle hook webhooks for agent chats.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", @@ -20349,7 +20347,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.", "Enables chat lifecycle hook webhooks for agent chats." @@ -20366,7 +20363,6 @@ const docTemplate = `{ "ExperimentMinimumImplicitMember", "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewaySeatExclusion", - "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop", "ExperimentAgentLifecycleHooks" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3b93ddb903..4553c63fc9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -18449,13 +18449,11 @@ "minimum-implicit-member", "workspace-capable-licensing", "ai-gateway-seat-exclusion", - "ai-gateway-cost-control", "chat-advisor", "chat-virtual-desktop", "agent-lifecycle-hooks" ], "x-enum-comments": { - "ExperimentAIGatewayCostControl": "Enables AI Gateway cost control functionality.", "ExperimentAIGatewaySeatExclusion": "Excludes AI Gateway (AI Bridge) usage from AI Governance seat consumption.", "ExperimentAgentLifecycleHooks": "Enables chat lifecycle hook webhooks for agent chats.", "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", @@ -18483,7 +18481,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.", "Enables chat lifecycle hook webhooks for agent chats." @@ -18500,7 +18497,6 @@ "ExperimentMinimumImplicitMember", "ExperimentWorkspaceCapableLicensing", "ExperimentAIGatewaySeatExclusion", - "ExperimentAIGatewayCostControl", "ExperimentChatAdvisor", "ExperimentChatVirtualDesktop", "ExperimentAgentLifecycleHooks" diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 51ba3c6a8d..5d6c624d20 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5365,7 +5365,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. ExperimentAgentLifecycleHooks Experiment = "agent-lifecycle-hooks" // Enables chat lifecycle hook webhooks for agent chats. @@ -5395,8 +5394,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: @@ -5424,7 +5421,6 @@ var ExperimentsKnown = Experiments{ ExperimentMinimumImplicitMember, ExperimentWorkspaceCapableLicensing, ExperimentAIGatewaySeatExclusion, - ExperimentAIGatewayCostControl, ExperimentChatAdvisor, ExperimentChatVirtualDesktop, ExperimentAgentLifecycleHooks, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 436e50ae24..f8295c7d1b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7473,9 +7473,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `agent-lifecycle-hooks`, `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) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `agent-lifecycle-hooks`, `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 44ace6025c..00c7b4dde7 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 bff2523fdb..8df7476400 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 b16a2920fd..5dadf27b2b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -5097,7 +5097,6 @@ export const EntitlementsWarningHeader = "X-Coder-Entitlements-Warning"; // From codersdk/deployment.go export type Experiment = - | "ai-gateway-cost-control" | "ai-gateway-seat-exclusion" | "agent-lifecycle-hooks" | "auto-fill-parameters" @@ -5114,7 +5113,6 @@ export type Experiment = | "workspace-usage"; export const Experiments: Experiment[] = [ - "ai-gateway-cost-control", "ai-gateway-seat-exclusion", "agent-lifecycle-hooks", "auto-fill-parameters", diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 1e0900dc25..6260b96dc7 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 828ffcc17c..2cc33a8fa0 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 8ce3e8d8e9..b716da6313 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 beda74199d..ff4ee823f2 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 b117e49af7..fc92df4089 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -159,7 +159,6 @@ export const MembersSpendError: Story = { }, parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ users: [MockUserMember], count: 1 }), @@ -314,7 +313,6 @@ const mockGroupBudget: GroupAIBudget = { export const WithMemberAIBudget: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -397,7 +395,6 @@ export const WithoutMemberAIBudgetColumn: Story = { export const AIBudgetActionDisabledForOtherGroup: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -451,7 +448,6 @@ export const AIBudgetActionDisabledForOtherGroup: Story = { export const WithMemberAIBudgetInAnotherOrg: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -508,7 +504,6 @@ export const WithMemberAIBudgetInAnotherOrg: Story = { export const OpenAIBudgetForCurrentGroupMember: Story = { parameters: { features: ["aibridge"], - experiments: ["ai-gateway-cost-control"], queries: [ groupQuery(MockGroupWithoutMembers), groupMembersQuery({ @@ -663,7 +658,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 5e34c186d4..e7f5d29359 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 65d592a9ef..b1fd6d89f5 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),