mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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. <details> <summary>Implementation notes</summary> - 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. </details> --- _This PR was created by Coder Agents on behalf of @ssncferreira._
This commit is contained in:
Generated
-4
@@ -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"
|
||||
|
||||
Generated
-4
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
|
||||
Generated
-2
@@ -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",
|
||||
|
||||
@@ -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<typeof UserDropdown> = {
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<UserDropdownProps> = ({
|
||||
supportLinks,
|
||||
onSignOut,
|
||||
}) => {
|
||||
const { experiments } = useDashboard();
|
||||
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
|
||||
const aibridgeVisible =
|
||||
Boolean(useFeatureVisibility().aibridge) &&
|
||||
experiments.includes("ai-gateway-cost-control");
|
||||
const aibridgeVisible = Boolean(useFeatureVisibility().aibridge);
|
||||
const { data, isError } = useQuery({
|
||||
...meAISpend(),
|
||||
enabled: aibridgeVisible,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<MemberWithSpend | null>(null);
|
||||
|
||||
const { experiments } = useDashboard();
|
||||
// TODO(AIGOV-443): drop the experiment gate once cost control is stable.
|
||||
const aibridgeVisible =
|
||||
Boolean(useFeatureVisibility().aibridge) &&
|
||||
experiments.includes("ai-gateway-cost-control");
|
||||
const aibridgeVisible = Boolean(useFeatureVisibility().aibridge);
|
||||
const { data: aiSpend } = useQuery({
|
||||
...meAISpend(),
|
||||
enabled: aibridgeVisible,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user