diff --git a/cli/server.go b/cli/server.go index 4c8c41f415..7b2b77a2d0 100644 --- a/cli/server.go +++ b/cli/server.go @@ -856,11 +856,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. ) } - aibridgeProviders, err := ReadAIBridgeProvidersFromEnv(logger, os.Environ()) + aiProviders, err := ReadAIProvidersFromEnv(logger, os.Environ()) if err != nil { - return xerrors.Errorf("read aibridge providers from env: %w", err) + return xerrors.Errorf("read AI providers from env: %w", err) } - vals.AI.BridgeConfig.Providers = append(vals.AI.BridgeConfig.Providers, aibridgeProviders...) + vals.AI.BridgeConfig.Providers = append(vals.AI.BridgeConfig.Providers, aiProviders...) // Manage push notifications. webpusher, err := webpush.New(ctx, ptr.Ref(options.Logger.Named("webpush")), options.Database, options.AccessURL.String()) @@ -2926,10 +2926,10 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder return providers, nil } -// ReadAIBridgeProvidersFromEnv parses CODER_AIBRIDGE_PROVIDER__ -// environment variables into a slice of AIBridgeProviderConfig. +// ReadAIProvidersFromEnv parses CODER_AIBRIDGE_PROVIDER__ +// environment variables into a slice of AIProviderConfig. // This follows the same indexed pattern as ReadExternalAuthProvidersFromEnv. -func ReadAIBridgeProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AIBridgeProviderConfig, error) { +func ReadAIProvidersFromEnv(logger slog.Logger, environ []string) ([]codersdk.AIProviderConfig, error) { parsed := serpent.ParseEnviron(environ, "CODER_AIBRIDGE_PROVIDER_") // Sort by numeric index so that PROVIDER_2 comes before PROVIDER_10. @@ -2942,7 +2942,7 @@ func ReadAIBridgeProvidersFromEnv(logger slog.Logger, environ []string) ([]coder return strings.Compare(a.Name, b.Name) }) - var providers []codersdk.AIBridgeProviderConfig + var providers []codersdk.AIProviderConfig for _, v := range parsed { tokens := strings.SplitN(v.Name, "_", 2) if len(tokens) != 2 { @@ -2954,7 +2954,7 @@ func ReadAIBridgeProvidersFromEnv(logger slog.Logger, environ []string) ([]coder return nil, xerrors.Errorf("parse number: %s", v.Name) } - var provider codersdk.AIBridgeProviderConfig + var provider codersdk.AIProviderConfig switch { case len(providers) < providerNum: return nil, xerrors.Errorf( @@ -3014,7 +3014,7 @@ func ReadAIBridgeProvidersFromEnv(logger slog.Logger, environ []string) ([]coder case "BEDROCK_SMALL_FAST_MODEL": provider.BedrockSmallFastModel = v.Value default: - logger.Warn(context.Background(), "ignoring unknown aibridge provider field (check for typos)", + logger.Warn(context.Background(), "ignoring unknown AI provider field (check for typos)", slog.F("env", fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_%s", providerNum, key)), ) } @@ -3066,7 +3066,7 @@ func ReadAIBridgeProvidersFromEnv(logger slog.Logger, environ []string) ([]coder return providers, nil } -func hasBedrockFields(p codersdk.AIBridgeProviderConfig) bool { +func hasBedrockFields(p codersdk.AIProviderConfig) bool { return p.BedrockBaseURL != "" || p.BedrockRegion != "" || len(p.BedrockAccessKeys) > 0 || len(p.BedrockAccessKeySecrets) > 0 || p.BedrockModel != "" || p.BedrockSmallFastModel != "" diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go index 6ab6bca585..5f05a69f1e 100644 --- a/cli/server_aibridge_internal_test.go +++ b/cli/server_aibridge_internal_test.go @@ -14,13 +14,13 @@ import ( "github.com/coder/coder/v2/testutil" ) -func TestReadAIBridgeProvidersFromEnv(t *testing.T) { +func TestReadAIProvidersFromEnv(t *testing.T) { t.Parallel() tests := []struct { name string env []string - expected []codersdk.AIBridgeProviderConfig + expected []codersdk.AIProviderConfig errContains string }{ { @@ -36,7 +36,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_BASE_URL=https://api.anthropic.com/", "CODER_AIBRIDGE_PROVIDER_0_DUMP_DIR=/tmp/aibridge-dump", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ { Type: aibridge.ProviderAnthropic, Name: "anthropic-zdr", @@ -55,7 +55,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_1_NAME=anthropic-eu", "CODER_AIBRIDGE_PROVIDER_1_BASE_URL=https://eu.api.anthropic.com/", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderAnthropic, Name: "anthropic-us"}, {Type: aibridge.ProviderAnthropic, Name: "anthropic-eu", BaseURL: "https://eu.api.anthropic.com/"}, }, @@ -65,7 +65,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { env: []string{ "CODER_AIBRIDGE_PROVIDER_0_TYPE=openai", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI}, }, }, @@ -79,7 +79,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_2_NAME=copilot-custom", "CODER_AIBRIDGE_PROVIDER_2_BASE_URL=https://custom.copilot.com", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderAnthropic, Name: "anthropic-main"}, {Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI}, {Type: aibridge.ProviderCopilot, Name: "copilot-custom", BaseURL: "https://custom.copilot.com"}, @@ -97,7 +97,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_BEDROCK_SMALL_FAST_MODEL=anthropic.claude-3-haiku", "CODER_AIBRIDGE_PROVIDER_0_BEDROCK_BASE_URL=https://bedrock.us-west-2.amazonaws.com", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ { Type: aibridge.ProviderAnthropic, Name: "anthropic-bedrock", @@ -118,7 +118,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_TYPE=openai", "CODER_AIBRIDGE_PROVIDER_0_NAME=first", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: "first"}, {Type: aibridge.ProviderAnthropic, Name: "second"}, }, @@ -172,7 +172,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_KEY=sk-xxx", "SOME_OTHER_VAR=hello", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Keys: []string{"sk-xxx"}}, }, }, @@ -186,7 +186,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID", "CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRETS=secret", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ { Type: aibridge.ProviderAnthropic, Name: aibridge.ProviderAnthropic, @@ -245,7 +245,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_TYPE=openai", "CODER_AIBRIDGE_PROVIDER_0_KEYS=sk-a,sk-b,sk-c", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Keys: []string{"sk-a", "sk-b", "sk-c"}}, }, }, @@ -255,7 +255,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_TYPE=openai", "CODER_AIBRIDGE_PROVIDER_0_KEYS= sk-a , sk-b ", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Keys: []string{"sk-a", "sk-b"}}, }, }, @@ -291,7 +291,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { "CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEYS=AKID1,AKID2", "CODER_AIBRIDGE_PROVIDER_0_BEDROCK_ACCESS_KEY_SECRETS=secret1,secret2", }, - expected: []codersdk.AIBridgeProviderConfig{ + expected: []codersdk.AIProviderConfig{ { Type: aibridge.ProviderAnthropic, Name: aibridge.ProviderAnthropic, @@ -324,7 +324,7 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - providers, err := ReadAIBridgeProvidersFromEnv(slogtest.Make(t, nil), tt.env) + providers, err := ReadAIProvidersFromEnv(slogtest.Make(t, nil), tt.env) if tt.errContains != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.errContains) @@ -342,20 +342,20 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { // Indices 0, 1, 2, ..., 10 — verifies that 10 sorts after 2, // not between 1 and 2 as a lexicographic sort would do. var env []string - var expected []codersdk.AIBridgeProviderConfig + var expected []codersdk.AIProviderConfig for i := range 11 { env = append(env, fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_TYPE=openai", i), fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_KEY=sk-%d", i, i), fmt.Sprintf("CODER_AIBRIDGE_PROVIDER_%d_NAME=p%d", i, i), ) - expected = append(expected, codersdk.AIBridgeProviderConfig{ + expected = append(expected, codersdk.AIProviderConfig{ Type: aibridge.ProviderOpenAI, Name: fmt.Sprintf("p%d", i), Keys: []string{fmt.Sprintf("sk-%d", i)}, }) } - providers, err := ReadAIBridgeProvidersFromEnv(slogtest.Make(t, nil), env) + providers, err := ReadAIProvidersFromEnv(slogtest.Make(t, nil), env) require.NoError(t, err) require.Equal(t, expected, providers) }) @@ -365,17 +365,17 @@ func TestReadAIBridgeProvidersFromEnv(t *testing.T) { // A typo like TPYE instead of TYPE should not prevent startup; // the function logs a warning and continues. sink := testutil.NewFakeSink(t) - providers, err := ReadAIBridgeProvidersFromEnv(sink.Logger(), []string{ + providers, err := ReadAIProvidersFromEnv(sink.Logger(), []string{ "CODER_AIBRIDGE_PROVIDER_0_TYPE=openai", "CODER_AIBRIDGE_PROVIDER_0_TPYE=openai", }) require.NoError(t, err) - require.Equal(t, []codersdk.AIBridgeProviderConfig{ + require.Equal(t, []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI}, }, providers) warnings := sink.Entries(func(e slog.SinkEntry) bool { - return e.Message == "ignoring unknown aibridge provider field (check for typos)" + return e.Message == "ignoring unknown AI provider field (check for typos)" }) require.Len(t, warnings, 1) require.Len(t, warnings[0].Fields, 1) diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index c7535238b7..cfbb6ba1b3 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -872,8 +872,8 @@ aibridgeproxy: # (default: , type: string) key_file: "" # Deprecated: This value is now derived automatically from the configured AI - # Bridge providers' base URLs. Setting this value has no effect. This option will - # be removed in a future release. + # providers' base URLs. Setting this value has no effect. This option will be + # removed in a future release. # (default: , type: string-array) domain_allowlist: [] # URL of an upstream HTTP proxy to chain tunneled (non-allowlisted) requests diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 258815011e..01647cbd34 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13889,7 +13889,7 @@ const docTemplate = `{ "description": "Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_\u003cKEY\u003e\nenv vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above.", "type": "array", "items": { - "$ref": "#/definitions/codersdk.AIBridgeProviderConfig" + "$ref": "#/definitions/codersdk.AIProviderConfig" } }, "rate_limit": { @@ -14010,36 +14010,6 @@ const docTemplate = `{ } } }, - "codersdk.AIBridgeProviderConfig": { - "type": "object", - "properties": { - "base_url": { - "description": "BaseURL is the base URL of the upstream provider API.", - "type": "string" - }, - "bedrock_model": { - "type": "string" - }, - "bedrock_region": { - "type": "string" - }, - "bedrock_small_fast_model": { - "type": "string" - }, - "dump_dir": { - "description": "DumpDir is the directory path for dumping API requests and responses.", - "type": "string" - }, - "name": { - "description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.", - "type": "string" - }, - "type": { - "description": "Type is the provider type: \"openai\", \"anthropic\", or \"copilot\".", - "type": "string" - } - } - }, "codersdk.AIBridgeProxyConfig": { "type": "object", "properties": { @@ -14421,6 +14391,36 @@ const docTemplate = `{ } } }, + "codersdk.AIProviderConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "BaseURL is the base URL of the upstream provider API.", + "type": "string" + }, + "bedrock_model": { + "type": "string" + }, + "bedrock_region": { + "type": "string" + }, + "bedrock_small_fast_model": { + "type": "string" + }, + "dump_dir": { + "description": "DumpDir is the directory path for dumping API requests and responses.", + "type": "string" + }, + "name": { + "description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.", + "type": "string" + }, + "type": { + "description": "Type is the provider type: \"openai\", \"anthropic\", or \"copilot\".", + "type": "string" + } + } + }, "codersdk.APIAllowListTarget": { "type": "object", "properties": { @@ -14522,6 +14522,11 @@ const docTemplate = `{ "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", + "ai_provider:*", + "ai_provider:create", + "ai_provider:delete", + "ai_provider:read", + "ai_provider:update", "ai_seat:*", "ai_seat:create", "ai_seat:read", @@ -14737,6 +14742,11 @@ const docTemplate = `{ "APIKeyScopeAiModelPriceAll", "APIKeyScopeAiModelPriceRead", "APIKeyScopeAiModelPriceUpdate", + "APIKeyScopeAiProviderAll", + "APIKeyScopeAiProviderCreate", + "APIKeyScopeAiProviderDelete", + "APIKeyScopeAiProviderRead", + "APIKeyScopeAiProviderUpdate", "APIKeyScopeAiSeatAll", "APIKeyScopeAiSeatCreate", "APIKeyScopeAiSeatRead", @@ -21351,6 +21361,7 @@ const docTemplate = `{ "enum": [ "*", "ai_model_price", + "ai_provider", "ai_seat", "aibridge_interception", "api_key", @@ -21399,6 +21410,7 @@ const docTemplate = `{ "x-enum-varnames": [ "ResourceWildcard", "ResourceAiModelPrice", + "ResourceAIProvider", "ResourceAiSeat", "ResourceAibridgeInterception", "ResourceApiKey", @@ -21658,6 +21670,8 @@ const docTemplate = `{ "workspace_app", "task", "ai_seat", + "ai_provider", + "ai_provider_key", "chat", "user_secret" ], @@ -21689,6 +21703,8 @@ const docTemplate = `{ "ResourceTypeWorkspaceApp", "ResourceTypeTask", "ResourceTypeAISeat", + "ResourceTypeAIProvider", + "ResourceTypeAIProviderKey", "ResourceTypeChat", "ResourceTypeUserSecret" ] diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6baef26867..4cc8ee4374 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12361,7 +12361,7 @@ "description": "Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER_\u003cN\u003e_\u003cKEY\u003e\nenv vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above.", "type": "array", "items": { - "$ref": "#/definitions/codersdk.AIBridgeProviderConfig" + "$ref": "#/definitions/codersdk.AIProviderConfig" } }, "rate_limit": { @@ -12482,36 +12482,6 @@ } } }, - "codersdk.AIBridgeProviderConfig": { - "type": "object", - "properties": { - "base_url": { - "description": "BaseURL is the base URL of the upstream provider API.", - "type": "string" - }, - "bedrock_model": { - "type": "string" - }, - "bedrock_region": { - "type": "string" - }, - "bedrock_small_fast_model": { - "type": "string" - }, - "dump_dir": { - "description": "DumpDir is the directory path for dumping API requests and responses.", - "type": "string" - }, - "name": { - "description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.", - "type": "string" - }, - "type": { - "description": "Type is the provider type: \"openai\", \"anthropic\", or \"copilot\".", - "type": "string" - } - } - }, "codersdk.AIBridgeProxyConfig": { "type": "object", "properties": { @@ -12893,6 +12863,36 @@ } } }, + "codersdk.AIProviderConfig": { + "type": "object", + "properties": { + "base_url": { + "description": "BaseURL is the base URL of the upstream provider API.", + "type": "string" + }, + "bedrock_model": { + "type": "string" + }, + "bedrock_region": { + "type": "string" + }, + "bedrock_small_fast_model": { + "type": "string" + }, + "dump_dir": { + "description": "DumpDir is the directory path for dumping API requests and responses.", + "type": "string" + }, + "name": { + "description": "Name is the unique instance identifier used for routing.\nDefaults to Type if not provided.", + "type": "string" + }, + "type": { + "description": "Type is the provider type: \"openai\", \"anthropic\", or \"copilot\".", + "type": "string" + } + } + }, "codersdk.APIAllowListTarget": { "type": "object", "properties": { @@ -12986,6 +12986,11 @@ "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", + "ai_provider:*", + "ai_provider:create", + "ai_provider:delete", + "ai_provider:read", + "ai_provider:update", "ai_seat:*", "ai_seat:create", "ai_seat:read", @@ -13201,6 +13206,11 @@ "APIKeyScopeAiModelPriceAll", "APIKeyScopeAiModelPriceRead", "APIKeyScopeAiModelPriceUpdate", + "APIKeyScopeAiProviderAll", + "APIKeyScopeAiProviderCreate", + "APIKeyScopeAiProviderDelete", + "APIKeyScopeAiProviderRead", + "APIKeyScopeAiProviderUpdate", "APIKeyScopeAiSeatAll", "APIKeyScopeAiSeatCreate", "APIKeyScopeAiSeatRead", @@ -19575,6 +19585,7 @@ "enum": [ "*", "ai_model_price", + "ai_provider", "ai_seat", "aibridge_interception", "api_key", @@ -19623,6 +19634,7 @@ "x-enum-varnames": [ "ResourceWildcard", "ResourceAiModelPrice", + "ResourceAIProvider", "ResourceAiSeat", "ResourceAibridgeInterception", "ResourceApiKey", @@ -19872,6 +19884,8 @@ "workspace_app", "task", "ai_seat", + "ai_provider", + "ai_provider_key", "chat", "user_secret" ], @@ -19903,6 +19917,8 @@ "ResourceTypeWorkspaceApp", "ResourceTypeTask", "ResourceTypeAISeat", + "ResourceTypeAIProvider", + "ResourceTypeAIProviderKey", "ResourceTypeChat", "ResourceTypeUserSecret" ] diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index a95301ad78..3c6df99574 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -34,6 +34,8 @@ type Auditable interface { idpsync.RoleSyncSettings | database.TaskTable | database.AiSeatState | + database.AIProvider | + database.AIProviderKey | database.Chat | database.UserSecret } diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 178153660f..25da232c92 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -134,6 +134,13 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Name case database.AiSeatState: return "AI Seat" + case database.AIProvider: + return typed.Name + case database.AIProviderKey: + // Provider keys have no user-facing name; show the parent + // provider's UUID so the row can be correlated back to its + // provider in the audit UI. + return typed.ProviderID.String() case database.Chat: // Chat titles can contain sensitive content (secrets, internal // project names), so we use a short UUID prefix as a display @@ -210,6 +217,10 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.AiSeatState: return typed.UserID + case database.AIProvider: + return typed.ID + case database.AIProviderKey: + return typed.ID case database.Chat: return typed.ID case database.UserSecret: @@ -271,6 +282,10 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeTask case database.AiSeatState: return database.ResourceTypeAiSeat + case database.AIProvider: + return database.ResourceTypeAiProvider + case database.AIProviderKey: + return database.ResourceTypeAiProviderKey case database.Chat: return database.ResourceTypeChat case database.UserSecret: @@ -335,6 +350,12 @@ func ResourceRequiresOrgID[T Auditable]() bool { return true case database.AiSeatState: return false + case database.AIProvider: + // AI providers are deployment-scoped, not org-scoped. + return false + case database.AIProviderKey: + // AI provider keys are deployment-scoped, not org-scoped. + return false case database.Chat: // Chats always have a non-null organization_id (since // migration 000467). diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index a3b837bf22..b61650c68a 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -10,6 +10,7 @@ const ( CheckAiModelPricesCacheWritePriceCheck CheckConstraint = "ai_model_prices_cache_write_price_check" // ai_model_prices CheckAiModelPricesInputPriceCheck CheckConstraint = "ai_model_prices_input_price_check" // ai_model_prices CheckAiModelPricesOutputPriceCheck CheckConstraint = "ai_model_prices_output_price_check" // ai_model_prices + CheckAiProvidersNameCheck CheckConstraint = "ai_providers_name_check" // ai_providers CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys CheckChatModelConfigsCompressionThresholdCheck CheckConstraint = "chat_model_configs_compression_threshold_check" // chat_model_configs CheckChatModelConfigsContextLimitCheck CheckConstraint = "chat_model_configs_context_limit_check" // chat_model_configs diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bc6f5842c3..f251dcf06d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1851,6 +1851,20 @@ func (q *querier) CustomRoles(ctx context.Context, arg database.CustomRolesParam return q.db.CustomRoles(ctx, arg) } +func (q *querier) DeleteAIProviderByID(ctx context.Context, id uuid.UUID) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAIProvider); err != nil { + return err + } + return q.db.DeleteAIProviderByID(ctx, id) +} + +func (q *querier) DeleteAIProviderKey(ctx context.Context, id uuid.UUID) error { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAIProvider); err != nil { + return err + } + return q.db.DeleteAIProviderKey(ctx, id) +} + func (q *querier) DeleteAPIKeyByID(ctx context.Context, id string) error { return deleteQ(q.log, q.auth, q.db.GetAPIKeyByID, q.db.DeleteAPIKeyByID)(ctx, id) } @@ -2488,6 +2502,52 @@ func (q *querier) GetAIModelPriceByProviderModel(ctx context.Context, arg databa return q.db.GetAIModelPriceByProviderModel(ctx, arg) } +func (q *querier) GetAIProviderByID(ctx context.Context, id uuid.UUID) (database.AIProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil { + return database.AIProvider{}, err + } + return q.db.GetAIProviderByID(ctx, id) +} + +func (q *querier) GetAIProviderByName(ctx context.Context, name string) (database.AIProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil { + return database.AIProvider{}, err + } + return q.db.GetAIProviderByName(ctx, name) +} + +func (q *querier) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (database.AIProviderKey, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil { + return database.AIProviderKey{}, err + } + return q.db.GetAIProviderKeyByID(ctx, id) +} + +func (q *querier) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) { + // This query intentionally returns every key row, including those + // whose provider has been soft-deleted, so the dbcrypt key rotation + // utility can re-encrypt every row that holds a foreign-key + // reference to dbcrypt_keys. + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil { + return nil, err + } + return q.db.GetAIProviderKeys(ctx) +} + +func (q *querier) GetAIProviderKeysByProviderID(ctx context.Context, providerID uuid.UUID) ([]database.AIProviderKey, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil { + return nil, err + } + return q.db.GetAIProviderKeysByProviderID(ctx, providerID) +} + +func (q *querier) GetAIProviders(ctx context.Context, arg database.GetAIProvidersParams) ([]database.AIProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil { + return nil, err + } + return q.db.GetAIProviders(ctx, arg) +} + func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id) } @@ -5193,6 +5253,20 @@ func (q *querier) InsertAIBridgeUserPrompt(ctx context.Context, arg database.Ins return q.db.InsertAIBridgeUserPrompt(ctx, arg) } +func (q *querier) InsertAIProvider(ctx context.Context, arg database.InsertAIProviderParams) (database.AIProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAIProvider); err != nil { + return database.AIProvider{}, err + } + return q.db.InsertAIProvider(ctx, arg) +} + +func (q *querier) InsertAIProviderKey(ctx context.Context, arg database.InsertAIProviderKeyParams) (database.AIProviderKey, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAIProvider); err != nil { + return database.AIProviderKey{}, err + } + return q.db.InsertAIProviderKey(ctx, arg) +} + func (q *querier) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { // TODO(Cian): ideally this would be encoded in the policy, but system users are just members and we // don't currently have a capability to conditionally deny creating resources by owner ID in a role. @@ -6259,6 +6333,13 @@ func (q *querier) UpdateAIBridgeInterceptionEnded(ctx context.Context, params da return q.db.UpdateAIBridgeInterceptionEnded(ctx, params) } +func (q *querier) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIProvider); err != nil { + return database.AIProvider{}, err + } + return q.db.UpdateAIProvider(ctx, arg) +} + func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { fetch := func(ctx context.Context, arg database.UpdateAPIKeyByIDParams) (database.APIKey, error) { return q.db.GetAPIKeyByID(ctx, arg.ID) @@ -6545,6 +6626,27 @@ func (q *querier) UpdateCustomRole(ctx context.Context, arg database.UpdateCusto return q.db.UpdateCustomRole(ctx, arg) } +func (q *querier) UpdateEncryptedAIProviderKey(ctx context.Context, arg database.UpdateEncryptedAIProviderKeyParams) (database.AIProviderKey, error) { + // Encrypted columns can be rewritten on any row, including those + // whose provider has been soft-deleted, so the dbcrypt rotation can + // move every FK reference to a new key digest before old keys are + // revoked. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIProvider); err != nil { + return database.AIProviderKey{}, err + } + return q.db.UpdateEncryptedAIProviderKey(ctx, arg) +} + +func (q *querier) UpdateEncryptedAIProviderSettings(ctx context.Context, arg database.UpdateEncryptedAIProviderSettingsParams) (database.AIProvider, error) { + // Settings can be rewritten on any row, including soft-deleted ones, + // so the dbcrypt rotation can move every FK reference to a new key + // digest before old keys are revoked. + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceAIProvider); err != nil { + return database.AIProvider{}, err + } + return q.db.UpdateEncryptedAIProviderSettings(ctx, arg) +} + func (q *querier) UpdateExternalAuthLink(ctx context.Context, arg database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) { fetch := func(ctx context.Context, arg database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) { return q.db.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{UserID: arg.UserID, ProviderID: arg.ProviderID}) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 1b57628db8..f6983b0a20 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6208,6 +6208,103 @@ func (s *MethodTestSuite) TestAIBridge() { db.EXPECT().GetAIModelPriceByProviderModel(gomock.Any(), gomock.Any()).Return(database.AiModelPrice{}, nil).AnyTimes() check.Args(database.GetAIModelPriceByProviderModelParams{}).Asserts(rbac.ResourceAiModelPrice, policy.ActionRead) })) + + s.Run("GetAIProviderByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.AIProvider{}) + dbm.EXPECT().GetAIProviderByID(gomock.Any(), provider.ID).Return(provider, nil).AnyTimes() + check.Args(provider.ID).Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns(provider) + })) + s.Run("GetAIProviderByName", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.AIProvider{}) + dbm.EXPECT().GetAIProviderByName(gomock.Any(), provider.Name).Return(provider, nil).AnyTimes() + check.Args(provider.Name).Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns(provider) + })) + s.Run("GetAIProviders", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + providerA := testutil.Fake(s.T(), faker, database.AIProvider{}) + providerB := testutil.Fake(s.T(), faker, database.AIProvider{}) + arg := database.GetAIProvidersParams{} + dbm.EXPECT().GetAIProviders(gomock.Any(), arg).Return([]database.AIProvider{providerA, providerB}, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns([]database.AIProvider{providerA, providerB}) + })) + s.Run("InsertAIProvider", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + arg := database.InsertAIProviderParams{ + ID: uuid.New(), + Type: database.AiProviderTypeOpenai, + Name: "test-provider", + Enabled: true, + BaseUrl: "https://api.example.com/", + } + provider := testutil.Fake(s.T(), faker, database.AIProvider{ID: arg.ID, Name: arg.Name}) + dbm.EXPECT().InsertAIProvider(gomock.Any(), arg).Return(provider, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAIProvider, policy.ActionCreate).Returns(provider) + })) + s.Run("UpdateAIProvider", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.AIProvider{}) + arg := database.UpdateAIProviderParams{ + ID: provider.ID, + Enabled: true, + BaseUrl: "https://api.example.com/", + } + dbm.EXPECT().UpdateAIProvider(gomock.Any(), arg).Return(provider, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAIProvider, policy.ActionUpdate).Returns(provider) + })) + s.Run("DeleteAIProviderByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.AIProvider{}) + dbm.EXPECT().DeleteAIProviderByID(gomock.Any(), provider.ID).Return(nil).AnyTimes() + check.Args(provider.ID).Asserts(rbac.ResourceAIProvider, policy.ActionDelete).Returns() + })) + s.Run("UpdateEncryptedAIProviderSettings", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.AIProvider{}) + arg := database.UpdateEncryptedAIProviderSettingsParams{ + ID: provider.ID, + Settings: sql.NullString{String: "encrypted-settings", Valid: true}, + } + dbm.EXPECT().UpdateEncryptedAIProviderSettings(gomock.Any(), arg).Return(provider, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAIProvider, policy.ActionUpdate).Returns(provider) + })) + s.Run("GetAIProviderKeyByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.AIProviderKey{}) + dbm.EXPECT().GetAIProviderKeyByID(gomock.Any(), key.ID).Return(key, nil).AnyTimes() + check.Args(key.ID).Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns(key) + })) + s.Run("GetAIProviderKeysByProviderID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.AIProvider{}) + keyA := testutil.Fake(s.T(), faker, database.AIProviderKey{ProviderID: provider.ID}) + keyB := testutil.Fake(s.T(), faker, database.AIProviderKey{ProviderID: provider.ID}) + dbm.EXPECT().GetAIProviderKeysByProviderID(gomock.Any(), provider.ID).Return([]database.AIProviderKey{keyA, keyB}, nil).AnyTimes() + check.Args(provider.ID).Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns([]database.AIProviderKey{keyA, keyB}) + })) + s.Run("GetAIProviderKeys", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + keyA := testutil.Fake(s.T(), faker, database.AIProviderKey{}) + keyB := testutil.Fake(s.T(), faker, database.AIProviderKey{}) + dbm.EXPECT().GetAIProviderKeys(gomock.Any()).Return([]database.AIProviderKey{keyA, keyB}, nil).AnyTimes() + check.Args().Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns([]database.AIProviderKey{keyA, keyB}) + })) + s.Run("InsertAIProviderKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + provider := testutil.Fake(s.T(), faker, database.AIProvider{}) + arg := database.InsertAIProviderKeyParams{ + ID: uuid.New(), + ProviderID: provider.ID, + APIKey: "test-key", + } + key := testutil.Fake(s.T(), faker, database.AIProviderKey{ID: arg.ID, ProviderID: arg.ProviderID}) + dbm.EXPECT().InsertAIProviderKey(gomock.Any(), arg).Return(key, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAIProvider, policy.ActionCreate).Returns(key) + })) + s.Run("DeleteAIProviderKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.AIProviderKey{}) + dbm.EXPECT().DeleteAIProviderKey(gomock.Any(), key.ID).Return(nil).AnyTimes() + check.Args(key.ID).Asserts(rbac.ResourceAIProvider, policy.ActionDelete).Returns() + })) + s.Run("UpdateEncryptedAIProviderKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + key := testutil.Fake(s.T(), faker, database.AIProviderKey{}) + arg := database.UpdateEncryptedAIProviderKeyParams{ + ID: key.ID, + APIKey: "encrypted-api-key", + } + dbm.EXPECT().UpdateEncryptedAIProviderKey(gomock.Any(), arg).Return(key, nil).AnyTimes() + check.Args(arg).Asserts(rbac.ResourceAIProvider, policy.ActionUpdate).Returns(key) + })) } func (s *MethodTestSuite) TestTelemetry() { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index c4ae990f93..a132249ba5 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -169,6 +169,62 @@ func ChatModelConfig(t testing.TB, db database.Store, seed database.ChatModelCon return cfg } +func AIProvider(t testing.TB, db database.Store, seed database.AIProvider, munge ...func(*database.InsertAIProviderParams)) database.AIProvider { + t.Helper() + id := seed.ID + if id == uuid.Nil { + id = uuid.New() + } + provType := seed.Type + if provType == "" { + provType = database.AiProviderTypeOpenai + } + name := takeFirst(seed.Name, testutil.GetRandomNameHyphenated(t)) + displayName := seed.DisplayName + if !displayName.Valid { + displayName = sql.NullString{String: name, Valid: true} + } + params := database.InsertAIProviderParams{ + ID: id, + Type: provType, + Name: name, + DisplayName: displayName, + Enabled: takeFirst(seed.Enabled, true), + BaseUrl: takeFirst(seed.BaseUrl, "https://api.example.com/"), + Settings: seed.Settings, + SettingsKeyID: seed.SettingsKeyID, + } + for _, fn := range munge { + fn(¶ms) + } + provider, err := db.InsertAIProvider(genCtx, params) + require.NoError(t, err, "insert ai provider") + return provider +} + +func AIProviderKey(t testing.TB, db database.Store, seed database.AIProviderKey, munge ...func(*database.InsertAIProviderKeyParams)) database.AIProviderKey { + t.Helper() + id := seed.ID + if id == uuid.Nil { + id = uuid.New() + } + now := dbtime.Now() + params := database.InsertAIProviderKeyParams{ + ID: id, + ProviderID: seed.ProviderID, + APIKey: takeFirst(seed.APIKey, "test-key"), + ApiKeyKeyID: seed.ApiKeyKeyID, + CreatedAt: takeFirst(seed.CreatedAt, now), + UpdatedAt: takeFirst(seed.UpdatedAt, now), + } + for _, fn := range munge { + fn(¶ms) + } + key, err := db.InsertAIProviderKey(genCtx, params) + require.NoError(t, err, "insert ai provider key") + return key +} + func ChatProvider(t testing.TB, db database.Store, seed database.ChatProvider, munge ...func(*database.InsertChatProviderParams)) database.ChatProvider { t.Helper() params := database.InsertChatProviderParams{ diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 9bd20957f4..69255ac229 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -377,6 +377,22 @@ func (m queryMetricsStore) CustomRoles(ctx context.Context, arg database.CustomR return r0, r1 } +func (m queryMetricsStore) DeleteAIProviderByID(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteAIProviderByID(ctx, id) + m.queryLatencies.WithLabelValues("DeleteAIProviderByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteAIProviderByID").Inc() + return r0 +} + +func (m queryMetricsStore) DeleteAIProviderKey(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteAIProviderKey(ctx, id) + m.queryLatencies.WithLabelValues("DeleteAIProviderKey").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteAIProviderKey").Inc() + return r0 +} + func (m queryMetricsStore) DeleteAPIKeyByID(ctx context.Context, id string) error { start := time.Now() r0 := m.s.DeleteAPIKeyByID(ctx, id) @@ -985,6 +1001,54 @@ func (m queryMetricsStore) GetAIModelPriceByProviderModel(ctx context.Context, a return r0, r1 } +func (m queryMetricsStore) GetAIProviderByID(ctx context.Context, id uuid.UUID) (database.AIProvider, error) { + start := time.Now() + r0, r1 := m.s.GetAIProviderByID(ctx, id) + m.queryLatencies.WithLabelValues("GetAIProviderByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviderByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetAIProviderByName(ctx context.Context, name string) (database.AIProvider, error) { + start := time.Now() + r0, r1 := m.s.GetAIProviderByName(ctx, name) + m.queryLatencies.WithLabelValues("GetAIProviderByName").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviderByName").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (database.AIProviderKey, error) { + start := time.Now() + r0, r1 := m.s.GetAIProviderKeyByID(ctx, id) + m.queryLatencies.WithLabelValues("GetAIProviderKeyByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviderKeyByID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) { + start := time.Now() + r0, r1 := m.s.GetAIProviderKeys(ctx) + m.queryLatencies.WithLabelValues("GetAIProviderKeys").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviderKeys").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetAIProviderKeysByProviderID(ctx context.Context, providerID uuid.UUID) ([]database.AIProviderKey, error) { + start := time.Now() + r0, r1 := m.s.GetAIProviderKeysByProviderID(ctx, providerID) + m.queryLatencies.WithLabelValues("GetAIProviderKeysByProviderID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviderKeysByProviderID").Inc() + return r0, r1 +} + +func (m queryMetricsStore) GetAIProviders(ctx context.Context, arg database.GetAIProvidersParams) ([]database.AIProvider, error) { + start := time.Now() + r0, r1 := m.s.GetAIProviders(ctx, arg) + m.queryLatencies.WithLabelValues("GetAIProviders").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviders").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { start := time.Now() r0, r1 := m.s.GetAPIKeyByID(ctx, id) @@ -3561,6 +3625,22 @@ func (m queryMetricsStore) InsertAIBridgeUserPrompt(ctx context.Context, arg dat return r0, r1 } +func (m queryMetricsStore) InsertAIProvider(ctx context.Context, arg database.InsertAIProviderParams) (database.AIProvider, error) { + start := time.Now() + r0, r1 := m.s.InsertAIProvider(ctx, arg) + m.queryLatencies.WithLabelValues("InsertAIProvider").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertAIProvider").Inc() + return r0, r1 +} + +func (m queryMetricsStore) InsertAIProviderKey(ctx context.Context, arg database.InsertAIProviderKeyParams) (database.AIProviderKey, error) { + start := time.Now() + r0, r1 := m.s.InsertAIProviderKey(ctx, arg) + m.queryLatencies.WithLabelValues("InsertAIProviderKey").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertAIProviderKey").Inc() + return r0, r1 +} + func (m queryMetricsStore) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { start := time.Now() r0, r1 := m.s.InsertAPIKey(ctx, arg) @@ -4513,6 +4593,14 @@ func (m queryMetricsStore) UpdateAIBridgeInterceptionEnded(ctx context.Context, return r0, r1 } +func (m queryMetricsStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { + start := time.Now() + r0, r1 := m.s.UpdateAIProvider(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateAIProvider").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateAIProvider").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { start := time.Now() r0 := m.s.UpdateAPIKeyByID(ctx, arg) @@ -4697,6 +4785,22 @@ func (m queryMetricsStore) UpdateCustomRole(ctx context.Context, arg database.Up return r0, r1 } +func (m queryMetricsStore) UpdateEncryptedAIProviderKey(ctx context.Context, arg database.UpdateEncryptedAIProviderKeyParams) (database.AIProviderKey, error) { + start := time.Now() + r0, r1 := m.s.UpdateEncryptedAIProviderKey(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateEncryptedAIProviderKey").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateEncryptedAIProviderKey").Inc() + return r0, r1 +} + +func (m queryMetricsStore) UpdateEncryptedAIProviderSettings(ctx context.Context, arg database.UpdateEncryptedAIProviderSettingsParams) (database.AIProvider, error) { + start := time.Now() + r0, r1 := m.s.UpdateEncryptedAIProviderSettings(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateEncryptedAIProviderSettings").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateEncryptedAIProviderSettings").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateExternalAuthLink(ctx context.Context, arg database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) { start := time.Now() r0, r1 := m.s.UpdateExternalAuthLink(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 80fc1741ab..40396a9b5a 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -603,6 +603,34 @@ func (mr *MockStoreMockRecorder) CustomRoles(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRoles", reflect.TypeOf((*MockStore)(nil).CustomRoles), ctx, arg) } +// DeleteAIProviderByID mocks base method. +func (m *MockStore) DeleteAIProviderByID(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAIProviderByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAIProviderByID indicates an expected call of DeleteAIProviderByID. +func (mr *MockStoreMockRecorder) DeleteAIProviderByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAIProviderByID", reflect.TypeOf((*MockStore)(nil).DeleteAIProviderByID), ctx, id) +} + +// DeleteAIProviderKey mocks base method. +func (m *MockStore) DeleteAIProviderKey(ctx context.Context, id uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAIProviderKey", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAIProviderKey indicates an expected call of DeleteAIProviderKey. +func (mr *MockStoreMockRecorder) DeleteAIProviderKey(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAIProviderKey", reflect.TypeOf((*MockStore)(nil).DeleteAIProviderKey), ctx, id) +} + // DeleteAPIKeyByID mocks base method. func (m *MockStore) DeleteAPIKeyByID(ctx context.Context, id string) error { m.ctrl.T.Helper() @@ -1698,6 +1726,96 @@ func (mr *MockStoreMockRecorder) GetAIModelPriceByProviderModel(ctx, arg any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIModelPriceByProviderModel", reflect.TypeOf((*MockStore)(nil).GetAIModelPriceByProviderModel), ctx, arg) } +// GetAIProviderByID mocks base method. +func (m *MockStore) GetAIProviderByID(ctx context.Context, id uuid.UUID) (database.AIProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIProviderByID", ctx, id) + ret0, _ := ret[0].(database.AIProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIProviderByID indicates an expected call of GetAIProviderByID. +func (mr *MockStoreMockRecorder) GetAIProviderByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderByID", reflect.TypeOf((*MockStore)(nil).GetAIProviderByID), ctx, id) +} + +// GetAIProviderByName mocks base method. +func (m *MockStore) GetAIProviderByName(ctx context.Context, name string) (database.AIProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIProviderByName", ctx, name) + ret0, _ := ret[0].(database.AIProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIProviderByName indicates an expected call of GetAIProviderByName. +func (mr *MockStoreMockRecorder) GetAIProviderByName(ctx, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderByName", reflect.TypeOf((*MockStore)(nil).GetAIProviderByName), ctx, name) +} + +// GetAIProviderKeyByID mocks base method. +func (m *MockStore) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (database.AIProviderKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIProviderKeyByID", ctx, id) + ret0, _ := ret[0].(database.AIProviderKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIProviderKeyByID indicates an expected call of GetAIProviderKeyByID. +func (mr *MockStoreMockRecorder) GetAIProviderKeyByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderKeyByID", reflect.TypeOf((*MockStore)(nil).GetAIProviderKeyByID), ctx, id) +} + +// GetAIProviderKeys mocks base method. +func (m *MockStore) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIProviderKeys", ctx) + ret0, _ := ret[0].([]database.AIProviderKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIProviderKeys indicates an expected call of GetAIProviderKeys. +func (mr *MockStoreMockRecorder) GetAIProviderKeys(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderKeys", reflect.TypeOf((*MockStore)(nil).GetAIProviderKeys), ctx) +} + +// GetAIProviderKeysByProviderID mocks base method. +func (m *MockStore) GetAIProviderKeysByProviderID(ctx context.Context, providerID uuid.UUID) ([]database.AIProviderKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIProviderKeysByProviderID", ctx, providerID) + ret0, _ := ret[0].([]database.AIProviderKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIProviderKeysByProviderID indicates an expected call of GetAIProviderKeysByProviderID. +func (mr *MockStoreMockRecorder) GetAIProviderKeysByProviderID(ctx, providerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderKeysByProviderID", reflect.TypeOf((*MockStore)(nil).GetAIProviderKeysByProviderID), ctx, providerID) +} + +// GetAIProviders mocks base method. +func (m *MockStore) GetAIProviders(ctx context.Context, arg database.GetAIProvidersParams) ([]database.AIProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIProviders", ctx, arg) + ret0, _ := ret[0].([]database.AIProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIProviders indicates an expected call of GetAIProviders. +func (mr *MockStoreMockRecorder) GetAIProviders(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviders", reflect.TypeOf((*MockStore)(nil).GetAIProviders), ctx, arg) +} + // GetAPIKeyByID mocks base method. func (m *MockStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) { m.ctrl.T.Helper() @@ -6677,6 +6795,36 @@ func (mr *MockStoreMockRecorder) InsertAIBridgeUserPrompt(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAIBridgeUserPrompt", reflect.TypeOf((*MockStore)(nil).InsertAIBridgeUserPrompt), ctx, arg) } +// InsertAIProvider mocks base method. +func (m *MockStore) InsertAIProvider(ctx context.Context, arg database.InsertAIProviderParams) (database.AIProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertAIProvider", ctx, arg) + ret0, _ := ret[0].(database.AIProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertAIProvider indicates an expected call of InsertAIProvider. +func (mr *MockStoreMockRecorder) InsertAIProvider(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAIProvider", reflect.TypeOf((*MockStore)(nil).InsertAIProvider), ctx, arg) +} + +// InsertAIProviderKey mocks base method. +func (m *MockStore) InsertAIProviderKey(ctx context.Context, arg database.InsertAIProviderKeyParams) (database.AIProviderKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertAIProviderKey", ctx, arg) + ret0, _ := ret[0].(database.AIProviderKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertAIProviderKey indicates an expected call of InsertAIProviderKey. +func (mr *MockStoreMockRecorder) InsertAIProviderKey(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAIProviderKey", reflect.TypeOf((*MockStore)(nil).InsertAIProviderKey), ctx, arg) +} + // InsertAPIKey mocks base method. func (m *MockStore) InsertAPIKey(ctx context.Context, arg database.InsertAPIKeyParams) (database.APIKey, error) { m.ctrl.T.Helper() @@ -8539,6 +8687,21 @@ func (mr *MockStoreMockRecorder) UpdateAIBridgeInterceptionEnded(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIBridgeInterceptionEnded", reflect.TypeOf((*MockStore)(nil).UpdateAIBridgeInterceptionEnded), ctx, arg) } +// UpdateAIProvider mocks base method. +func (m *MockStore) UpdateAIProvider(ctx context.Context, arg database.UpdateAIProviderParams) (database.AIProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAIProvider", ctx, arg) + ret0, _ := ret[0].(database.AIProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAIProvider indicates an expected call of UpdateAIProvider. +func (mr *MockStoreMockRecorder) UpdateAIProvider(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAIProvider", reflect.TypeOf((*MockStore)(nil).UpdateAIProvider), ctx, arg) +} + // UpdateAPIKeyByID mocks base method. func (m *MockStore) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error { m.ctrl.T.Helper() @@ -8881,6 +9044,36 @@ func (mr *MockStoreMockRecorder) UpdateCustomRole(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCustomRole", reflect.TypeOf((*MockStore)(nil).UpdateCustomRole), ctx, arg) } +// UpdateEncryptedAIProviderKey mocks base method. +func (m *MockStore) UpdateEncryptedAIProviderKey(ctx context.Context, arg database.UpdateEncryptedAIProviderKeyParams) (database.AIProviderKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateEncryptedAIProviderKey", ctx, arg) + ret0, _ := ret[0].(database.AIProviderKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateEncryptedAIProviderKey indicates an expected call of UpdateEncryptedAIProviderKey. +func (mr *MockStoreMockRecorder) UpdateEncryptedAIProviderKey(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEncryptedAIProviderKey", reflect.TypeOf((*MockStore)(nil).UpdateEncryptedAIProviderKey), ctx, arg) +} + +// UpdateEncryptedAIProviderSettings mocks base method. +func (m *MockStore) UpdateEncryptedAIProviderSettings(ctx context.Context, arg database.UpdateEncryptedAIProviderSettingsParams) (database.AIProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateEncryptedAIProviderSettings", ctx, arg) + ret0, _ := ret[0].(database.AIProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateEncryptedAIProviderSettings indicates an expected call of UpdateEncryptedAIProviderSettings. +func (mr *MockStoreMockRecorder) UpdateEncryptedAIProviderSettings(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEncryptedAIProviderSettings", reflect.TypeOf((*MockStore)(nil).UpdateEncryptedAIProviderSettings), ctx, arg) +} + // UpdateExternalAuthLink mocks base method. func (m *MockStore) UpdateExternalAuthLink(ctx context.Context, arg database.UpdateExternalAuthLinkParams) (database.ExternalAuthLink, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c19263b918..e5968cb437 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -10,6 +10,11 @@ CREATE TYPE agent_key_scope_enum AS ENUM ( 'no_user_data' ); +CREATE TYPE ai_provider_type AS ENUM ( + 'openai', + 'anthropic' +); + CREATE TYPE ai_seat_usage_reason AS ENUM ( 'aibridge', 'task' @@ -226,7 +231,12 @@ CREATE TYPE api_key_scope AS ENUM ( 'ai_seat:read', 'ai_model_price:*', 'ai_model_price:read', - 'ai_model_price:update' + 'ai_model_price:update', + 'ai_provider:*', + 'ai_provider:create', + 'ai_provider:delete', + 'ai_provider:read', + 'ai_provider:update' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -533,7 +543,9 @@ CREATE TYPE resource_type AS ENUM ( 'task', 'ai_seat', 'chat', - 'user_secret' + 'user_secret', + 'ai_provider', + 'ai_provider_key' ); CREATE TYPE shareable_workspace_owners AS ENUM ( @@ -1108,6 +1120,46 @@ CREATE TABLE ai_model_prices ( COMMENT ON TABLE ai_model_prices IS 'Per-model token prices used by AI Bridge to compute interception cost.'; +CREATE TABLE ai_provider_keys ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + provider_id uuid NOT NULL, + api_key text NOT NULL, + api_key_key_id text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + +COMMENT ON TABLE ai_provider_keys IS 'API keys associated with AI providers. Bedrock providers have zero keys (they authenticate via settings). OpenAI and Anthropic providers have one or more keys for failover.'; + +COMMENT ON COLUMN ai_provider_keys.api_key IS 'API key used to authenticate with the upstream AI provider. Encrypted at rest via dbcrypt when api_key_key_id is set.'; + +COMMENT ON COLUMN ai_provider_keys.api_key_key_id IS 'The ID of the key used to encrypt the provider API key. If this is NULL, the API key is not encrypted.'; + +CREATE TABLE ai_providers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + type ai_provider_type NOT NULL, + name text NOT NULL, + display_name text, + enabled boolean DEFAULT true NOT NULL, + deleted boolean DEFAULT false NOT NULL, + base_url text NOT NULL, + settings text, + settings_key_id text, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT ai_providers_name_check CHECK ((name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'::text)) +); + +COMMENT ON TABLE ai_providers IS 'Runtime configuration for AI providers. Authoritative source for the provider set served by aibridged. Replaces deployment-time CODER_AIBRIDGE_* environment variables.'; + +COMMENT ON COLUMN ai_providers.display_name IS 'Optional human-readable label. When NULL, callers should fall back to name.'; + +COMMENT ON COLUMN ai_providers.deleted IS 'Soft delete flag. Soft-deleted rows are preserved for audit and FK history but do not block name reuse by future live rows.'; + +COMMENT ON COLUMN ai_providers.settings IS 'Encrypted JSON blob holding type-specific configuration (e.g. AWS Bedrock region, model, access key secret). Plaintext is a JSON object. NULL when no type-specific settings are required.'; + +COMMENT ON COLUMN ai_providers.settings_key_id IS 'The ID of the key used to encrypt settings. If this is NULL, settings is not encrypted.'; + CREATE TABLE ai_seat_state ( user_id uuid NOT NULL, first_used_at timestamp with time zone NOT NULL, @@ -3409,6 +3461,12 @@ ALTER TABLE ONLY workspace_agent_stats ALTER TABLE ONLY ai_model_prices ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model); +ALTER TABLE ONLY ai_provider_keys + ADD CONSTRAINT ai_provider_keys_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY ai_providers + ADD CONSTRAINT ai_providers_pkey PRIMARY KEY (id); + ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id); @@ -3775,6 +3833,8 @@ ALTER TABLE ONLY workspace_resources ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); +CREATE UNIQUE INDEX ai_providers_name_unique ON ai_providers USING btree (name) WHERE (deleted = false); + CREATE INDEX api_keys_last_used_idx ON api_keys USING btree (last_used DESC); COMMENT ON INDEX api_keys_last_used_idx IS 'Index for optimizing api_keys queries filtering by last_used'; @@ -3783,6 +3843,10 @@ CREATE INDEX idx_agent_stats_created_at ON workspace_agent_stats USING btree (cr CREATE INDEX idx_agent_stats_user_id ON workspace_agent_stats USING btree (user_id); +CREATE INDEX idx_ai_provider_keys_provider_id ON ai_provider_keys USING btree (provider_id); + +CREATE INDEX idx_ai_providers_enabled ON ai_providers USING btree (enabled) WHERE (deleted = false); + CREATE INDEX idx_aibridge_interceptions_client ON aibridge_interceptions USING btree (client); CREATE INDEX idx_aibridge_interceptions_client_session_id ON aibridge_interceptions USING btree (client_session_id) WHERE (client_session_id IS NOT NULL); @@ -4149,6 +4213,15 @@ COMMENT ON TRIGGER workspace_agent_name_unique_trigger ON workspace_agents IS 'U the uniqueness requirement. A trigger allows us to enforce uniqueness going forward without requiring a migration to clean up historical data.'; +ALTER TABLE ONLY ai_provider_keys + ADD CONSTRAINT ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); + +ALTER TABLE ONLY ai_provider_keys + ADD CONSTRAINT ai_provider_keys_provider_id_fkey FOREIGN KEY (provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY ai_providers + ADD CONSTRAINT ai_providers_settings_key_id_fkey FOREIGN KEY (settings_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 0d02665c0d..78acd9b3f1 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -6,6 +6,9 @@ type ForeignKeyConstraint string // ForeignKeyConstraint enums. const ( + ForeignKeyAiProviderKeysAPIKeyKeyID ForeignKeyConstraint = "ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); + ForeignKeyAiProviderKeysProviderID ForeignKeyConstraint = "ai_provider_keys_provider_id_fkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_provider_id_fkey FOREIGN KEY (provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; + ForeignKeyAiProvidersSettingsKeyID ForeignKeyConstraint = "ai_providers_settings_key_id_fkey" // ALTER TABLE ONLY ai_providers ADD CONSTRAINT ai_providers_settings_key_id_fkey FOREIGN KEY (settings_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyAiSeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id); ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000495_ai_providers.down.sql b/coderd/database/migrations/000495_ai_providers.down.sql new file mode 100644 index 0000000000..98dc548625 --- /dev/null +++ b/coderd/database/migrations/000495_ai_providers.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS ai_provider_keys; +DROP TABLE IF EXISTS ai_providers; +DROP TYPE IF EXISTS ai_provider_type; +-- No-op for ALTER TYPE resource_type / api_key_scope ADD VALUE: +-- Postgres does not allow removing enum values safely. diff --git a/coderd/database/migrations/000495_ai_providers.up.sql b/coderd/database/migrations/000495_ai_providers.up.sql new file mode 100644 index 0000000000..d6de725ed0 --- /dev/null +++ b/coderd/database/migrations/000495_ai_providers.up.sql @@ -0,0 +1,67 @@ +CREATE TYPE ai_provider_type AS ENUM ( + 'openai', + 'anthropic' +); + +CREATE TABLE ai_providers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + type ai_provider_type NOT NULL, + name text NOT NULL + CONSTRAINT ai_providers_name_check + CHECK (name ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + display_name text, + enabled boolean NOT NULL DEFAULT TRUE, + deleted boolean NOT NULL DEFAULT FALSE, + base_url text NOT NULL, + settings text, + settings_key_id text REFERENCES dbcrypt_keys(active_key_digest), + created_at timestamp with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp with time zone NOT NULL DEFAULT NOW() +); + +-- Provider names are unique among live rows only. Soft-deleted rows +-- are retained for audit and FK history but do not reserve names. +CREATE UNIQUE INDEX ai_providers_name_unique + ON ai_providers (name) + WHERE deleted = FALSE; + +COMMENT ON TABLE ai_providers IS 'Runtime configuration for AI providers. Authoritative source for the provider set served by aibridged. Replaces deployment-time CODER_AIBRIDGE_* environment variables.'; + +COMMENT ON COLUMN ai_providers.settings IS 'Encrypted JSON blob holding type-specific configuration (e.g. AWS Bedrock region, model, access key secret). Plaintext is a JSON object. NULL when no type-specific settings are required.'; + +COMMENT ON COLUMN ai_providers.settings_key_id IS 'The ID of the key used to encrypt settings. If this is NULL, settings is not encrypted.'; + +COMMENT ON COLUMN ai_providers.deleted IS 'Soft delete flag. Soft-deleted rows are preserved for audit and FK history but do not block name reuse by future live rows.'; + +COMMENT ON COLUMN ai_providers.display_name IS 'Optional human-readable label. When NULL, callers should fall back to name.'; + +CREATE INDEX idx_ai_providers_enabled ON ai_providers (enabled) WHERE deleted = FALSE; + +CREATE TABLE ai_provider_keys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + provider_id uuid NOT NULL REFERENCES ai_providers(id) ON DELETE CASCADE, + api_key text NOT NULL, + api_key_key_id text REFERENCES dbcrypt_keys(active_key_digest), + created_at timestamp with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp with time zone NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE ai_provider_keys IS 'API keys associated with AI providers. Bedrock providers have zero keys (they authenticate via settings). OpenAI and Anthropic providers have one or more keys for failover.'; + +COMMENT ON COLUMN ai_provider_keys.api_key IS 'API key used to authenticate with the upstream AI provider. Encrypted at rest via dbcrypt when api_key_key_id is set.'; + +COMMENT ON COLUMN ai_provider_keys.api_key_key_id IS 'The ID of the key used to encrypt the provider API key. If this is NULL, the API key is not encrypted.'; + +CREATE INDEX idx_ai_provider_keys_provider_id ON ai_provider_keys (provider_id); + +-- Audit support: allow ai_providers and ai_provider_keys to appear in +-- audit_log.resource_type. +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'ai_provider'; +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'ai_provider_key'; + +-- API key scopes for ai_provider resources. +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_provider:*'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_provider:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_provider:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_provider:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'ai_provider:update'; diff --git a/coderd/database/migrations/testdata/fixtures/000495_ai_providers.up.sql b/coderd/database/migrations/testdata/fixtures/000495_ai_providers.up.sql new file mode 100644 index 0000000000..59df6d793e --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000495_ai_providers.up.sql @@ -0,0 +1,56 @@ +INSERT INTO ai_providers ( + id, + type, + name, + display_name, + enabled, + deleted, + base_url, + settings +) VALUES + ( + '8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1a01', + 'openai', + 'openai', + 'OpenAI (Fixture)', + TRUE, + FALSE, + 'https://api.openai.com/v1/', + '' + ), + ( + '8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1a02', + 'anthropic', + 'anthropic-bedrock', + 'Anthropic via Bedrock (Fixture)', + TRUE, + FALSE, + 'https://bedrock-runtime.us-west-2.amazonaws.com/', + '{"bedrock_region":"us-west-2","bedrock_model":"global.anthropic.claude-sonnet-4-5-20250929-v1:0","bedrock_access_key":"fixture-bedrock-access-key","bedrock_access_key_secret":"fixture-bedrock-access-key-secret"}' + ), + ( + '8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1a03', + 'openai', + 'openai-deleted', + 'OpenAI (Deleted Fixture)', + FALSE, + TRUE, + 'https://api.openai.com/v1/', + '' + ); + +INSERT INTO ai_provider_keys ( + id, + provider_id, + api_key +) VALUES + ( + '8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1b01', + '8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1a01', + 'fixture-openai-key' + ), + ( + '8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1b02', + '8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1a01', + 'fixture-openai-key-failover' + ); diff --git a/coderd/database/models.go b/coderd/database/models.go index 2c05309c87..bf3e2233df 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -16,6 +16,64 @@ import ( "github.com/sqlc-dev/pqtype" ) +type AIProviderType string + +const ( + AiProviderTypeOpenai AIProviderType = "openai" + AiProviderTypeAnthropic AIProviderType = "anthropic" +) + +func (e *AIProviderType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = AIProviderType(s) + case string: + *e = AIProviderType(s) + default: + return fmt.Errorf("unsupported scan type for AIProviderType: %T", src) + } + return nil +} + +type NullAIProviderType struct { + AIProviderType AIProviderType `json:"ai_provider_type"` + Valid bool `json:"valid"` // Valid is true if AIProviderType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullAIProviderType) Scan(value interface{}) error { + if value == nil { + ns.AIProviderType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.AIProviderType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullAIProviderType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.AIProviderType), nil +} + +func (e AIProviderType) Valid() bool { + switch e { + case AiProviderTypeOpenai, + AiProviderTypeAnthropic: + return true + } + return false +} + +func AllAIProviderTypeValues() []AIProviderType { + return []AIProviderType{ + AiProviderTypeOpenai, + AiProviderTypeAnthropic, + } +} + type APIKeyScope string const ( @@ -230,6 +288,11 @@ const ( ApiKeyScopeAiModelPrice APIKeyScope = "ai_model_price:*" ApiKeyScopeAiModelPriceRead APIKeyScope = "ai_model_price:read" ApiKeyScopeAiModelPriceUpdate APIKeyScope = "ai_model_price:update" + ApiKeyScopeAiProvider APIKeyScope = "ai_provider:*" + ApiKeyScopeAiProviderCreate APIKeyScope = "ai_provider:create" + ApiKeyScopeAiProviderDelete APIKeyScope = "ai_provider:delete" + ApiKeyScopeAiProviderRead APIKeyScope = "ai_provider:read" + ApiKeyScopeAiProviderUpdate APIKeyScope = "ai_provider:update" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -479,7 +542,12 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeAiSeatRead, ApiKeyScopeAiModelPrice, ApiKeyScopeAiModelPriceRead, - ApiKeyScopeAiModelPriceUpdate: + ApiKeyScopeAiModelPriceUpdate, + ApiKeyScopeAiProvider, + ApiKeyScopeAiProviderCreate, + ApiKeyScopeAiProviderDelete, + ApiKeyScopeAiProviderRead, + ApiKeyScopeAiProviderUpdate: return true } return false @@ -698,6 +766,11 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeAiModelPrice, ApiKeyScopeAiModelPriceRead, ApiKeyScopeAiModelPriceUpdate, + ApiKeyScopeAiProvider, + ApiKeyScopeAiProviderCreate, + ApiKeyScopeAiProviderDelete, + ApiKeyScopeAiProviderRead, + ApiKeyScopeAiProviderUpdate, } } @@ -3225,6 +3298,8 @@ const ( ResourceTypeAiSeat ResourceType = "ai_seat" ResourceTypeChat ResourceType = "chat" ResourceTypeUserSecret ResourceType = "user_secret" + ResourceTypeAiProvider ResourceType = "ai_provider" + ResourceTypeAiProviderKey ResourceType = "ai_provider_key" ) func (e *ResourceType) Scan(src interface{}) error { @@ -3292,7 +3367,9 @@ func (e ResourceType) Valid() bool { ResourceTypeTask, ResourceTypeAiSeat, ResourceTypeChat, - ResourceTypeUserSecret: + ResourceTypeUserSecret, + ResourceTypeAiProvider, + ResourceTypeAiProviderKey: return true } return false @@ -3329,6 +3406,8 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeAiSeat, ResourceTypeChat, ResourceTypeUserSecret, + ResourceTypeAiProvider, + ResourceTypeAiProviderKey, } } @@ -4299,6 +4378,37 @@ type AIBridgeUserPrompt struct { CreatedAt time.Time `db:"created_at" json:"created_at"` } +// Runtime configuration for AI providers. Authoritative source for the provider set served by aibridged. Replaces deployment-time CODER_AIBRIDGE_* environment variables. +type AIProvider struct { + ID uuid.UUID `db:"id" json:"id"` + Type AIProviderType `db:"type" json:"type"` + Name string `db:"name" json:"name"` + // Optional human-readable label. When NULL, callers should fall back to name. + DisplayName sql.NullString `db:"display_name" json:"display_name"` + Enabled bool `db:"enabled" json:"enabled"` + // Soft delete flag. Soft-deleted rows are preserved for audit and FK history but do not block name reuse by future live rows. + Deleted bool `db:"deleted" json:"deleted"` + BaseUrl string `db:"base_url" json:"base_url"` + // Encrypted JSON blob holding type-specific configuration (e.g. AWS Bedrock region, model, access key secret). Plaintext is a JSON object. NULL when no type-specific settings are required. + Settings sql.NullString `db:"settings" json:"settings"` + // The ID of the key used to encrypt settings. If this is NULL, settings is not encrypted. + SettingsKeyID sql.NullString `db:"settings_key_id" json:"settings_key_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// API keys associated with AI providers. Bedrock providers have zero keys (they authenticate via settings). OpenAI and Anthropic providers have one or more keys for failover. +type AIProviderKey struct { + ID uuid.UUID `db:"id" json:"id"` + ProviderID uuid.UUID `db:"provider_id" json:"provider_id"` + // API key used to authenticate with the upstream AI provider. Encrypted at rest via dbcrypt when api_key_key_id is set. + APIKey string `db:"api_key" json:"api_key"` + // The ID of the key used to encrypt the provider API key. If this is NULL, the API key is not encrypted. + ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + type APIKey struct { ID string `db:"id" json:"id"` // hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 6bb711b1a5..e5d1ade48b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -99,6 +99,8 @@ type sqlcQuerier interface { CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error) CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) + DeleteAIProviderByID(ctx context.Context, id uuid.UUID) error + DeleteAIProviderKey(ctx context.Context, id uuid.UUID) error DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteAllChatQueuedMessages(ctx context.Context, chatID uuid.UUID) error @@ -246,6 +248,21 @@ type sqlcQuerier interface { GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, error) GetAIModelPriceByProviderModel(ctx context.Context, arg GetAIModelPriceByProviderModelParams) (AiModelPrice, error) + GetAIProviderByID(ctx context.Context, id uuid.UUID) (AIProvider, error) + GetAIProviderByName(ctx context.Context, name string) (AIProvider, error) + GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (AIProviderKey, error) + // Returns every AI provider key row, including those belonging to a + // soft-deleted provider, so the dbcrypt key rotation utility can + // re-encrypt their api_key and clear references to retired keys. + GetAIProviderKeys(ctx context.Context) ([]AIProviderKey, error) + // Returns all keys for a provider, ordered by created_at ASC so the + // oldest key is returned first. AI Bridge currently uses the oldest + // key per provider; multiple keys are stored to support future + // failover and rotation flows. + GetAIProviderKeysByProviderID(ctx context.Context, providerID uuid.UUID) ([]AIProviderKey, error) + // Returns AI provider rows. Soft-deleted and disabled rows are excluded + // unless include_deleted or include_disabled is set. + GetAIProviders(ctx context.Context, arg GetAIProvidersParams) ([]AIProvider, error) GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) // there is no unique constraint on empty token names GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error) @@ -866,6 +883,8 @@ type sqlcQuerier interface { InsertAIBridgeTokenUsage(ctx context.Context, arg InsertAIBridgeTokenUsageParams) (AIBridgeTokenUsage, error) InsertAIBridgeToolUsage(ctx context.Context, arg InsertAIBridgeToolUsageParams) (AIBridgeToolUsage, error) InsertAIBridgeUserPrompt(ctx context.Context, arg InsertAIBridgeUserPromptParams) (AIBridgeUserPrompt, error) + InsertAIProvider(ctx context.Context, arg InsertAIProviderParams) (AIProvider, error) + InsertAIProviderKey(ctx context.Context, arg InsertAIProviderKeyParams) (AIProviderKey, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) // We use the organization_id as the id // for simplicity since all users is @@ -1097,6 +1116,7 @@ type sqlcQuerier interface { UnpinChatByID(ctx context.Context, id uuid.UUID) error UnsetDefaultChatModelConfigs(ctx context.Context) error UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) + UpdateAIProvider(ctx context.Context, arg UpdateAIProviderParams) (AIProvider, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateChatBuildAgentBinding(ctx context.Context, arg UpdateChatBuildAgentBindingParams) (Chat, error) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error) @@ -1159,6 +1179,15 @@ type sqlcQuerier interface { UpdateChatWorkspaceBinding(ctx context.Context, arg UpdateChatWorkspaceBindingParams) (Chat, error) UpdateCryptoKeyDeletesAt(ctx context.Context, arg UpdateCryptoKeyDeletesAtParams) (CryptoKey, error) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error) + // Updates only the encrypted columns (api_key, api_key_key_id) and + // the updated_at timestamp on a row. Used by the dbcrypt key + // rotation utility to re-encrypt or decrypt rows in place. + UpdateEncryptedAIProviderKey(ctx context.Context, arg UpdateEncryptedAIProviderKeyParams) (AIProviderKey, error) + // Updates only the encrypted columns (settings, settings_key_id) and + // the updated_at timestamp on a row, regardless of its deleted flag. + // Used by the dbcrypt key rotation utility to re-encrypt or decrypt + // rows in place. + UpdateEncryptedAIProviderSettings(ctx context.Context, arg UpdateEncryptedAIProviderSettingsParams) (AIProvider, error) UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error) // Optimistic lock: only update the row if the refresh token in the database // still matches the one we read before attempting the refresh. This prevents diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 828db17cbe..dd5a186970 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -111,6 +111,499 @@ func (q *sqlQuerier) ActivityBumpWorkspace(ctx context.Context, arg ActivityBump return err } +const deleteAIProviderKey = `-- name: DeleteAIProviderKey :exec +DELETE FROM + ai_provider_keys +WHERE + id = $1::uuid +` + +func (q *sqlQuerier) DeleteAIProviderKey(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteAIProviderKey, id) + return err +} + +const getAIProviderKeyByID = `-- name: GetAIProviderKeyByID :one +SELECT + id, provider_id, api_key, api_key_key_id, created_at, updated_at +FROM + ai_provider_keys +WHERE + id = $1::uuid +` + +func (q *sqlQuerier) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (AIProviderKey, error) { + row := q.db.QueryRowContext(ctx, getAIProviderKeyByID, id) + var i AIProviderKey + err := row.Scan( + &i.ID, + &i.ProviderID, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAIProviderKeys = `-- name: GetAIProviderKeys :many +SELECT + id, provider_id, api_key, api_key_key_id, created_at, updated_at +FROM + ai_provider_keys +ORDER BY + provider_id ASC, + created_at ASC, + id ASC +` + +// Returns every AI provider key row, including those belonging to a +// soft-deleted provider, so the dbcrypt key rotation utility can +// re-encrypt their api_key and clear references to retired keys. +func (q *sqlQuerier) GetAIProviderKeys(ctx context.Context) ([]AIProviderKey, error) { + rows, err := q.db.QueryContext(ctx, getAIProviderKeys) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AIProviderKey + for rows.Next() { + var i AIProviderKey + if err := rows.Scan( + &i.ID, + &i.ProviderID, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ); 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 getAIProviderKeysByProviderID = `-- name: GetAIProviderKeysByProviderID :many +SELECT + id, provider_id, api_key, api_key_key_id, created_at, updated_at +FROM + ai_provider_keys +WHERE + provider_id = $1::uuid +ORDER BY + created_at ASC, + id ASC +` + +// Returns all keys for a provider, ordered by created_at ASC so the +// oldest key is returned first. AI Bridge currently uses the oldest +// key per provider; multiple keys are stored to support future +// failover and rotation flows. +func (q *sqlQuerier) GetAIProviderKeysByProviderID(ctx context.Context, providerID uuid.UUID) ([]AIProviderKey, error) { + rows, err := q.db.QueryContext(ctx, getAIProviderKeysByProviderID, providerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AIProviderKey + for rows.Next() { + var i AIProviderKey + if err := rows.Scan( + &i.ID, + &i.ProviderID, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ); 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 insertAIProviderKey = `-- name: InsertAIProviderKey :one +INSERT INTO ai_provider_keys ( + id, + provider_id, + api_key, + api_key_key_id, + created_at, + updated_at +) VALUES ( + $1::uuid, + $2::uuid, + $3::text, + $4::text, + $5::timestamptz, + $6::timestamptz +) +RETURNING + id, provider_id, api_key, api_key_key_id, created_at, updated_at +` + +type InsertAIProviderKeyParams struct { + ID uuid.UUID `db:"id" json:"id"` + ProviderID uuid.UUID `db:"provider_id" json:"provider_id"` + APIKey string `db:"api_key" json:"api_key"` + ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) InsertAIProviderKey(ctx context.Context, arg InsertAIProviderKeyParams) (AIProviderKey, error) { + row := q.db.QueryRowContext(ctx, insertAIProviderKey, + arg.ID, + arg.ProviderID, + arg.APIKey, + arg.ApiKeyKeyID, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i AIProviderKey + err := row.Scan( + &i.ID, + &i.ProviderID, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateEncryptedAIProviderKey = `-- name: UpdateEncryptedAIProviderKey :one +UPDATE + ai_provider_keys +SET + api_key = $1::text, + api_key_key_id = $2::text, + updated_at = NOW() +WHERE + id = $3::uuid +RETURNING + id, provider_id, api_key, api_key_key_id, created_at, updated_at +` + +type UpdateEncryptedAIProviderKeyParams struct { + APIKey string `db:"api_key" json:"api_key"` + ApiKeyKeyID sql.NullString `db:"api_key_key_id" json:"api_key_key_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +// Updates only the encrypted columns (api_key, api_key_key_id) and +// the updated_at timestamp on a row. Used by the dbcrypt key +// rotation utility to re-encrypt or decrypt rows in place. +func (q *sqlQuerier) UpdateEncryptedAIProviderKey(ctx context.Context, arg UpdateEncryptedAIProviderKeyParams) (AIProviderKey, error) { + row := q.db.QueryRowContext(ctx, updateEncryptedAIProviderKey, arg.APIKey, arg.ApiKeyKeyID, arg.ID) + var i AIProviderKey + err := row.Scan( + &i.ID, + &i.ProviderID, + &i.APIKey, + &i.ApiKeyKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAIProviderByID = `-- name: DeleteAIProviderByID :exec +UPDATE + ai_providers +SET + deleted = TRUE, + enabled = FALSE, + updated_at = NOW() +WHERE + id = $1::uuid AND deleted = FALSE +` + +func (q *sqlQuerier) DeleteAIProviderByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteAIProviderByID, id) + return err +} + +const getAIProviderByID = `-- name: GetAIProviderByID :one +SELECT + id, type, name, display_name, enabled, deleted, base_url, settings, settings_key_id, created_at, updated_at +FROM + ai_providers +WHERE + id = $1::uuid AND deleted = FALSE +` + +func (q *sqlQuerier) GetAIProviderByID(ctx context.Context, id uuid.UUID) (AIProvider, error) { + row := q.db.QueryRowContext(ctx, getAIProviderByID, id) + var i AIProvider + err := row.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.DisplayName, + &i.Enabled, + &i.Deleted, + &i.BaseUrl, + &i.Settings, + &i.SettingsKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAIProviderByName = `-- name: GetAIProviderByName :one +SELECT + id, type, name, display_name, enabled, deleted, base_url, settings, settings_key_id, created_at, updated_at +FROM + ai_providers +WHERE + name = $1::text AND deleted = FALSE +` + +func (q *sqlQuerier) GetAIProviderByName(ctx context.Context, name string) (AIProvider, error) { + row := q.db.QueryRowContext(ctx, getAIProviderByName, name) + var i AIProvider + err := row.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.DisplayName, + &i.Enabled, + &i.Deleted, + &i.BaseUrl, + &i.Settings, + &i.SettingsKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAIProviders = `-- name: GetAIProviders :many +SELECT + id, type, name, display_name, enabled, deleted, base_url, settings, settings_key_id, created_at, updated_at +FROM + ai_providers +WHERE + ($1::boolean OR NOT deleted) + AND ($2::boolean OR enabled) +ORDER BY + name ASC +` + +type GetAIProvidersParams struct { + IncludeDeleted bool `db:"include_deleted" json:"include_deleted"` + IncludeDisabled bool `db:"include_disabled" json:"include_disabled"` +} + +// Returns AI provider rows. Soft-deleted and disabled rows are excluded +// unless include_deleted or include_disabled is set. +func (q *sqlQuerier) GetAIProviders(ctx context.Context, arg GetAIProvidersParams) ([]AIProvider, error) { + rows, err := q.db.QueryContext(ctx, getAIProviders, arg.IncludeDeleted, arg.IncludeDisabled) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AIProvider + for rows.Next() { + var i AIProvider + if err := rows.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.DisplayName, + &i.Enabled, + &i.Deleted, + &i.BaseUrl, + &i.Settings, + &i.SettingsKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ); 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 insertAIProvider = `-- name: InsertAIProvider :one +INSERT INTO ai_providers ( + id, + type, + name, + display_name, + enabled, + base_url, + settings, + settings_key_id +) VALUES ( + $1::uuid, + $2::ai_provider_type, + $3::text, + $4::text, + $5::boolean, + $6::text, + $7::text, + $8::text +) +RETURNING + id, type, name, display_name, enabled, deleted, base_url, settings, settings_key_id, created_at, updated_at +` + +type InsertAIProviderParams struct { + ID uuid.UUID `db:"id" json:"id"` + Type AIProviderType `db:"type" json:"type"` + Name string `db:"name" json:"name"` + DisplayName sql.NullString `db:"display_name" json:"display_name"` + Enabled bool `db:"enabled" json:"enabled"` + BaseUrl string `db:"base_url" json:"base_url"` + Settings sql.NullString `db:"settings" json:"settings"` + SettingsKeyID sql.NullString `db:"settings_key_id" json:"settings_key_id"` +} + +func (q *sqlQuerier) InsertAIProvider(ctx context.Context, arg InsertAIProviderParams) (AIProvider, error) { + row := q.db.QueryRowContext(ctx, insertAIProvider, + arg.ID, + arg.Type, + arg.Name, + arg.DisplayName, + arg.Enabled, + arg.BaseUrl, + arg.Settings, + arg.SettingsKeyID, + ) + var i AIProvider + err := row.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.DisplayName, + &i.Enabled, + &i.Deleted, + &i.BaseUrl, + &i.Settings, + &i.SettingsKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateAIProvider = `-- name: UpdateAIProvider :one +UPDATE + ai_providers +SET + display_name = $1::text, + enabled = $2::boolean, + base_url = $3::text, + settings = $4::text, + settings_key_id = $5::text, + updated_at = NOW() +WHERE + id = $6::uuid AND deleted = FALSE +RETURNING + id, type, name, display_name, enabled, deleted, base_url, settings, settings_key_id, created_at, updated_at +` + +type UpdateAIProviderParams struct { + DisplayName sql.NullString `db:"display_name" json:"display_name"` + Enabled bool `db:"enabled" json:"enabled"` + BaseUrl string `db:"base_url" json:"base_url"` + Settings sql.NullString `db:"settings" json:"settings"` + SettingsKeyID sql.NullString `db:"settings_key_id" json:"settings_key_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateAIProvider(ctx context.Context, arg UpdateAIProviderParams) (AIProvider, error) { + row := q.db.QueryRowContext(ctx, updateAIProvider, + arg.DisplayName, + arg.Enabled, + arg.BaseUrl, + arg.Settings, + arg.SettingsKeyID, + arg.ID, + ) + var i AIProvider + err := row.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.DisplayName, + &i.Enabled, + &i.Deleted, + &i.BaseUrl, + &i.Settings, + &i.SettingsKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateEncryptedAIProviderSettings = `-- name: UpdateEncryptedAIProviderSettings :one +UPDATE + ai_providers +SET + settings = $1::text, + settings_key_id = $2::text, + updated_at = NOW() +WHERE + id = $3::uuid +RETURNING + id, type, name, display_name, enabled, deleted, base_url, settings, settings_key_id, created_at, updated_at +` + +type UpdateEncryptedAIProviderSettingsParams struct { + Settings sql.NullString `db:"settings" json:"settings"` + SettingsKeyID sql.NullString `db:"settings_key_id" json:"settings_key_id"` + ID uuid.UUID `db:"id" json:"id"` +} + +// Updates only the encrypted columns (settings, settings_key_id) and +// the updated_at timestamp on a row, regardless of its deleted flag. +// Used by the dbcrypt key rotation utility to re-encrypt or decrypt +// rows in place. +func (q *sqlQuerier) UpdateEncryptedAIProviderSettings(ctx context.Context, arg UpdateEncryptedAIProviderSettingsParams) (AIProvider, error) { + row := q.db.QueryRowContext(ctx, updateEncryptedAIProviderSettings, arg.Settings, arg.SettingsKeyID, arg.ID) + var i AIProvider + err := row.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.DisplayName, + &i.Enabled, + &i.Deleted, + &i.BaseUrl, + &i.Settings, + &i.SettingsKeyID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const calculateAIBridgeInterceptionsTelemetrySummary = `-- name: CalculateAIBridgeInterceptionsTelemetrySummary :one WITH interceptions_in_range AS ( -- Get all matching interceptions in the given timeframe. diff --git a/coderd/database/queries/ai_provider_keys.sql b/coderd/database/queries/ai_provider_keys.sql new file mode 100644 index 0000000000..018c434ef6 --- /dev/null +++ b/coderd/database/queries/ai_provider_keys.sql @@ -0,0 +1,75 @@ +-- name: GetAIProviderKeyByID :one +SELECT + * +FROM + ai_provider_keys +WHERE + id = @id::uuid; + +-- name: GetAIProviderKeysByProviderID :many +-- Returns all keys for a provider, ordered by created_at ASC so the +-- oldest key is returned first. AI Bridge currently uses the oldest +-- key per provider; multiple keys are stored to support future +-- failover and rotation flows. +SELECT + * +FROM + ai_provider_keys +WHERE + provider_id = @provider_id::uuid +ORDER BY + created_at ASC, + id ASC; + +-- name: GetAIProviderKeys :many +-- Returns every AI provider key row, including those belonging to a +-- soft-deleted provider, so the dbcrypt key rotation utility can +-- re-encrypt their api_key and clear references to retired keys. +SELECT + * +FROM + ai_provider_keys +ORDER BY + provider_id ASC, + created_at ASC, + id ASC; + +-- name: InsertAIProviderKey :one +INSERT INTO ai_provider_keys ( + id, + provider_id, + api_key, + api_key_key_id, + created_at, + updated_at +) VALUES ( + @id::uuid, + @provider_id::uuid, + @api_key::text, + sqlc.narg('api_key_key_id')::text, + @created_at::timestamptz, + @updated_at::timestamptz +) +RETURNING + *; + +-- name: DeleteAIProviderKey :exec +DELETE FROM + ai_provider_keys +WHERE + id = @id::uuid; + +-- name: UpdateEncryptedAIProviderKey :one +-- Updates only the encrypted columns (api_key, api_key_key_id) and +-- the updated_at timestamp on a row. Used by the dbcrypt key +-- rotation utility to re-encrypt or decrypt rows in place. +UPDATE + ai_provider_keys +SET + api_key = @api_key::text, + api_key_key_id = sqlc.narg('api_key_key_id')::text, + updated_at = NOW() +WHERE + id = @id::uuid +RETURNING + *; diff --git a/coderd/database/queries/ai_providers.sql b/coderd/database/queries/ai_providers.sql new file mode 100644 index 0000000000..9c2302861e --- /dev/null +++ b/coderd/database/queries/ai_providers.sql @@ -0,0 +1,92 @@ +-- name: GetAIProviderByID :one +SELECT + * +FROM + ai_providers +WHERE + id = @id::uuid AND deleted = FALSE; + +-- name: GetAIProviderByName :one +SELECT + * +FROM + ai_providers +WHERE + name = @name::text AND deleted = FALSE; + +-- name: GetAIProviders :many +-- Returns AI provider rows. Soft-deleted and disabled rows are excluded +-- unless include_deleted or include_disabled is set. +SELECT + * +FROM + ai_providers +WHERE + (@include_deleted::boolean OR NOT deleted) + AND (@include_disabled::boolean OR enabled) +ORDER BY + name ASC; + +-- name: InsertAIProvider :one +INSERT INTO ai_providers ( + id, + type, + name, + display_name, + enabled, + base_url, + settings, + settings_key_id +) VALUES ( + @id::uuid, + @type::ai_provider_type, + @name::text, + sqlc.narg('display_name')::text, + @enabled::boolean, + @base_url::text, + sqlc.narg('settings')::text, + sqlc.narg('settings_key_id')::text +) +RETURNING + *; + +-- name: UpdateAIProvider :one +UPDATE + ai_providers +SET + display_name = sqlc.narg('display_name')::text, + enabled = @enabled::boolean, + base_url = @base_url::text, + settings = sqlc.narg('settings')::text, + settings_key_id = sqlc.narg('settings_key_id')::text, + updated_at = NOW() +WHERE + id = @id::uuid AND deleted = FALSE +RETURNING + *; + +-- name: DeleteAIProviderByID :exec +UPDATE + ai_providers +SET + deleted = TRUE, + enabled = FALSE, + updated_at = NOW() +WHERE + id = @id::uuid AND deleted = FALSE; + +-- name: UpdateEncryptedAIProviderSettings :one +-- Updates only the encrypted columns (settings, settings_key_id) and +-- the updated_at timestamp on a row, regardless of its deleted flag. +-- Used by the dbcrypt key rotation utility to re-encrypt or decrypt +-- rows in place. +UPDATE + ai_providers +SET + settings = sqlc.narg('settings')::text, + settings_key_id = sqlc.narg('settings_key_id')::text, + updated_at = NOW() +WHERE + id = @id::uuid +RETURNING + *; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 0be8f26bbd..ef50155c9f 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -240,6 +240,9 @@ sql: aibridge_token_usage: AIBridgeTokenUsage aibridge_user_prompt: AIBridgeUserPrompt aibridge_model_thought: AIBridgeModelThought + ai_provider: AIProvider + ai_provider_key: AIProviderKey + ai_provider_type: AIProviderType mcp_server_config: MCPServerConfig mcp_server_configs: MCPServerConfigs mcp_server_user_token: MCPServerUserToken diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9c71259b23..9a6a828691 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -8,6 +8,8 @@ type UniqueConstraint string const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); UniqueAiModelPricesPkey UniqueConstraint = "ai_model_prices_pkey" // ALTER TABLE ONLY ai_model_prices ADD CONSTRAINT ai_model_prices_pkey PRIMARY KEY (provider, model); + UniqueAiProviderKeysPkey UniqueConstraint = "ai_provider_keys_pkey" // ALTER TABLE ONLY ai_provider_keys ADD CONSTRAINT ai_provider_keys_pkey PRIMARY KEY (id); + UniqueAiProvidersPkey UniqueConstraint = "ai_providers_pkey" // ALTER TABLE ONLY ai_providers ADD CONSTRAINT ai_providers_pkey PRIMARY KEY (id); UniqueAiSeatStatePkey UniqueConstraint = "ai_seat_state_pkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id); UniqueAibridgeInterceptionsPkey UniqueConstraint = "aibridge_interceptions_pkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id); UniqueAibridgeTokenUsagesPkey UniqueConstraint = "aibridge_token_usages_pkey" // ALTER TABLE ONLY aibridge_token_usages ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id); @@ -130,6 +132,7 @@ const ( UniqueWorkspaceResourceMetadataPkey UniqueConstraint = "workspace_resource_metadata_pkey" // ALTER TABLE ONLY workspace_resource_metadata ADD CONSTRAINT workspace_resource_metadata_pkey PRIMARY KEY (id); UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); + UniqueAiProvidersNameUnique UniqueConstraint = "ai_providers_name_unique" // CREATE UNIQUE INDEX ai_providers_name_unique ON ai_providers USING btree (name) WHERE (deleted = false); UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); UniqueIndexChatDebugRunsIDChat UniqueConstraint = "idx_chat_debug_runs_id_chat" // CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree (id, chat_id); UniqueIndexChatDebugStepsRunStep UniqueConstraint = "idx_chat_debug_steps_run_step" // CREATE UNIQUE INDEX idx_chat_debug_steps_run_step ON chat_debug_steps USING btree (run_id, step_number); diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 6ca3c3a3cd..2d304e6a3e 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -23,6 +23,16 @@ var ( Type: "ai_model_price", } + // ResourceAIProvider + // Valid Actions + // - "ActionCreate" :: create an AI provider + // - "ActionDelete" :: delete an AI provider + // - "ActionRead" :: read AI provider configuration + // - "ActionUpdate" :: update an AI provider + ResourceAIProvider = Object{ + Type: "ai_provider", + } + // ResourceAiSeat // Valid Actions // - "ActionCreate" :: record AI seat usage @@ -450,6 +460,7 @@ func AllResources() []Objecter { return []Objecter{ ResourceWildcard, ResourceAiModelPrice, + ResourceAIProvider, ResourceAiSeat, ResourceAibridgeInterception, ResourceApiKey, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index c366dd9a14..ccbc7634db 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -398,6 +398,15 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionUpdate: "update AI model prices", }, }, + "ai_provider": { + Name: "AIProvider", + Actions: map[Action]ActionDefinition{ + ActionRead: "read AI provider configuration", + ActionCreate: "create an AI provider", + ActionUpdate: "update an AI provider", + ActionDelete: "delete an AI provider", + }, + }, "ai_seat": { Actions: map[Action]ActionDefinition{ ActionCreate: "record AI seat usage", diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 6f10c7bf99..9cbefced0e 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1105,6 +1105,25 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + // Only owners can manage AI providers. Provider + // configuration is deployment-wide and includes secret + // material (api_key, settings) so it is not exposed to + // org admins or auditors. + Name: "AIProviders", + Actions: crud, + Resource: rbac.ResourceAIProvider, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: { + memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, { Name: "BoundaryUsage", Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index 85ef453602..cc1a75d1f6 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -9,6 +9,10 @@ package rbac const ( ScopeAiModelPriceRead ScopeName = "ai_model_price:read" ScopeAiModelPriceUpdate ScopeName = "ai_model_price:update" + ScopeAiProviderCreate ScopeName = "ai_provider:create" + ScopeAiProviderDelete ScopeName = "ai_provider:delete" + ScopeAiProviderRead ScopeName = "ai_provider:read" + ScopeAiProviderUpdate ScopeName = "ai_provider:update" ScopeAiSeatCreate ScopeName = "ai_seat:create" ScopeAiSeatRead ScopeName = "ai_seat:read" ScopeAibridgeInterceptionCreate ScopeName = "aibridge_interception:create" @@ -177,6 +181,10 @@ func (e ScopeName) Valid() bool { ScopeName("no_user_data"), ScopeAiModelPriceRead, ScopeAiModelPriceUpdate, + ScopeAiProviderCreate, + ScopeAiProviderDelete, + ScopeAiProviderRead, + ScopeAiProviderUpdate, ScopeAiSeatCreate, ScopeAiSeatRead, ScopeAibridgeInterceptionCreate, @@ -346,6 +354,10 @@ func AllScopeNameValues() []ScopeName { ScopeName("no_user_data"), ScopeAiModelPriceRead, ScopeAiModelPriceUpdate, + ScopeAiProviderCreate, + ScopeAiProviderDelete, + ScopeAiProviderRead, + ScopeAiProviderUpdate, ScopeAiSeatCreate, ScopeAiSeatRead, ScopeAibridgeInterceptionCreate, diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index 464d96968a..442601e40b 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -9,6 +9,11 @@ const ( APIKeyScopeAiModelPriceAll APIKeyScope = "ai_model_price:*" APIKeyScopeAiModelPriceRead APIKeyScope = "ai_model_price:read" APIKeyScopeAiModelPriceUpdate APIKeyScope = "ai_model_price:update" + APIKeyScopeAiProviderAll APIKeyScope = "ai_provider:*" + APIKeyScopeAiProviderCreate APIKeyScope = "ai_provider:create" + APIKeyScopeAiProviderDelete APIKeyScope = "ai_provider:delete" + APIKeyScopeAiProviderRead APIKeyScope = "ai_provider:read" + APIKeyScopeAiProviderUpdate APIKeyScope = "ai_provider:update" APIKeyScopeAiSeatAll APIKeyScope = "ai_seat:*" APIKeyScopeAiSeatCreate APIKeyScope = "ai_seat:create" APIKeyScopeAiSeatRead APIKeyScope = "ai_seat:read" diff --git a/codersdk/audit.go b/codersdk/audit.go index 1a06aecd31..912e1a8f85 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -43,11 +43,13 @@ const ( ResourceTypeWorkspaceAgent ResourceType = "workspace_agent" // Deprecated: Workspace App connections are now included in the // connection log. - ResourceTypeWorkspaceApp ResourceType = "workspace_app" - ResourceTypeTask ResourceType = "task" - ResourceTypeAISeat ResourceType = "ai_seat" - ResourceTypeChat ResourceType = "chat" - ResourceTypeUserSecret ResourceType = "user_secret" + ResourceTypeWorkspaceApp ResourceType = "workspace_app" + ResourceTypeTask ResourceType = "task" + ResourceTypeAISeat ResourceType = "ai_seat" + ResourceTypeAIProvider ResourceType = "ai_provider" + ResourceTypeAIProviderKey ResourceType = "ai_provider_key" + ResourceTypeChat ResourceType = "chat" + ResourceTypeUserSecret ResourceType = "user_secret" ) func (r ResourceType) FriendlyString() string { @@ -108,6 +110,10 @@ func (r ResourceType) FriendlyString() string { return "task" case ResourceTypeAISeat: return "ai seat" + case ResourceTypeAIProvider: + return "ai provider" + case ResourceTypeAIProviderKey: + return "ai provider key" case ResourceTypeChat: return "chat" case ResourceTypeUserSecret: diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 2641005e4a..19d2cb22fd 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -4005,7 +4005,7 @@ Write out the current server config as YAML to stdout.`, }, { Name: "AI Bridge Proxy Domain Allowlist", - Description: "Deprecated: This value is now derived automatically from the configured AI Bridge providers' base URLs. Setting this value has no effect. This option will be removed in a future release.", + Description: "Deprecated: This value is now derived automatically from the configured AI providers' base URLs. Setting this value has no effect. This option will be removed in a future release.", Flag: "aibridge-proxy-domain-allowlist", Env: "CODER_AIBRIDGE_PROXY_DOMAIN_ALLOWLIST", Value: &c.AI.BridgeProxyConfig.DomainAllowlist, @@ -4147,7 +4147,7 @@ type AIBridgeConfig struct { LegacyBedrock AIBridgeBedrockConfig `json:"bedrock" typescript:",notnull"` // Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER__ // env vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above. - Providers []AIBridgeProviderConfig `json:"providers,omitempty"` + Providers []AIProviderConfig `json:"providers,omitempty"` // Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. InjectCoderMCPTools serpent.Bool `json:"inject_coder_mcp_tools" typescript:",notnull"` Retention serpent.Duration `json:"retention" typescript:",notnull"` @@ -4187,10 +4187,10 @@ type AIBridgeBedrockConfig struct { SmallFastModel serpent.String `json:"small_fast_model" typescript:",notnull"` } -// AIBridgeProviderConfig represents a single AI Bridge provider instance, +// AIProviderConfig represents a single AI provider instance, // parsed from CODER_AIBRIDGE_PROVIDER__ environment variables. // This follows the same indexed pattern as ExternalAuthConfig. -type AIBridgeProviderConfig struct { +type AIProviderConfig struct { // Type is the provider type: "openai", "anthropic", or "copilot". Type string `json:"type"` // Name is the unique instance identifier used for routing. diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index d1e9853f23..f5940f6ad6 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -6,6 +6,7 @@ type RBACResource string const ( ResourceWildcard RBACResource = "*" ResourceAiModelPrice RBACResource = "ai_model_price" + ResourceAIProvider RBACResource = "ai_provider" ResourceAiSeat RBACResource = "ai_seat" ResourceAibridgeInterception RBACResource = "aibridge_interception" ResourceApiKey RBACResource = "api_key" @@ -80,6 +81,7 @@ const ( var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceWildcard: {}, ResourceAiModelPrice: {ActionRead, ActionUpdate}, + ResourceAIProvider: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, ResourceAiSeat: {ActionCreate, ActionRead}, ResourceAibridgeInterception: {ActionCreate, ActionRead, ActionUpdate}, ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 1f028ff055..33606da12a 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -15,6 +15,8 @@ We track the following resources: | Resource | | | |-----------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| AIProvider
create, write, delete | |
FieldTracked
base_urltrue
created_atfalse
deletedtrue
display_nametrue
enabledtrue
idtrue
nametrue
settingstrue
settings_key_idfalse
typetrue
updated_atfalse
| +| AIProviderKey
create, delete | |
FieldTracked
api_keytrue
api_key_key_idfalse
created_atfalse
idtrue
provider_idtrue
updated_atfalse
| | APIKey
login, logout, register, create, write, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| | AiSeatState
create | |
FieldTracked
first_used_attrue
last_event_descriptiontrue
last_event_typetrue
last_used_atfalse
updated_atfalse
user_idtrue
| | AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index b04c6408c1..15097d53aa 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -193,10 +193,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -326,10 +326,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -459,10 +459,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -554,10 +554,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -960,9 +960,9 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 35100d38d4..db26714321 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -484,27 +484,27 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|-------------------------------------|-----------------------------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `allow_byok` | boolean | false | | | -| `anthropic` | [codersdk.AIBridgeAnthropicConfig](#codersdkaibridgeanthropicconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | -| `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | -| `budget_period` | string | false | | | -| `budget_policy` | string | false | | Budget settings for AI Governance cost controls. | -| `circuit_breaker_enabled` | boolean | false | | Circuit breaker protects against cascading failures from upstream AI provider overload (503, 529). | -| `circuit_breaker_failure_threshold` | integer | false | | | -| `circuit_breaker_interval` | integer | false | | | -| `circuit_breaker_max_requests` | integer | false | | | -| `circuit_breaker_timeout` | integer | false | | | -| `enabled` | boolean | false | | | -| `inject_coder_mcp_tools` | boolean | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. | -| `max_concurrency` | integer | false | | | -| `openai` | [codersdk.AIBridgeOpenAIConfig](#codersdkaibridgeopenaiconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | -| `providers` | array of [codersdk.AIBridgeProviderConfig](#codersdkaibridgeproviderconfig) | false | | Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER__ env vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above. | -| `rate_limit` | integer | false | | | -| `retention` | integer | false | | | -| `send_actor_headers` | boolean | false | | | -| `structured_logging` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +|-------------------------------------|----------------------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `allow_byok` | boolean | false | | | +| `anthropic` | [codersdk.AIBridgeAnthropicConfig](#codersdkaibridgeanthropicconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | +| `bedrock` | [codersdk.AIBridgeBedrockConfig](#codersdkaibridgebedrockconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | +| `budget_period` | string | false | | | +| `budget_policy` | string | false | | Budget settings for AI Governance cost controls. | +| `circuit_breaker_enabled` | boolean | false | | Circuit breaker protects against cascading failures from upstream AI provider overload (503, 529). | +| `circuit_breaker_failure_threshold` | integer | false | | | +| `circuit_breaker_interval` | integer | false | | | +| `circuit_breaker_max_requests` | integer | false | | | +| `circuit_breaker_timeout` | integer | false | | | +| `enabled` | boolean | false | | | +| `inject_coder_mcp_tools` | boolean | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. | +| `max_concurrency` | integer | false | | | +| `openai` | [codersdk.AIBridgeOpenAIConfig](#codersdkaibridgeopenaiconfig) | false | | Deprecated: Use Providers with indexed CODER_AIBRIDGE_PROVIDER__* env vars instead. | +| `providers` | array of [codersdk.AIProviderConfig](#codersdkaiproviderconfig) | false | | Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER__ env vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above. | +| `rate_limit` | integer | false | | | +| `retention` | integer | false | | | +| `send_actor_headers` | boolean | false | | | +| `structured_logging` | boolean | false | | | ## codersdk.AIBridgeInterception @@ -757,32 +757,6 @@ | `base_url` | string | false | | | | `key` | string | false | | | -## codersdk.AIBridgeProviderConfig - -```json -{ - "base_url": "string", - "bedrock_model": "string", - "bedrock_region": "string", - "bedrock_small_fast_model": "string", - "dump_dir": "string", - "name": "string", - "type": "string" -} -``` - -### Properties - -| Name | Type | Required | Restrictions | Description | -|----------------------------|--------|----------|--------------|--------------------------------------------------------------------------------------------| -| `base_url` | string | false | | Base URL is the base URL of the upstream provider API. | -| `bedrock_model` | string | false | | | -| `bedrock_region` | string | false | | | -| `bedrock_small_fast_model` | string | false | | | -| `dump_dir` | string | false | | Dump dir is the directory path for dumping API requests and responses. | -| `name` | string | false | | Name is the unique instance identifier used for routing. Defaults to Type if not provided. | -| `type` | string | false | | Type is the provider type: "openai", "anthropic", or "copilot". | - ## codersdk.AIBridgeProxyConfig ```json @@ -1324,6 +1298,32 @@ | `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | | | `chat` | [codersdk.ChatConfig](#codersdkchatconfig) | false | | | +## codersdk.AIProviderConfig + +```json +{ + "base_url": "string", + "bedrock_model": "string", + "bedrock_region": "string", + "bedrock_small_fast_model": "string", + "dump_dir": "string", + "name": "string", + "type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------------|--------|----------|--------------|--------------------------------------------------------------------------------------------| +| `base_url` | string | false | | Base URL is the base URL of the upstream provider API. | +| `bedrock_model` | string | false | | | +| `bedrock_region` | string | false | | | +| `bedrock_small_fast_model` | string | false | | | +| `dump_dir` | string | false | | Dump dir is the directory path for dumping API requests and responses. | +| `name` | string | false | | Name is the unique instance identifier used for routing. Defaults to Type if not provided. | +| `type` | string | false | | Type is the provider type: "openai", "anthropic", or "copilot". | + ## codersdk.APIAllowListTarget ```json @@ -1400,9 +1400,9 @@ #### Enumerated Values -| Value(s) | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -10462,9 +10462,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | ## codersdk.RateLimitConfig @@ -10682,9 +10682,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_secret`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_provider`, `ai_provider_key`, `ai_seat`, `api_key`, `chat`, `convert_login`, `custom_role`, `git_ssh_key`, `group`, `health_settings`, `idp_sync_settings_group`, `idp_sync_settings_organization`, `idp_sync_settings_role`, `license`, `notification_template`, `notifications_settings`, `oauth2_provider_app`, `oauth2_provider_app_secret`, `organization`, `organization_member`, `prebuilds_settings`, `task`, `template`, `template_version`, `user`, `user_secret`, `workspace`, `workspace_agent`, `workspace_app`, `workspace_build`, `workspace_proxy` | ## codersdk.Response diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index ca1ea011d5..4d4e39c4a6 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -865,11 +865,11 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `*`, `ai_model_price`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| Property | Value(s) | +|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 1ccf6f4628..03996e744c 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -29,6 +29,8 @@ var AuditActionMap = map[string][]codersdk.AuditAction{ "License": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, "Task": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, "AiSeatState": {codersdk.AuditActionCreate}, + "AIProvider": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, + "AIProviderKey": {codersdk.AuditActionCreate, codersdk.AuditActionDelete}, "Chat": {codersdk.AuditActionCreate, codersdk.AuditActionWrite}, // chats get 'archived' by users, not deleted. "UserSecret": {codersdk.AuditActionCreate, codersdk.AuditActionWrite, codersdk.AuditActionDelete}, } @@ -367,6 +369,27 @@ var auditableResourcesTypes = map[any]map[string]Action{ "last_used_at": ActionIgnore, "updated_at": ActionIgnore, }, + &database.AIProvider{}: { + "id": ActionTrack, + "type": ActionTrack, + "name": ActionTrack, + "display_name": ActionTrack, + "enabled": ActionTrack, + "deleted": ActionTrack, + "base_url": ActionTrack, + "settings": ActionSecret, // Encrypted JSON blob may contain provider secrets (e.g. Bedrock access key + secret). + "settings_key_id": ActionIgnore, // dbcrypt key reference, derivable. + "created_at": ActionIgnore, // Implicit; not useful in a diff. + "updated_at": ActionIgnore, // Changes; not useful in a diff. + }, + &database.AIProviderKey{}: { + "id": ActionTrack, + "provider_id": ActionTrack, + "api_key": ActionSecret, // Provider API key, never expose in audit diffs. + "api_key_key_id": ActionIgnore, // dbcrypt key reference, derivable. + "created_at": ActionIgnore, // Implicit; not useful in a diff. + "updated_at": ActionIgnore, // Changes; not useful in a diff. + }, &database.TaskTable{}: { "id": ActionTrack, "organization_id": ActionIgnore, // Never changes. diff --git a/enterprise/cli/aibridged.go b/enterprise/cli/aibridged.go index 1dd6a2a05d..abff7b96b5 100644 --- a/enterprise/cli/aibridged.go +++ b/enterprise/cli/aibridged.go @@ -44,7 +44,7 @@ func newAIBridgeDaemon(coderAPI *coderd.API, providers []aibridge.Provider) (*ai return srv, nil } -// buildProviders constructs the list of aibridge providers from config. +// buildProviders constructs the list of AI providers from config. // It merges legacy single-provider env vars and indexed provider configs: // 1. Legacy providers (from CODER_AIBRIDGE_OPENAI_KEY, etc.) are added first. // If a legacy name conflicts with an indexed provider, startup fails with @@ -171,9 +171,9 @@ func buildProviders(cfg codersdk.AIBridgeConfig) ([]aibridge.Provider, error) { } // bedrockConfigFromProvider converts Bedrock fields from an indexed -// AIBridgeProviderConfig into an aibridge AWSBedrockConfig. +// AIProviderConfig into an aibridge AWSBedrockConfig. // Returns nil if no Bedrock fields are set. -func bedrockConfigFromProvider(p codersdk.AIBridgeProviderConfig) *aibridge.AWSBedrockConfig { +func bedrockConfigFromProvider(p codersdk.AIProviderConfig) *aibridge.AWSBedrockConfig { if p.BedrockRegion == "" && p.BedrockBaseURL == "" && len(p.BedrockAccessKeys) == 0 && len(p.BedrockAccessKeySecrets) == 0 { return nil } diff --git a/enterprise/cli/aibridged_internal_test.go b/enterprise/cli/aibridged_internal_test.go index 0504fdb705..a893feb8ea 100644 --- a/enterprise/cli/aibridged_internal_test.go +++ b/enterprise/cli/aibridged_internal_test.go @@ -42,7 +42,7 @@ func TestBuildProviders(t *testing.T) { t.Run("IndexedOnly", func(t *testing.T) { t.Parallel() cfg := codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ { Type: aibridge.ProviderAnthropic, Name: "anthropic-zdr", @@ -71,7 +71,7 @@ func TestBuildProviders(t *testing.T) { t.Run("LegacyOpenAIConflictsWithIndexed", func(t *testing.T) { t.Parallel() cfg := codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: aibridge.ProviderOpenAI, Keys: []string{"sk-indexed"}}, }, } @@ -85,7 +85,7 @@ func TestBuildProviders(t *testing.T) { t.Run("LegacyAnthropicConflictsWithIndexed", func(t *testing.T) { t.Parallel() cfg := codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderAnthropic, Name: aibridge.ProviderAnthropic, Keys: []string{"sk-indexed"}}, }, } @@ -99,7 +99,7 @@ func TestBuildProviders(t *testing.T) { t.Run("MixedLegacyAndIndexed", func(t *testing.T) { t.Parallel() cfg := codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderAnthropic, Name: "anthropic-zdr", Keys: []string{"sk-zdr"}}, }, } @@ -151,7 +151,7 @@ func TestBuildProviders(t *testing.T) { t.Run("UnknownType", func(t *testing.T) { t.Parallel() cfg := codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: "gemini", Name: "gemini-pro"}, }, } @@ -166,7 +166,7 @@ func TestBuildProviders(t *testing.T) { // Copilot providers can target any of the three GitHub // Copilot API hosts via an explicit BASE_URL. cfg := codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderCopilot, Name: aibridge.ProviderCopilot, DumpDir: "/tmp/copilot-dump"}, {Type: aibridge.ProviderCopilot, Name: agplaibridge.ProviderCopilotBusiness, BaseURL: "https://" + agplaibridge.HostCopilotBusiness}, {Type: aibridge.ProviderCopilot, Name: agplaibridge.ProviderCopilotEnterprise, BaseURL: "https://" + agplaibridge.HostCopilotEnterprise}, @@ -190,7 +190,7 @@ func TestBuildProviders(t *testing.T) { // ChatGPT is an OpenAI-compatible provider with a custom // base URL. Admins configure it as an indexed openai provider. cfg := codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: agplaibridge.ProviderChatGPT, BaseURL: agplaibridge.BaseURLChatGPT}, }, } @@ -219,7 +219,7 @@ func TestDomainsFromProviders(t *testing.T) { t.Parallel() providers, err := buildProviders(codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: "openai", Keys: []string{"k"}}, {Type: aibridge.ProviderAnthropic, Name: "anthropic", Keys: []string{"k"}}, {Type: aibridge.ProviderOpenAI, Name: "custom", Keys: []string{"k"}, BaseURL: "https://custom-llm.example.com:8443/api"}, @@ -243,7 +243,7 @@ func TestDomainsFromProviders(t *testing.T) { t.Parallel() providers, err := buildProviders(codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: "first", Keys: []string{"k"}, BaseURL: "https://api.example.com/v1"}, {Type: aibridge.ProviderOpenAI, Name: "second", Keys: []string{"k"}, BaseURL: "https://api.example.com/v2"}, }, @@ -268,7 +268,7 @@ func TestDomainsFromProviders(t *testing.T) { t.Parallel() providers, err := buildProviders(codersdk.AIBridgeConfig{ - Providers: []codersdk.AIBridgeProviderConfig{ + Providers: []codersdk.AIProviderConfig{ {Type: aibridge.ProviderOpenAI, Name: "provider", Keys: []string{"k"}, BaseURL: "https://API.Example.COM/v1"}, }, }) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 3b5df42a7d..23b5c6b57e 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -168,7 +168,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { if bridgeEnabled || proxyEnabled { providers, err := buildProviders(options.DeploymentValues.AI.BridgeConfig) if err != nil { - return nil, nil, xerrors.Errorf("build aibridge providers: %w", err) + return nil, nil, xerrors.Errorf("build AI providers: %w", err) } // In-memory aibridge daemon. diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index dcb373239f..47942e70f6 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -12,6 +12,12 @@ export const RBACResourceActions: Partial< read: "read AI model prices", update: "update AI model prices", }, + ai_provider: { + create: "create an AI provider", + delete: "delete an AI provider", + read: "read AI provider configuration", + update: "update an AI provider", + }, ai_seat: { create: "record AI seat usage", read: "read AI seat state", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3ea0fa8220..0ee4f550ea 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -57,7 +57,7 @@ export interface AIBridgeConfig { * Providers holds provider instances populated from CODER_AIBRIDGE_PROVIDER__ * env vars and/or the deprecated LegacyOpenAI/LegacyAnthropic/LegacyBedrock fields above. */ - readonly providers?: readonly AIBridgeProviderConfig[]; + readonly providers?: readonly AIProviderConfig[]; /** * @deprecated Injected MCP in AI Bridge is deprecated and will be removed in a future release. */ @@ -129,35 +129,6 @@ export interface AIBridgeOpenAIConfig { readonly key: string; } -// From codersdk/deployment.go -/** - * AIBridgeProviderConfig represents a single AI Bridge provider instance, - * parsed from CODER_AIBRIDGE_PROVIDER__ environment variables. - * This follows the same indexed pattern as ExternalAuthConfig. - */ -export interface AIBridgeProviderConfig { - /** - * Type is the provider type: "openai", "anthropic", or "copilot". - */ - readonly type: string; - /** - * Name is the unique instance identifier used for routing. - * Defaults to Type if not provided. - */ - readonly name: string; - /** - * BaseURL is the base URL of the upstream provider API. - */ - readonly base_url: string; - /** - * DumpDir is the directory path for dumping API requests and responses. - */ - readonly dump_dir?: string; - readonly bedrock_region?: string; - readonly bedrock_model?: string; - readonly bedrock_small_fast_model?: string; -} - // From codersdk/deployment.go export interface AIBridgeProxyConfig { readonly enabled: boolean; @@ -327,6 +298,35 @@ export interface AIConfig { readonly chat?: ChatConfig; } +// From codersdk/deployment.go +/** + * AIProviderConfig represents a single AI provider instance, + * parsed from CODER_AIBRIDGE_PROVIDER__ environment variables. + * This follows the same indexed pattern as ExternalAuthConfig. + */ +export interface AIProviderConfig { + /** + * Type is the provider type: "openai", "anthropic", or "copilot". + */ + readonly type: string; + /** + * Name is the unique instance identifier used for routing. + * Defaults to Type if not provided. + */ + readonly name: string; + /** + * BaseURL is the base URL of the upstream provider API. + */ + readonly base_url: string; + /** + * DumpDir is the directory path for dumping API requests and responses. + */ + readonly dump_dir?: string; + readonly bedrock_region?: string; + readonly bedrock_model?: string; + readonly bedrock_small_fast_model?: string; +} + // From codersdk/allowlist.go /** * APIAllowListTarget represents a single allow-list entry using the canonical @@ -362,6 +362,11 @@ export type APIKeyScope = | "ai_model_price:*" | "ai_model_price:read" | "ai_model_price:update" + | "ai_provider:*" + | "ai_provider:create" + | "ai_provider:delete" + | "ai_provider:read" + | "ai_provider:update" | "ai_seat:*" | "ai_seat:create" | "ai_seat:read" @@ -577,6 +582,11 @@ export const APIKeyScopes: APIKeyScope[] = [ "ai_model_price:*", "ai_model_price:read", "ai_model_price:update", + "ai_provider:*", + "ai_provider:create", + "ai_provider:delete", + "ai_provider:read", + "ai_provider:update", "ai_seat:*", "ai_seat:create", "ai_seat:read", @@ -6432,6 +6442,7 @@ export const RBACActions: RBACAction[] = [ // From codersdk/rbacresources_gen.go export type RBACResource = + | "ai_provider" | "ai_model_price" | "ai_seat" | "aibridge_interception" @@ -6480,6 +6491,7 @@ export type RBACResource = | "workspace_proxy"; export const RBACResources: RBACResource[] = [ + "ai_provider", "ai_model_price", "ai_seat", "aibridge_interception", @@ -6639,6 +6651,8 @@ export interface ResolveAutostartResponse { // From codersdk/audit.go export type ResourceType = + | "ai_provider" + | "ai_provider_key" | "ai_seat" | "api_key" | "chat" @@ -6670,6 +6684,8 @@ export type ResourceType = | "workspace_proxy"; export const ResourceTypes: ResourceType[] = [ + "ai_provider", + "ai_provider_key", "ai_seat", "api_key", "chat",