feat: add AI providers HTTP CRUD handlers (#24894)

This commit is contained in:
Danny Kopping
2026-05-20 10:21:36 +02:00
committed by GitHub
parent fe01efeb21
commit dd3223451b
27 changed files with 3260 additions and 150 deletions
+75
View File
@@ -19,6 +19,7 @@ import (
"tailscale.com/tailcfg"
agentproto "github.com/coder/coder/v2/agent/proto"
aibridgeutils "github.com/coder/coder/v2/aibridge/utils"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
@@ -42,6 +43,80 @@ func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget
}
}
// AIProvider converts a database row plus its API keys into the
// codersdk shape. The caller is responsible for ensuring the row and
// keys have been decrypted (i.e. fetched through the dbcrypt-wrapped
// store). Each api_key is masked via aibridge utils.MaskSecret and
// write-only fields on Settings are stripped, so the result is safe
// to echo back in API responses.
func AIProvider(row database.AIProvider, keys []database.AIProviderKey) (codersdk.AIProvider, error) {
display := row.Name
if row.DisplayName.Valid && row.DisplayName.String != "" {
display = row.DisplayName.String
}
out := codersdk.AIProvider{
ID: row.ID,
Type: codersdk.AIProviderType(row.Type),
Name: row.Name,
DisplayName: display,
Enabled: row.Enabled,
BaseURL: row.BaseUrl,
APIKeys: maskAIProviderKeys(keys),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
s, err := AIProviderSettings(row.Settings)
if err != nil {
return codersdk.AIProvider{}, xerrors.Errorf("decode settings: %w", err)
}
out.Settings = redactAIProviderSettings(s)
return out, nil
}
// AIProviderSettings parses the on-disk JSON form back into a codersdk
// settings value. SQL NULL and the empty string decode to the zero
// value.
func AIProviderSettings(col sql.NullString) (codersdk.AIProviderSettings, error) {
if !col.Valid || col.String == "" {
return codersdk.AIProviderSettings{}, nil
}
var s codersdk.AIProviderSettings
if err := json.Unmarshal([]byte(col.String), &s); err != nil {
return codersdk.AIProviderSettings{}, err
}
return s, nil
}
// maskAIProviderKeys converts the supplied database rows into the
// public-facing AIProviderKey shape, preserving order. Plaintext is
// replaced by a non-reversible mask (see aibridgeutils.MaskSecret) so
// the result is safe to embed in API responses.
func maskAIProviderKeys(keys []database.AIProviderKey) []codersdk.AIProviderKey {
out := make([]codersdk.AIProviderKey, 0, len(keys))
for _, k := range keys {
out = append(out, codersdk.AIProviderKey{
ID: k.ID,
Masked: aibridgeutils.MaskSecret(k.APIKey),
CreatedAt: k.CreatedAt,
})
}
return out
}
// redactAIProviderSettings strips write-only fields from a settings
// value so it can be safely echoed back in API responses.
func redactAIProviderSettings(s codersdk.AIProviderSettings) codersdk.AIProviderSettings {
out := s
if out.Bedrock != nil {
// Deep-copy so we don't mutate the caller's struct.
b := *out.Bedrock
b.AccessKey = nil
b.AccessKeySecret = nil
out.Bedrock = &b
}
return out
}
type ExternalAuthMeta struct {
Authenticated bool
ValidateError string
+6 -6
View File
@@ -2543,15 +2543,15 @@ func (q *querier) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (datab
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.
func (q *querier) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]database.AIProviderKey, error) {
// Callers pass include_deleted=TRUE only from the dbcrypt key
// rotation utility, which needs to re-encrypt every row that holds
// a foreign-key reference to dbcrypt_keys regardless of whether
// the parent provider is still live.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil {
return nil, err
}
return q.db.GetAIProviderKeys(ctx)
return q.db.GetAIProviderKeys(ctx, includeDeleted)
}
func (q *querier) GetAIProviderKeysByProviderID(ctx context.Context, providerID uuid.UUID) ([]database.AIProviderKey, error) {
+2 -2
View File
@@ -6517,8 +6517,8 @@ func (s *MethodTestSuite) TestAIBridge() {
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})
dbm.EXPECT().GetAIProviderKeys(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{keyA, keyB}, nil).AnyTimes()
check.Args(false).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{})
+2 -2
View File
@@ -1041,9 +1041,9 @@ func (m queryMetricsStore) GetAIProviderKeyByID(ctx context.Context, id uuid.UUI
return r0, r1
}
func (m queryMetricsStore) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) {
func (m queryMetricsStore) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]database.AIProviderKey, error) {
start := time.Now()
r0, r1 := m.s.GetAIProviderKeys(ctx)
r0, r1 := m.s.GetAIProviderKeys(ctx, includeDeleted)
m.queryLatencies.WithLabelValues("GetAIProviderKeys").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviderKeys").Inc()
return r0, r1
+4 -4
View File
@@ -1802,18 +1802,18 @@ func (mr *MockStoreMockRecorder) GetAIProviderKeyByID(ctx, id any) *gomock.Call
}
// GetAIProviderKeys mocks base method.
func (m *MockStore) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) {
func (m *MockStore) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]database.AIProviderKey, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAIProviderKeys", ctx)
ret := m.ctrl.Call(m, "GetAIProviderKeys", ctx, includeDeleted)
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 {
func (mr *MockStoreMockRecorder) GetAIProviderKeys(ctx, includeDeleted any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderKeys", reflect.TypeOf((*MockStore)(nil).GetAIProviderKeys), ctx)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderKeys", reflect.TypeOf((*MockStore)(nil).GetAIProviderKeys), ctx, includeDeleted)
}
// GetAIProviderKeysByProviderID mocks base method.
@@ -26,7 +26,7 @@ INSERT INTO ai_providers (
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"}'
'{"_type":"bedrock","_version":1,"region":"us-west-2","model":"global.anthropic.claude-sonnet-4-5-20250929-v1:0","access_key":"fixture-bedrock-access-key","access_key_secret":"fixture-bedrock-access-key-secret"}'
),
(
'8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1a03',
+6 -4
View File
@@ -253,10 +253,12 @@ type sqlcQuerier interface {
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 AI provider key rows. By default, only rows whose parent
// provider is live (deleted = FALSE) are returned, so the API list
// handler can fetch every visible provider's keys in a single query.
// The dbcrypt key rotation utility passes include_deleted=TRUE to
// re-encrypt rows that belong to soft-deleted providers as well.
GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]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
+14 -9
View File
@@ -148,20 +148,25 @@ func (q *sqlQuerier) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (AI
const getAIProviderKeys = `-- name: GetAIProviderKeys :many
SELECT
id, provider_id, api_key, api_key_key_id, created_at, updated_at
ai_provider_keys.id, ai_provider_keys.provider_id, ai_provider_keys.api_key, ai_provider_keys.api_key_key_id, ai_provider_keys.created_at, ai_provider_keys.updated_at
FROM
ai_provider_keys
JOIN ai_providers ON ai_providers.id = ai_provider_keys.provider_id
WHERE
$1::boolean OR NOT ai_providers.deleted
ORDER BY
provider_id ASC,
created_at ASC,
id ASC
ai_provider_keys.provider_id ASC,
ai_provider_keys.created_at ASC,
ai_provider_keys.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)
// Returns AI provider key rows. By default, only rows whose parent
// provider is live (deleted = FALSE) are returned, so the API list
// handler can fetch every visible provider's keys in a single query.
// The dbcrypt key rotation utility passes include_deleted=TRUE to
// re-encrypt rows that belong to soft-deleted providers as well.
func (q *sqlQuerier) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]AIProviderKey, error) {
rows, err := q.db.QueryContext(ctx, getAIProviderKeys, includeDeleted)
if err != nil {
return nil, err
}
+12 -7
View File
@@ -22,17 +22,22 @@ ORDER BY
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.
-- Returns AI provider key rows. By default, only rows whose parent
-- provider is live (deleted = FALSE) are returned, so the API list
-- handler can fetch every visible provider's keys in a single query.
-- The dbcrypt key rotation utility passes include_deleted=TRUE to
-- re-encrypt rows that belong to soft-deleted providers as well.
SELECT
*
ai_provider_keys.*
FROM
ai_provider_keys
JOIN ai_providers ON ai_providers.id = ai_provider_keys.provider_id
WHERE
@include_deleted::boolean OR NOT ai_providers.deleted
ORDER BY
provider_id ASC,
created_at ASC,
id ASC;
ai_provider_keys.provider_id ASC,
ai_provider_keys.created_at ASC,
ai_provider_keys.id ASC;
-- name: InsertAIProviderKey :one
INSERT INTO ai_provider_keys (