From 43415f0144920e549b15199dc1c5a8592bd3780a Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Sat, 27 Sep 2025 01:13:06 +1000 Subject: [PATCH] chore: add enterprise feature for aibridge (#19976) Adds enterprise feature "aibridge" and gates the aibridge CRUD and LLM API endpoints behind it. --- codersdk/deployment.go | 4 ++ enterprise/cli/exp_aibridge_test.go | 16 +++++++ enterprise/cli/server.go | 6 ++- enterprise/coderd/aibridge_test.go | 56 +++++++++++++++++++++++ enterprise/coderd/coderd.go | 2 + enterprise/coderd/license/license_test.go | 1 + site/src/api/typesGenerated.ts | 2 + 7 files changed, 86 insertions(+), 1 deletion(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 763da724c6..9549b0b98e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -90,6 +90,7 @@ const ( // enterprise/coderd/license/license.go for the license format. FeatureManagedAgentLimit FeatureName = "managed_agent_limit" FeatureWorkspaceExternalAgent FeatureName = "workspace_external_agent" + FeatureAIBridge FeatureName = "aibridge" ) var ( @@ -117,6 +118,7 @@ var ( FeatureWorkspacePrebuilds, FeatureManagedAgentLimit, FeatureWorkspaceExternalAgent, + FeatureAIBridge, } // FeatureNamesMap is a map of all feature names for quick lookups. @@ -136,6 +138,8 @@ func (n FeatureName) Humanize() string { return "Template RBAC" case FeatureSCIM: return "SCIM" + case FeatureAIBridge: + return "AI Bridge" default: return strings.Title(strings.ReplaceAll(string(n), "_", " ")) } diff --git a/enterprise/cli/exp_aibridge_test.go b/enterprise/cli/exp_aibridge_test.go index 454e81d070..64b7eef698 100644 --- a/enterprise/cli/exp_aibridge_test.go +++ b/enterprise/cli/exp_aibridge_test.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/testutil" ) @@ -31,6 +32,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) now := dbtime.Now() @@ -77,6 +83,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) @@ -158,6 +169,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 1e3c71f6eb..ea9f2d3e93 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -148,8 +148,12 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { experiments := agplcoderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value()) - var aibridgeDaemon *aibridged.Server // In-memory aibridge daemon. + // TODO(@deansheather): the lifecycle of the aibridged server is + // probably better managed by the enterprise API type itself. Managing + // it in the API type means we can avoid starting it up when the license + // is not entitled to the feature. + var aibridgeDaemon *aibridged.Server if options.DeploymentValues.AI.BridgeConfig.Enabled { if experiments.Enabled(codersdk.ExperimentAIBridge) { aibridgeDaemon, err = newAIBridgeDaemon(api) diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index e323c0e2b8..8babf2324d 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "net/http" "testing" "time" @@ -15,12 +16,37 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/testutil" ) func TestAIBridgeListInterceptions(t *testing.T) { t.Parallel() + t.Run("RequiresLicenseFeature", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentAIBridge)} + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + // No aibridge feature + Features: license.Features{}, + }, + }) + experimentalClient := codersdk.NewExperimentalClient(client) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := experimentalClient.AIBridgeListInterceptions(ctx, codersdk.AIBridgeListInterceptionsFilter{}) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + require.Equal(t, "AI Bridge is a Premium feature. Contact sales!", sdkErr.Message) + }) + t.Run("EmptyDB", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) @@ -29,6 +55,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) experimentalClient := codersdk.NewExperimentalClient(client) ctx := testutil.Context(t, testutil.WaitLong) @@ -45,6 +76,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) experimentalClient := codersdk.NewExperimentalClient(client) ctx := testutil.Context(t, testutil.WaitLong) @@ -126,6 +162,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) experimentalClient := codersdk.NewExperimentalClient(client) ctx := testutil.Context(t, testutil.WaitLong) @@ -210,6 +251,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) adminExperimentalClient := codersdk.NewExperimentalClient(adminClient) ctx := testutil.Context(t, testutil.WaitLong) @@ -249,6 +295,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) experimentalClient := codersdk.NewExperimentalClient(client) _, secondUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) @@ -407,6 +458,11 @@ func TestAIBridgeListInterceptions(t *testing.T) { Options: &coderdtest.Options{ DeploymentValues: dv, }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAIBridge: 1, + }, + }, }) experimentalClient := codersdk.NewExperimentalClient(client) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 73585a8e6b..b699fc264d 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -229,6 +229,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { api.AGPL.ExperimentalHandler.Group(func(r chi.Router) { r.Route("/aibridge", func(r chi.Router) { r.Use( + api.RequireFeatureMW(codersdk.FeatureAIBridge), httpmw.RequireExperimentWithDevBypass(api.AGPL.Experiments, codersdk.ExperimentAIBridge), ) r.Group(func(r chi.Router) { @@ -770,6 +771,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureUserRoleManagement: true, codersdk.FeatureAccessControl: true, codersdk.FeatureControlSharedPorts: true, + codersdk.FeatureAIBridge: true, }) if err != nil { return codersdk.Entitlements{}, err diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 2cb264f4c7..0e540989b6 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -890,6 +890,7 @@ func TestLicenseEntitlements(t *testing.T) { codersdk.FeatureAccessControl: true, codersdk.FeatureControlSharedPorts: true, codersdk.FeatureWorkspaceExternalAgent: true, + codersdk.FeatureAIBridge: true, } legacyLicense := func() *coderdenttest.LicenseOptions { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b480408fd4..0cea1c5920 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1222,6 +1222,7 @@ export interface Feature { // From codersdk/deployment.go export type FeatureName = + | "aibridge" | "access_control" | "advanced_template_scheduling" | "appearance" @@ -1246,6 +1247,7 @@ export type FeatureName = | "workspace_proxy"; export const FeatureNames: FeatureName[] = [ + "aibridge", "access_control", "advanced_template_scheduling", "appearance",