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
+642
View File
@@ -0,0 +1,642 @@
package coderd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)
// aiProvidersHandler registers the CRUD HTTP routes for runtime AI
// provider configuration at /api/v2/ai/providers.
func aiProvidersHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) {
return func(r chi.Router) {
r.Use(middlewares...)
r.Get("/", api.aiProvidersList)
r.Post("/", api.aiProvidersCreate)
r.Route("/{idOrName}", func(r chi.Router) {
r.Get("/", api.aiProvidersGet)
r.Patch("/", api.aiProvidersUpdate)
r.Delete("/", api.aiProvidersDelete)
})
}
}
// @Summary List AI providers
// @ID list-ai-providers
// @Security CoderSessionToken
// @Produce json
// @Tags AI Providers
// @Success 200 {array} codersdk.AIProvider
// @Router /api/v2/ai/providers [get]
func (api *API) aiProvidersList(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rows, err := api.Database.GetAIProviders(ctx, database.GetAIProvidersParams{
IncludeDisabled: true,
})
if dbauthz.IsNotAuthorizedError(err) {
api.Logger.Error(ctx, "list AI providers", slog.Error(err))
httpapi.Forbidden(rw)
return
}
if err != nil {
api.Logger.Error(ctx, "list AI providers", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error listing AI providers.",
Detail: err.Error(),
})
return
}
keysByProvider, err := loadAIProviderKeysByProvider(ctx, api.Database)
if err != nil {
api.Logger.Error(ctx, "list AI provider keys", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error loading AI provider keys.",
Detail: err.Error(),
})
return
}
out := make([]codersdk.AIProvider, 0, len(rows))
for _, row := range rows {
sdk, err := db2sdk.AIProvider(row, keysByProvider[row.ID])
if err != nil {
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", row.ID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting AI provider.",
Detail: err.Error(),
})
return
}
out = append(out, sdk)
}
httpapi.Write(ctx, rw, http.StatusOK, out)
}
// @Summary Get an AI provider
// @ID get-an-ai-provider
// @Security CoderSessionToken
// @Produce json
// @Tags AI Providers
// @Param idOrName path string true "Provider ID or name"
// @Success 200 {object} codersdk.AIProvider
// @Router /api/v2/ai/providers/{idOrName} [get]
func (api *API) aiProvidersGet(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row, err := lookupAIProvider(ctx, api.Database, chi.URLParam(r, "idOrName"))
if err != nil {
writeAIProviderError(ctx, api.Logger, rw, err, "lookup AI provider", "Internal error fetching AI provider.")
return
}
keys, err := api.Database.GetAIProviderKeysByProviderID(ctx, row.ID)
if err != nil {
api.Logger.Error(ctx, "fetch AI provider keys", slog.F("provider_id", row.ID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error loading AI provider keys.",
Detail: err.Error(),
})
return
}
sdk, err := db2sdk.AIProvider(row, keys)
if err != nil {
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", row.ID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting AI provider.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, sdk)
}
// @Summary Create an AI provider
// @ID create-an-ai-provider
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags AI Providers
// @Param request body codersdk.CreateAIProviderRequest true "Create AI provider request"
// @Success 201 {object} codersdk.AIProvider
// @Router /api/v2/ai/providers [post]
func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.AIProvider](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionCreate,
})
)
defer commitAudit()
var req codersdk.CreateAIProviderRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if validations := req.Validate(); len(validations) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid AI provider request.",
Validations: validations,
})
return
}
// Bedrock providers authenticate via the settings blob, not via a
// bearer key, so registering an api_keys list against them would
// be silently unused.
if req.Settings.Bedrock != nil && len(req.APIKeys) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Bedrock providers do not accept api_keys; configure access credentials via settings.",
})
return
}
settings, err := encodeAIProviderSettings(req.Settings)
if err != nil {
api.Logger.Error(ctx, "encode AI provider settings", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error encoding settings.",
Detail: err.Error(),
})
return
}
var (
row database.AIProvider
keys []database.AIProviderKey
)
err = api.Database.InTx(func(tx database.Store) error {
var txErr error
row, txErr = tx.InsertAIProvider(ctx, database.InsertAIProviderParams{
ID: uuid.New(),
Type: database.AIProviderType(req.Type),
Name: req.Name,
DisplayName: sql.NullString{String: req.DisplayName, Valid: req.DisplayName != ""},
Enabled: req.Enabled,
BaseUrl: req.BaseURL,
Settings: settings,
// SettingsKeyID is set by the dbcrypt wrapper.
SettingsKeyID: sql.NullString{},
})
if txErr != nil {
return txErr
}
keys, txErr = insertAIProviderKeys(ctx, tx, row.ID, req.APIKeys)
return txErr
}, &database.TxOptions{TxIdentifier: "create_ai_provider"})
if err != nil {
if database.IsUniqueViolation(err) {
api.Logger.Warn(ctx, "create AI provider: duplicate name", slog.F("name", req.Name), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf("AI provider %q already exists.", req.Name),
Detail: err.Error(),
})
return
}
if dbauthz.IsNotAuthorizedError(err) {
api.Logger.Error(ctx, "create AI provider", slog.Error(err))
httpapi.Forbidden(rw)
return
}
api.Logger.Error(ctx, "create AI provider", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error creating AI provider.",
Detail: err.Error(),
})
return
}
aReq.New = row
sdk, err := db2sdk.AIProvider(row, keys)
if err != nil {
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", row.ID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting AI provider.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusCreated, sdk)
}
// @Summary Update an AI provider
// @ID update-an-ai-provider
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags AI Providers
// @Param idOrName path string true "Provider ID or name"
// @Param request body codersdk.UpdateAIProviderRequest true "Update AI provider request"
// @Success 200 {object} codersdk.AIProvider
// @Router /api/v2/ai/providers/{idOrName} [patch]
func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.AIProvider](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
)
defer commitAudit()
var req codersdk.UpdateAIProviderRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if req.IsEmpty() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "At least one field must be provided.",
})
return
}
if validations := req.Validate(); len(validations) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid AI provider request.",
Validations: validations,
})
return
}
idOrName := chi.URLParam(r, "idOrName")
var (
updated database.AIProvider
keys []database.AIProviderKey
)
err := api.Database.InTx(func(tx database.Store) error {
old, err := lookupAIProvider(ctx, tx, idOrName)
if err != nil {
return err
}
aReq.Old = old
// Decode the existing settings to merge with the patch. The dbcrypt
// wrapper has already decrypted the blob for us.
existing, err := db2sdk.AIProviderSettings(old.Settings)
if err != nil {
return xerrors.Errorf("decode existing settings: %w", err)
}
if req.Settings != nil {
existing = mergeAIProviderSettings(existing, *req.Settings)
}
// Bedrock settings are only meaningful for anthropic-typed
// providers; rejecting the mismatch keeps a misconfiguration
// from sitting silently in the encrypted blob.
if existing.Bedrock != nil && old.Type != database.AiProviderTypeAnthropic {
return errAIProviderBedrockTypeMismatch
}
settings, err := encodeAIProviderSettings(existing)
if err != nil {
return xerrors.Errorf("encode settings: %w", err)
}
// Reject keys against Bedrock providers (whether the existing
// row is Bedrock or the patch would make it so).
if req.APIKeys != nil && existing.Bedrock != nil && len(*req.APIKeys) > 0 {
return errBedrockRejectsAPIKeys
}
displayName := old.DisplayName
if req.DisplayName != nil {
// Empty string clears the column.
displayName = sql.NullString{String: *req.DisplayName, Valid: *req.DisplayName != ""}
}
params := database.UpdateAIProviderParams{
ID: old.ID,
DisplayName: displayName,
Enabled: ptr.NilToDefault(req.Enabled, old.Enabled),
BaseUrl: ptr.NilToDefault(req.BaseURL, old.BaseUrl),
Settings: settings,
// SettingsKeyID is set by the dbcrypt wrapper.
SettingsKeyID: sql.NullString{},
}
updated, err = tx.UpdateAIProvider(ctx, params)
if err != nil {
return xerrors.Errorf("update ai provider: %w", err)
}
aReq.New = updated
if req.APIKeys != nil {
keys, err = applyAIProviderKeyOps(ctx, tx, updated.ID, *req.APIKeys)
if err != nil {
return err
}
return nil
}
keys, err = tx.GetAIProviderKeysByProviderID(ctx, updated.ID)
if err != nil {
return xerrors.Errorf("load ai provider keys: %w", err)
}
return nil
}, &database.TxOptions{TxIdentifier: "update_ai_provider"})
if errors.Is(err, errBedrockRejectsAPIKeys) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Bedrock providers do not accept api_keys; configure access credentials via settings.",
})
return
}
if errors.Is(err, errAIProviderBedrockTypeMismatch) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Bedrock settings are only valid for type=anthropic.",
})
return
}
if errors.Is(err, errAIProviderKeyUnknown) {
// Use the sentinel directly so the response message does not
// leak the "execute transaction:" wrapper xerrors added on the
// way out of InTx.
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: errAIProviderKeyUnknown.Error(),
Detail: err.Error(),
})
return
}
if err != nil {
writeAIProviderError(ctx, api.Logger, rw, err, "update AI provider", "Internal error updating AI provider.")
return
}
sdk, err := db2sdk.AIProvider(updated, keys)
if err != nil {
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", updated.ID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting AI provider.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, sdk)
}
// @Summary Delete an AI provider
// @ID delete-an-ai-provider
// @Security CoderSessionToken
// @Tags AI Providers
// @Param idOrName path string true "Provider ID or name"
// @Success 204
// @Router /api/v2/ai/providers/{idOrName} [delete]
func (api *API) aiProvidersDelete(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.AIProvider](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
})
)
defer commitAudit()
idOrName := chi.URLParam(r, "idOrName")
err := api.Database.InTx(func(tx database.Store) error {
row, err := lookupAIProvider(ctx, tx, idOrName)
if err != nil {
return err
}
aReq.Old = row
// Soft-delete UPDATE; :exec, so re-deletion is a silent no-op.
if err := tx.DeleteAIProviderByID(ctx, row.ID); err != nil {
return xerrors.Errorf("delete ai provider: %w", err)
}
return nil
}, &database.TxOptions{TxIdentifier: "delete_ai_provider"})
if err != nil {
writeAIProviderError(ctx, api.Logger, rw, err, "delete AI provider", "Internal error deleting AI provider.")
return
}
rw.WriteHeader(http.StatusNoContent)
}
// errBedrockRejectsAPIKeys is the sentinel returned from inside the
// update transaction when a caller attempts to attach api_keys to a
// Bedrock-typed provider; the outer handler translates it into a 400.
var errBedrockRejectsAPIKeys = xerrors.New("bedrock providers do not accept api_keys")
// errAIProviderBedrockTypeMismatch is the sentinel returned from
// inside the update transaction when the post-merge settings carry a
// Bedrock block but the provider is not anthropic-typed; the outer
// handler translates it into a 400.
var errAIProviderBedrockTypeMismatch = xerrors.New("bedrock settings are only valid for type=anthropic")
// errAIProviderInvalidName is returned from lookupAIProvider when the
// idOrName parameter is neither a UUID nor a syntactically-valid name.
// The handler translates this into a 400 so an integrator gets a hint
// about the path shape instead of a misleading 404.
var errAIProviderInvalidName = xerrors.New("invalid provider id or name")
// lookupAIProvider resolves a UUID-or-name path parameter against a Store.
// Soft-deleted providers are not returned; lookup by name searches active
// rows only.
func lookupAIProvider(ctx context.Context, store database.Store, idOrName string) (database.AIProvider, error) {
if id, err := uuid.Parse(idOrName); err == nil {
row, err := store.GetAIProviderByID(ctx, id)
if err != nil {
return database.AIProvider{}, err
}
return row, nil
}
if !codersdk.AIProviderNameRegex.MatchString(idOrName) {
// Bail before hitting the DB: the regex matches the CHECK
// constraint on ai_providers.name, so a non-matching string
// could not have been inserted.
return database.AIProvider{}, errAIProviderInvalidName
}
return store.GetAIProviderByName(ctx, idOrName)
}
// writeAIProviderError translates an error from the AI provider
// lookup/update/delete paths into the right HTTP status code. logMsg
// labels the log line for operator debugging, and userMsg is the
// internal-error response message shown to the API consumer when no
// more specific branch fires.
func writeAIProviderError(ctx context.Context, logger slog.Logger, rw http.ResponseWriter, err error, logMsg, userMsg string) {
if errors.Is(err, errAIProviderInvalidName) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid provider id or name: must be a UUID or match %s.", codersdk.AIProviderNameRegex),
})
return
}
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
if dbauthz.IsNotAuthorizedError(err) {
logger.Error(ctx, logMsg, slog.Error(err))
httpapi.Forbidden(rw)
return
}
logger.Error(ctx, logMsg, slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: userMsg,
Detail: err.Error(),
})
}
// loadAIProviderKeysByProvider fetches keys for every live provider in
// one query and buckets the rows by ProviderID, so the list handler
// can avoid an N+1 fetch. Soft-deleted providers' keys are excluded
// by the query.
func loadAIProviderKeysByProvider(ctx context.Context, store database.Store) (map[uuid.UUID][]database.AIProviderKey, error) {
rows, err := store.GetAIProviderKeys(ctx, false)
if err != nil {
return nil, err
}
out := make(map[uuid.UUID][]database.AIProviderKey, len(rows))
for _, row := range rows {
out[row.ProviderID] = append(out[row.ProviderID], row)
}
return out, nil
}
// insertAIProviderKeys writes a fresh set of key rows for a provider
// inside a transaction. It returns the inserted rows in insertion
// order so callers can render them in a response.
func insertAIProviderKeys(ctx context.Context, tx database.Store, providerID uuid.UUID, plaintexts []string) ([]database.AIProviderKey, error) {
out := make([]database.AIProviderKey, 0, len(plaintexts))
now := dbtime.Now()
for _, key := range plaintexts {
row, err := tx.InsertAIProviderKey(ctx, database.InsertAIProviderKeyParams{
ID: uuid.New(),
ProviderID: providerID,
APIKey: key,
// ApiKeyKeyID is set by the dbcrypt wrapper.
ApiKeyKeyID: sql.NullString{},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return nil, xerrors.Errorf("insert ai provider key: %w", err)
}
out = append(out, row)
}
return out, nil
}
// applyAIProviderKeyOps reconciles a provider's keys against the
// supplied mutation list inside a transaction: kept-by-ID rows stay,
// rows whose ID is absent from the list are deleted, and entries
// carrying a plaintext APIKey are inserted as new rows. Caller is
// responsible for prior validation (XOR per entry, no duplicate IDs).
// IDs that do not belong to this provider return errAIProviderKeyUnknown.
func applyAIProviderKeyOps(ctx context.Context, tx database.Store, providerID uuid.UUID, muts []codersdk.AIProviderKeyMutation) ([]database.AIProviderKey, error) {
existing, err := tx.GetAIProviderKeysByProviderID(ctx, providerID)
if err != nil {
return nil, xerrors.Errorf("load existing ai provider keys: %w", err)
}
existingByID := make(map[uuid.UUID]struct{}, len(existing))
for _, k := range existing {
existingByID[k.ID] = struct{}{}
}
keep := make(map[uuid.UUID]struct{}, len(muts))
var inserts []string
for _, m := range muts {
switch {
case m.ID != nil:
if _, ok := existingByID[*m.ID]; !ok {
return nil, xerrors.Errorf("%w: %s", errAIProviderKeyUnknown, *m.ID)
}
keep[*m.ID] = struct{}{}
case m.APIKey != nil:
inserts = append(inserts, *m.APIKey)
}
}
for _, k := range existing {
if _, ok := keep[k.ID]; ok {
continue
}
if err := tx.DeleteAIProviderKey(ctx, k.ID); err != nil {
return nil, xerrors.Errorf("delete ai provider key %s: %w", k.ID, err)
}
}
if _, err := insertAIProviderKeys(ctx, tx, providerID, inserts); err != nil {
return nil, err
}
out, err := tx.GetAIProviderKeysByProviderID(ctx, providerID)
if err != nil {
return nil, xerrors.Errorf("reload ai provider keys: %w", err)
}
return out, nil
}
// errAIProviderKeyUnknown is the sentinel returned by
// applyAIProviderKeyOps when a mutation references an ID that does not
// belong to the provider being patched; the outer handler translates it
// into a 400.
var errAIProviderKeyUnknown = xerrors.New("api_keys references an unknown id for this provider")
// encodeAIProviderSettings serializes a settings value into the
// discriminated JSON form stored in ai_providers.settings. Empty
// settings return an invalid sql.NullString so the row stores SQL NULL
// and skips dbcrypt encryption entirely.
func encodeAIProviderSettings(s codersdk.AIProviderSettings) (sql.NullString, error) {
if s.IsZero() {
return sql.NullString{}, nil
}
out, err := json.Marshal(s)
if err != nil {
return sql.NullString{}, err
}
return sql.NullString{String: string(out), Valid: true}, nil
}
// mergeAIProviderSettings overlays a patch onto an existing settings
// value. Write-only fields (Bedrock AccessKey and AccessKeySecret) use
// pointers so the patch can distinguish "omitted, keep existing" (nil)
// from "explicitly clear" (pointer to empty string) - e.g. when an
// admin migrates from static AWS credentials to IAM role-based auth
// in a single PATCH.
func mergeAIProviderSettings(existing, patch codersdk.AIProviderSettings) codersdk.AIProviderSettings {
if patch.Bedrock == nil {
// Patch carries no type-specific data; treat as a clear.
return codersdk.AIProviderSettings{}
}
merged := *patch.Bedrock
if existing.Bedrock != nil {
if merged.AccessKey == nil {
merged.AccessKey = existing.Bedrock.AccessKey
}
if merged.AccessKeySecret == nil {
merged.AccessKeySecret = existing.Bedrock.AccessKeySecret
}
}
return codersdk.AIProviderSettings{Bedrock: &merged}
}
File diff suppressed because it is too large Load Diff
+316
View File
@@ -1143,6 +1143,175 @@ const docTemplate = `{
}
}
},
"/api/v2/ai/providers": {
"get": {
"produces": [
"application/json"
],
"tags": [
"AI Providers"
],
"summary": "List AI providers",
"operationId": "list-ai-providers",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIProvider"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"AI Providers"
],
"summary": "Create an AI provider",
"operationId": "create-an-ai-provider",
"parameters": [
{
"description": "Create AI provider request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateAIProviderRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.AIProvider"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/ai/providers/{idOrName}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"AI Providers"
],
"summary": "Get an AI provider",
"operationId": "get-an-ai-provider",
"parameters": [
{
"type": "string",
"description": "Provider ID or name",
"name": "idOrName",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.AIProvider"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"delete": {
"tags": [
"AI Providers"
],
"summary": "Delete an AI provider",
"operationId": "delete-an-ai-provider",
"parameters": [
{
"type": "string",
"description": "Provider ID or name",
"name": "idOrName",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"patch": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"AI Providers"
],
"summary": "Update an AI provider",
"operationId": "update-an-ai-provider",
"parameters": [
{
"type": "string",
"description": "Provider ID or name",
"name": "idOrName",
"in": "path",
"required": true
},
{
"description": "Update AI provider request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateAIProviderRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.AIProvider"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/aibridge/clients": {
"get": {
"produces": [
@@ -14719,6 +14888,47 @@ const docTemplate = `{
}
}
},
"codersdk.AIProvider": {
"type": "object",
"properties": {
"api_keys": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIProviderKey"
}
},
"base_url": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"settings": {
"$ref": "#/definitions/codersdk.AIProviderSettings"
},
"type": {
"$ref": "#/definitions/codersdk.AIProviderType"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.AIProviderConfig": {
"type": "object",
"properties": {
@@ -14749,6 +14959,60 @@ const docTemplate = `{
}
}
},
"codersdk.AIProviderKey": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"masked": {
"type": "string"
}
}
},
"codersdk.AIProviderKeyMutation": {
"type": "object",
"properties": {
"api_key": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.AIProviderSettings": {
"type": "object"
},
"codersdk.AIProviderType": {
"type": "string",
"enum": [
"openai",
"anthropic",
"azure",
"google",
"openai-compat",
"openrouter",
"vercel",
"bedrock"
],
"x-enum-varnames": [
"AIProviderTypeOpenAI",
"AIProviderTypeAnthropic",
"AIProviderTypeAzure",
"AIProviderTypeGoogle",
"AIProviderTypeOpenAICompat",
"AIProviderTypeOpenrouter",
"AIProviderTypeVercel",
"AIProviderTypeBedrock"
]
},
"codersdk.APIAllowListTarget": {
"type": "object",
"properties": {
@@ -17008,6 +17272,35 @@ const docTemplate = `{
}
}
},
"codersdk.CreateAIProviderRequest": {
"type": "object",
"properties": {
"api_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"base_url": {
"type": "string"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"settings": {
"$ref": "#/definitions/codersdk.AIProviderSettings"
},
"type": {
"$ref": "#/definitions/codersdk.AIProviderType"
}
}
},
"codersdk.CreateChatMessageRequest": {
"type": "object",
"properties": {
@@ -23641,6 +23934,29 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateAIProviderRequest": {
"type": "object",
"properties": {
"api_keys": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIProviderKeyMutation"
}
},
"base_url": {
"type": "string"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"settings": {
"$ref": "#/definitions/codersdk.AIProviderSettings"
}
}
},
"codersdk.UpdateActiveTemplateVersion": {
"type": "object",
"required": [
+294
View File
@@ -1010,6 +1010,153 @@
}
}
},
"/api/v2/ai/providers": {
"get": {
"produces": ["application/json"],
"tags": ["AI Providers"],
"summary": "List AI providers",
"operationId": "list-ai-providers",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIProvider"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"post": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["AI Providers"],
"summary": "Create an AI provider",
"operationId": "create-an-ai-provider",
"parameters": [
{
"description": "Create AI provider request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateAIProviderRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.AIProvider"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/ai/providers/{idOrName}": {
"get": {
"produces": ["application/json"],
"tags": ["AI Providers"],
"summary": "Get an AI provider",
"operationId": "get-an-ai-provider",
"parameters": [
{
"type": "string",
"description": "Provider ID or name",
"name": "idOrName",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.AIProvider"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"delete": {
"tags": ["AI Providers"],
"summary": "Delete an AI provider",
"operationId": "delete-an-ai-provider",
"parameters": [
{
"type": "string",
"description": "Provider ID or name",
"name": "idOrName",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"patch": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["AI Providers"],
"summary": "Update an AI provider",
"operationId": "update-an-ai-provider",
"parameters": [
{
"type": "string",
"description": "Provider ID or name",
"name": "idOrName",
"in": "path",
"required": true
},
{
"description": "Update AI provider request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateAIProviderRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.AIProvider"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/aibridge/clients": {
"get": {
"produces": ["application/json"],
@@ -13157,6 +13304,47 @@
}
}
},
"codersdk.AIProvider": {
"type": "object",
"properties": {
"api_keys": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIProviderKey"
}
},
"base_url": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"settings": {
"$ref": "#/definitions/codersdk.AIProviderSettings"
},
"type": {
"$ref": "#/definitions/codersdk.AIProviderType"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"codersdk.AIProviderConfig": {
"type": "object",
"properties": {
@@ -13187,6 +13375,60 @@
}
}
},
"codersdk.AIProviderKey": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"masked": {
"type": "string"
}
}
},
"codersdk.AIProviderKeyMutation": {
"type": "object",
"properties": {
"api_key": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.AIProviderSettings": {
"type": "object"
},
"codersdk.AIProviderType": {
"type": "string",
"enum": [
"openai",
"anthropic",
"azure",
"google",
"openai-compat",
"openrouter",
"vercel",
"bedrock"
],
"x-enum-varnames": [
"AIProviderTypeOpenAI",
"AIProviderTypeAnthropic",
"AIProviderTypeAzure",
"AIProviderTypeGoogle",
"AIProviderTypeOpenAICompat",
"AIProviderTypeOpenrouter",
"AIProviderTypeVercel",
"AIProviderTypeBedrock"
]
},
"codersdk.APIAllowListTarget": {
"type": "object",
"properties": {
@@ -15372,6 +15614,35 @@
}
}
},
"codersdk.CreateAIProviderRequest": {
"type": "object",
"properties": {
"api_keys": {
"type": "array",
"items": {
"type": "string"
}
},
"base_url": {
"type": "string"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"settings": {
"$ref": "#/definitions/codersdk.AIProviderSettings"
},
"type": {
"$ref": "#/definitions/codersdk.AIProviderType"
}
}
},
"codersdk.CreateChatMessageRequest": {
"type": "object",
"properties": {
@@ -21739,6 +22010,29 @@
}
}
},
"codersdk.UpdateAIProviderRequest": {
"type": "object",
"properties": {
"api_keys": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIProviderKeyMutation"
}
},
"base_url": {
"type": "string"
},
"display_name": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"settings": {
"$ref": "#/definitions/codersdk.AIProviderSettings"
}
}
},
"codersdk.UpdateActiveTemplateVersion": {
"type": "object",
"required": ["id"],
-1
View File
@@ -35,7 +35,6 @@ type Auditable interface {
database.TaskTable |
database.AiSeatState |
database.AIProvider |
database.AIProviderKey |
database.Chat |
database.AuditableGroupAiBudget |
database.UserSecret |
-12
View File
@@ -136,11 +136,6 @@ func ResourceTarget[T Auditable](tgt T) string {
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.AuditableGroupAiBudget:
return typed.GroupName
case database.Chat:
@@ -223,8 +218,6 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.UserID
case database.AIProvider:
return typed.ID
case database.AIProviderKey:
return typed.ID
case database.AuditableGroupAiBudget:
return typed.GroupID
case database.Chat:
@@ -292,8 +285,6 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeAiSeat
case database.AIProvider:
return database.ResourceTypeAiProvider
case database.AIProviderKey:
return database.ResourceTypeAiProviderKey
case database.AuditableGroupAiBudget:
return database.ResourceTypeGroupAiBudget
case database.Chat:
@@ -365,9 +356,6 @@ func ResourceRequiresOrgID[T Auditable]() bool {
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.AuditableGroupAiBudget:
// Group AI budgets are org-scoped through their parent group.
return true
+1
View File
@@ -2006,6 +2006,7 @@ func New(options *Options) *API {
r.Route("/init-script", func(r chi.Router) {
r.Get("/{os}/{arch}", api.initScript)
})
r.Route("/ai/providers", aiProvidersHandler(api, apiKeyMiddleware))
r.Route("/tasks", func(r chi.Router) {
r.Use(apiKeyMiddleware)
+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 (