mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add AI providers HTTP CRUD handlers (#24894)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user