From dd3223451bda18333fd25010f521894371f3ddb8 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 20 May 2026 10:21:36 +0200 Subject: [PATCH] feat: add AI providers HTTP CRUD handlers (#24894) --- coderd/ai_providers.go | 642 +++++++++ coderd/ai_providers_test.go | 1182 +++++++++++++++++ coderd/apidoc/docs.go | 316 +++++ coderd/apidoc/swagger.json | 294 ++++ coderd/audit/diff.go | 1 - coderd/audit/request.go | 12 - coderd/coderd.go | 1 + coderd/database/db2sdk/db2sdk.go | 75 ++ coderd/database/dbauthz/dbauthz.go | 12 +- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbmetrics/querymetrics.go | 4 +- coderd/database/dbmock/dbmock.go | 8 +- .../fixtures/000495_ai_providers.up.sql | 2 +- coderd/database/querier.go | 10 +- coderd/database/queries.sql.go | 23 +- coderd/database/queries/ai_provider_keys.sql | 19 +- codersdk/aiproviders.go | 249 +++- codersdk/aiproviders_bedrock.go | 9 +- codersdk/aiproviders_test.go | 9 +- docs/admin/security/audit-logs.md | 1 - docs/manifest.json | 4 + docs/reference/api/aiproviders.md | 294 ++++ docs/reference/api/schemas.md | 151 +++ enterprise/audit/table.go | 8 - enterprise/dbcrypt/cliutil.go | 4 +- enterprise/dbcrypt/dbcrypt.go | 14 +- site/src/api/typesGenerated.ts | 62 +- 27 files changed, 3260 insertions(+), 150 deletions(-) create mode 100644 coderd/ai_providers.go create mode 100644 coderd/ai_providers_test.go create mode 100644 docs/reference/api/aiproviders.md diff --git a/coderd/ai_providers.go b/coderd/ai_providers.go new file mode 100644 index 0000000000..ef41143695 --- /dev/null +++ b/coderd/ai_providers.go @@ -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} +} diff --git a/coderd/ai_providers_test.go b/coderd/ai_providers_test.go new file mode 100644 index 0000000000..3e855d1bf5 --- /dev/null +++ b/coderd/ai_providers_test.go @@ -0,0 +1,1182 @@ +package coderd_test + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// keyIDs extracts the IDs from a slice of AIProviderKey responses, in +// order, to make assertions on key-set membership easier to read. +func keyIDs(keys []codersdk.AIProviderKey) []uuid.UUID { + out := make([]uuid.UUID, len(keys)) + for i, k := range keys { + out[i] = k.ID + } + return out +} + +func TestAIProvidersCRUD(t *testing.T) { + t.Parallel() + + t.Run("EmptyList", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + //nolint:gocritic // Owner role is the audience for this endpoint. + got, err := client.AIProviders(ctx) + require.NoError(t, err) + require.Empty(t, got) + }) + + t.Run("CreateGetUpdateDelete", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create. + req := codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeAnthropic, + Name: "primary-anthropic", + DisplayName: "Primary Anthropic", + Enabled: true, + BaseURL: "https://api.anthropic.com/", + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + }, + }, + } + //nolint:gocritic // Owner role is the audience for this endpoint. + created, err := client.CreateAIProvider(ctx, req) + require.NoError(t, err) + require.NotEqual(t, [16]byte{}, created.ID) + require.Equal(t, req.Type, created.Type) + require.Equal(t, req.Name, created.Name) + require.Equal(t, req.DisplayName, created.DisplayName) + require.Equal(t, req.Enabled, created.Enabled) + require.Equal(t, req.BaseURL, created.BaseURL) + require.NotNil(t, created.Settings.Bedrock) + require.Equal(t, req.Settings.Bedrock.Region, created.Settings.Bedrock.Region) + + // Get by ID. + gotByID, err := client.AIProvider(ctx, created.ID.String()) + require.NoError(t, err) + require.Equal(t, created.ID, gotByID.ID) + + // Get by name. + gotByName, err := client.AIProvider(ctx, created.Name) + require.NoError(t, err) + require.Equal(t, created.ID, gotByName.ID) + + // List. + list, err := client.AIProviders(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, created.ID, list[0].ID) + + // Update. + newDisplay := "Updated Display" + newURL := "https://api.anthropic.com/v1" + disabled := false + updated, err := client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + DisplayName: &newDisplay, + BaseURL: &newURL, + Enabled: &disabled, + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-west-2", + Model: "anthropic.claude-3-5-sonnet", + }, + }, + }) + require.NoError(t, err) + require.Equal(t, newDisplay, updated.DisplayName) + require.Equal(t, newURL, updated.BaseURL) + require.False(t, updated.Enabled) + require.NotNil(t, updated.Settings.Bedrock) + require.Equal(t, "us-west-2", updated.Settings.Bedrock.Region) + require.Equal(t, "anthropic.claude-3-5-sonnet", updated.Settings.Bedrock.Model) + + // Delete. + err = client.DeleteAIProvider(ctx, created.ID.String()) + require.NoError(t, err) + + // Subsequent get returns 404. + _, err = client.AIProvider(ctx, created.ID.String()) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Resource not found") + + // List excludes the deleted provider. + list, err = client.AIProviders(ctx) + require.NoError(t, err) + require.Empty(t, list) + + // Soft-deleted rows do not block name reuse: the unique index + // is partial on deleted = FALSE, so re-creating the same name + // succeeds and produces a new row with a different id. + recreated, err := client.CreateAIProvider(ctx, req) + require.NoError(t, err) + require.NotEqual(t, created.ID, recreated.ID) + require.Equal(t, req.Name, recreated.Name) + }) + + t.Run("DefaultDisplayName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "no-display", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.NoError(t, err) + // Server falls back to Name when DisplayName is empty. + require.Equal(t, "no-display", created.DisplayName) + }) + + t.Run("DuplicateNameConflict", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + req := codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "duplicate", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + } + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, req) + require.NoError(t, err) + _, err = client.CreateAIProvider(ctx, req) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusConflict, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, `"duplicate"`) + require.Contains(t, sdkErr.Message, "already exists") + }) + + t.Run("InvalidName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Invalid character in name. + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "Bad_Name", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "name", sdkErr.Validations[0].Field) + }) + + t.Run("InvalidType", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: "nope", + Name: "nope", + Enabled: true, + BaseURL: "https://api.example.com", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "type", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, `"nope"`) + }) + + t.Run("InvalidBaseURL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "bad-url", + Enabled: true, + BaseURL: "not-a-url", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "base_url", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "absolute URL") + + _, err = client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "bad-scheme", + Enabled: true, + BaseURL: "ftp://api.example.com", + }) + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "base_url", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "http or https") + }) + + t.Run("UpdateNoFields", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "patchable", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.NoError(t, err) + + _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{}) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "At least one field must be provided") + }) + + t.Run("UpdateSettingsEmptyObjectRejected", func(t *testing.T) { + t.Parallel() + // "settings": {} cannot decode because the _type discriminator + // is missing. The handler must reject with 400; nothing about + // the provider should change. + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "patch-settings-empty", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.NoError(t, err) + + res, err := client.Request(ctx, http.MethodPatch, + "/api/v2/ai/providers/"+created.Name, + json.RawMessage(`{"settings":{}}`), + ) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) + var body codersdk.Response + require.NoError(t, json.NewDecoder(res.Body).Decode(&body)) + require.Contains(t, body.Message, "valid JSON") + require.Contains(t, body.Detail, "_type discriminator") + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.AIProvider(ctx, "missing") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Resource not found") + + err = client.DeleteAIProvider(ctx, "missing") + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Resource not found") + }) + + t.Run("ListExcludesDeletedProviderKeys", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // A soft-deleted provider's keys must not bleed into the list + // response. Create one provider, delete it, then create a + // second; the list should only contain the live one with its + // own keys. + //nolint:gocritic // Owner role is the audience for this endpoint. + deleted, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "list-deleted", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-deleted-qqqqqqqqqqqqqqqqqq"}, //nolint:gosec // test fixture + }) + require.NoError(t, err) + err = client.DeleteAIProvider(ctx, deleted.ID.String()) + require.NoError(t, err) + + live, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "list-live", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-live-rrrrrrrrrrrrrrrrrr"}, //nolint:gosec // test fixture + }) + require.NoError(t, err) + + list, err := client.AIProviders(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, live.ID, list[0].ID) + require.Len(t, list[0].APIKeys, 1) + require.Equal(t, live.APIKeys[0].ID, list[0].APIKeys[0].ID) + }) + + t.Run("LookupInvalidName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // A string that is neither a UUID nor a syntactically-valid + // provider name must surface a 400, not a misleading 404. + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.AIProvider(ctx, "Bad_Name") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid provider id or name") + + err = client.DeleteAIProvider(ctx, "Bad_Name") + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid provider id or name") + }) + + t.Run("Unauthenticated", func(t *testing.T) { + t.Parallel() + ownerClient := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, ownerClient) + ctx := testutil.Context(t, testutil.WaitLong) + + anon := codersdk.New(ownerClient.URL) + _, err := anon.AIProviders(ctx) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) + require.NotEmpty(t, sdkErr.Message) + }) + + t.Run("BedrockSettingsRequireAnthropic", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create: OpenAI-typed provider with Bedrock settings is a type + // mismatch and must be rejected so the runtime never silently + // drops the operator's authentication intent. + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "bedrock-on-openai", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + AccessKey: ptr.Ref("AKIA-fixture"), //nolint:gosec // test fixture + AccessKeySecret: ptr.Ref("bedrock-fixture"), //nolint:gosec // test fixture + }, + }, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.NotEmpty(t, sdkErr.Validations) + require.Equal(t, "settings", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "bedrock settings are only valid for type=anthropic") + + // Update: existing OpenAI provider patched with Bedrock settings + // must also be rejected. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "openai-then-bedrock", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.NoError(t, err) + _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{Region: "us-east-1"}, + }, + }) + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Bedrock settings are only valid for type=anthropic") + }) + + t.Run("BedrockSecretsHidden", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Bedrock providers carry their AWS access key + secret inside the + // encrypted settings blob. The response never echoes those fields + // back, so callers cannot recover them after creation. + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeAnthropic, + Name: "bedrock-secret-leak", + Enabled: true, + BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + Model: "anthropic.claude-3-5-sonnet", + AccessKey: ptr.Ref("AKIA-leak"), //nolint:gosec // test fixture, not a real credential + AccessKeySecret: ptr.Ref("bedrock-supersecret"), + }, + }, + }) + require.NoError(t, err) + + res, err := client.Request(ctx, http.MethodGet, "/api/v2/ai/providers/bedrock-secret-leak", nil) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + bodyBytes, err := io.ReadAll(res.Body) + require.NoError(t, err) + body := string(bodyBytes) + require.NotContains(t, body, "AKIA-leak") + require.NotContains(t, body, "bedrock-supersecret") + require.NotContains(t, body, `"access_key"`) + require.NotContains(t, body, `"access_key_secret"`) + }) +} + +func TestAIProvidersKeyManagement(t *testing.T) { + t.Parallel() + + t.Run("CreateWithKeysReturnsMasked", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + const ( + primary = "sk-openai-primary-fixture-aaaaaa" //nolint:gosec // test fixture, not a real credential + secondary = "sk-openai-secondary-fixture-bbbbbb" //nolint:gosec // test fixture, not a real credential + ) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-openai", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{primary, secondary}, + }) + require.NoError(t, err) + require.Len(t, provider.APIKeys, 2) + // Masked form preserves prefix and suffix while hiding the + // middle, so it's enough for an operator to recognize the key + // without recovering the plaintext. + require.True(t, strings.HasPrefix(provider.APIKeys[0].Masked, "sk-o")) + require.True(t, strings.HasSuffix(provider.APIKeys[0].Masked, "aaaa")) + require.NotContains(t, provider.APIKeys[0].Masked, primary) + require.NotContains(t, provider.APIKeys[1].Masked, secondary) + require.NotEqual(t, uuid.Nil, provider.APIKeys[0].ID) + require.NotEqual(t, uuid.Nil, provider.APIKeys[1].ID) + }) + + t.Run("ResponseHidesPlaintext", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + const plaintext = "sk-openai-extra-secret-cccccccccccc" //nolint:gosec // test fixture, not a real credential + + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-secret", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{plaintext}, + }) + require.NoError(t, err) + + // Inspect the raw HTTP body of the GET response. The masked + // form must replace the plaintext entirely on the wire. + res, err := client.Request(ctx, http.MethodGet, "/api/v2/ai/providers/keys-secret", nil) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + bodyBytes, err := io.ReadAll(res.Body) + require.NoError(t, err) + require.NotContains(t, string(bodyBytes), plaintext) + }) + + t.Run("UpdateReplacesKeys", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-replace", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-original-ddddddddddddddd"}, //nolint:gosec // test fixture, not a real credential + }) + require.NoError(t, err) + require.Len(t, provider.APIKeys, 1) + originalID := provider.APIKeys[0].ID + + // Omitting the original ID from the mutation list deletes it; + // the two APIKey-bearing entries add fresh rows. + replacement := []codersdk.AIProviderKeyMutation{ + {APIKey: ptr.Ref("sk-openai-rotated-eeeeeeeeeeeeeeeeeee")}, //nolint:gosec // test fixture + {APIKey: ptr.Ref("sk-openai-rotated-second-ffffffffffffffff")}, //nolint:gosec // test fixture + } + updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &replacement, + }) + require.NoError(t, err) + require.Len(t, updated.APIKeys, 2) + for _, k := range updated.APIKeys { + require.NotEqual(t, originalID, k.ID) + } + }) + + t.Run("UpdateKeepsExistingByID", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-keep-by-id", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{ + "sk-openai-keep-aaaaaaaaaaaaaaaaaaaaaa", //nolint:gosec // test fixture + "sk-openai-evict-bbbbbbbbbbbbbbbbbbbbbb", //nolint:gosec // test fixture + }, + }) + require.NoError(t, err) + require.Len(t, provider.APIKeys, 2) + keepID := provider.APIKeys[0].ID + keepMasked := provider.APIKeys[0].Masked + evictID := provider.APIKeys[1].ID + + // Reference only keepID and add one new plaintext: evictID is + // implicitly removed. + patch := []codersdk.AIProviderKeyMutation{ + {ID: &keepID}, + {APIKey: ptr.Ref("sk-openai-added-cccccccccccccccccccccc")}, //nolint:gosec // test fixture + } + updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &patch, + }) + require.NoError(t, err) + require.Len(t, updated.APIKeys, 2) + ids := keyIDs(updated.APIKeys) + require.Contains(t, ids, keepID) + require.NotContains(t, ids, evictID) + // The kept key's masked value is unchanged. + for _, k := range updated.APIKeys { + if k.ID == keepID { + require.Equal(t, keepMasked, k.Masked) + } + } + }) + + t.Run("UpdateClearsKeys", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-clear", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-tobedeleted-gggggggggggggg"}, //nolint:gosec // test fixture, not a real credential + }) + require.NoError(t, err) + require.Len(t, provider.APIKeys, 1) + + empty := []codersdk.AIProviderKeyMutation{} + updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &empty, + }) + require.NoError(t, err) + require.Empty(t, updated.APIKeys) + }) + + t.Run("UpdateKeepOnlyIsNoOp", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-keeponly", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{ + "sk-openai-stay-1-iiiiiiiiiiiiiiiiiiii", //nolint:gosec // test fixture + "sk-openai-stay-2-jjjjjjjjjjjjjjjjjjjj", //nolint:gosec // test fixture + }, + }) + require.NoError(t, err) + require.Len(t, provider.APIKeys, 2) + originalIDs := keyIDs(provider.APIKeys) + + mutations := []codersdk.AIProviderKeyMutation{ + {ID: &provider.APIKeys[0].ID}, + {ID: &provider.APIKeys[1].ID}, + } + updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &mutations, + }) + require.NoError(t, err) + require.ElementsMatch(t, originalIDs, keyIDs(updated.APIKeys)) + }) + + t.Run("UpdateWithoutKeysPreserves", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-preserve", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-keepme-hhhhhhhhhhhhhhhh"}, //nolint:gosec // test fixture, not a real credential + }) + require.NoError(t, err) + require.Len(t, provider.APIKeys, 1) + original := provider.APIKeys[0] + + // PATCH with no APIKeys field must leave keys untouched. + newDisplay := "Keep Display" + updated, err := client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + DisplayName: &newDisplay, + }) + require.NoError(t, err) + require.Len(t, updated.APIKeys, 1) + require.Equal(t, original.ID, updated.APIKeys[0].ID) + require.Equal(t, original.Masked, updated.APIKeys[0].Masked) + }) + + t.Run("BedrockRejectsCreateWithKeys", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Bedrock providers authenticate via the settings blob (AWS + // access key + secret), so an api_keys list would be silently + // unused. + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeAnthropic, + Name: "keys-bedrock-create", + Enabled: true, + BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", + APIKeys: []string{"sk-should-be-rejected"}, //nolint:gosec // test fixture, not a real credential + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + Model: "anthropic.claude-3-5-sonnet", + AccessKey: ptr.Ref("AKIA-test"), //nolint:gosec // test fixture, not a real credential + AccessKeySecret: ptr.Ref("bedrock-test-secret"), + }, + }, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Bedrock providers do not accept api_keys") + }) + + t.Run("BedrockRejectsUpdateWithKeys", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeAnthropic, + Name: "keys-bedrock-update", + Enabled: true, + BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + Model: "anthropic.claude-3-5-sonnet", + AccessKey: ptr.Ref("AKIA-test"), //nolint:gosec // test fixture, not a real credential + AccessKeySecret: ptr.Ref("bedrock-test-secret"), + }, + }, + }) + require.NoError(t, err) + + rejected := []codersdk.AIProviderKeyMutation{ + {APIKey: ptr.Ref("sk-bedrock-no")}, //nolint:gosec // test fixture, not a real credential + } + _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &rejected, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Bedrock providers do not accept api_keys") + }) + + t.Run("EmptyKeyRejected", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-empty-element", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{""}, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "must not be empty") + }) + + t.Run("WhitespaceKeyRejected", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Surrounding whitespace would silently break upstream auth, + // since the server stores credentials verbatim. Reject up-front + // so the operator gets a clear signal instead of a 401 later. + //nolint:gocritic // Owner role is the audience for this endpoint. + _, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-whitespace-create", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{" sk-openai-padded-nnnnnnnnnnnnnnnnnnnn "}, //nolint:gosec // test fixture + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-whitespace-update", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-clean-oooooooooooooooooooo"}, //nolint:gosec // test fixture + }) + require.NoError(t, err) + padded := " sk-openai-padded-pppppppppppppppppppp " + muts := []codersdk.AIProviderKeyMutation{{APIKey: &padded}} + _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &muts, + }) + require.Error(t, err) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("NonOwnerForbidden", func(t *testing.T) { + t.Parallel() + ownerClient := coderdtest.New(t, nil) + firstUser := coderdtest.CreateFirstUser(t, ownerClient) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := ownerClient.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-owner-only", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.NoError(t, err) + + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID) + + patch := []codersdk.AIProviderKeyMutation{ + {APIKey: ptr.Ref("sk-not-allowed")}, //nolint:gosec // test fixture, not a real credential + } + _, err = memberClient.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &patch, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + t.Run("MutationBothFieldsRejected", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-mut-both", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-existing-kkkkkkkkkkkkkkkk"}, //nolint:gosec // test fixture + }) + require.NoError(t, err) + existingID := provider.APIKeys[0].ID + + muts := []codersdk.AIProviderKeyMutation{ + {ID: &existingID, APIKey: ptr.Ref("sk-conflict")}, //nolint:gosec // test fixture + } + _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &muts, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "exactly one of id or api_key must be set") + }) + + t.Run("MutationNeitherFieldRejected", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-mut-empty", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + }) + require.NoError(t, err) + + muts := []codersdk.AIProviderKeyMutation{{}} + _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &muts, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "api_keys[0]", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "exactly one of id or api_key must be set") + }) + + t.Run("MutationDuplicateIDRejected", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-mut-dup", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-dup-llllllllllllllllllll"}, //nolint:gosec // test fixture + }) + require.NoError(t, err) + id := provider.APIKeys[0].ID + + muts := []codersdk.AIProviderKeyMutation{ + {ID: &id}, + {ID: &id}, + } + _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &muts, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "Invalid AI provider request") + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "api_keys[1].id", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "already referenced") + }) + + t.Run("MutationUnknownIDRejected", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeOpenAI, + Name: "keys-mut-unknown", + Enabled: true, + BaseURL: "https://api.openai.com/v1", + APIKeys: []string{"sk-openai-real-mmmmmmmmmmmmmmmmmmmm"}, //nolint:gosec // test fixture + }) + require.NoError(t, err) + + bogus := uuid.New() + muts := []codersdk.AIProviderKeyMutation{{ID: &bogus}} + _, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{ + APIKeys: &muts, + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "api_keys references an unknown id for this provider") + + // Provider's real key is left untouched. + reread, err := client.AIProvider(ctx, provider.Name) + require.NoError(t, err) + require.Len(t, reread.APIKeys, 1) + require.Equal(t, provider.APIKeys[0].ID, reread.APIKeys[0].ID) + }) +} + +// TestAIProviderSettingsMerge exercises the PATCH merge semantics for +// the write-only Bedrock secrets through a real HTTP client. Because +// the API never echoes AccessKey or AccessKeySecret back, each +// subtest reads the provider row directly from the database to +// confirm what the merge actually persisted. +func TestAIProviderSettingsMerge(t *testing.T) { + t.Parallel() + + t.Run("OmittedSecretsPreserveExisting", func(t *testing.T) { + t.Parallel() + // A PATCH that only rotates non-secret fields must keep the + // existing AccessKey and AccessKeySecret intact so the provider + // keeps authenticating after the update. + client, db := coderdtest.NewWithDatabase(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeAnthropic, + Name: "merge-omit", + Enabled: true, + BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + Model: "anthropic.claude-3-5-sonnet", + AccessKey: ptr.Ref("AKIA-old"), //nolint:gosec // test fixture, not a real credential + AccessKeySecret: ptr.Ref("secret-old"), + }, + }, + }) + require.NoError(t, err) + + _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-west-2", + Model: "anthropic.claude-3-5-haiku", + }, + }, + }) + require.NoError(t, err) + + //nolint:gocritic // Test reads the row to verify write-only fields. + row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID) + require.NoError(t, err) + persisted, err := db2sdk.AIProviderSettings(row.Settings) + require.NoError(t, err) + require.NotNil(t, persisted.Bedrock) + require.Equal(t, "us-west-2", persisted.Bedrock.Region) + require.Equal(t, "anthropic.claude-3-5-haiku", persisted.Bedrock.Model) + require.NotNil(t, persisted.Bedrock.AccessKey) + require.Equal(t, "AKIA-old", *persisted.Bedrock.AccessKey) + require.NotNil(t, persisted.Bedrock.AccessKeySecret) + require.Equal(t, "secret-old", *persisted.Bedrock.AccessKeySecret) + }) + + t.Run("ExplicitEmptyClearsSecrets", func(t *testing.T) { + t.Parallel() + // An admin migrating from static AWS credentials to IAM + // role-based auth needs to clear AccessKey and AccessKeySecret + // in a single PATCH. Sending the field with an empty string is + // the explicit clear signal; the *string field distinguishes + // "omitted" (nil) from "set to empty" (pointer to ""). + client, db := coderdtest.NewWithDatabase(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeAnthropic, + Name: "merge-clear", + Enabled: true, + BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + AccessKey: ptr.Ref("AKIA-old"), //nolint:gosec // test fixture, not a real credential + AccessKeySecret: ptr.Ref("secret-old"), + }, + }, + }) + require.NoError(t, err) + + _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + AccessKey: ptr.Ref(""), + AccessKeySecret: ptr.Ref(""), + }, + }, + }) + require.NoError(t, err) + + //nolint:gocritic // Test reads the row to verify write-only fields. + row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID) + require.NoError(t, err) + persisted, err := db2sdk.AIProviderSettings(row.Settings) + require.NoError(t, err) + require.NotNil(t, persisted.Bedrock) + require.NotNil(t, persisted.Bedrock.AccessKey) + require.Equal(t, "", *persisted.Bedrock.AccessKey) + require.NotNil(t, persisted.Bedrock.AccessKeySecret) + require.Equal(t, "", *persisted.Bedrock.AccessKeySecret) + }) + + t.Run("ExplicitRotatesSecrets", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + //nolint:gocritic // Owner role is the audience for this endpoint. + created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ + Type: codersdk.AIProviderTypeAnthropic, + Name: "merge-rotate", + Enabled: true, + BaseURL: "https://bedrock-runtime.us-east-1.amazonaws.com/", + Settings: codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + AccessKey: ptr.Ref("AKIA-old"), //nolint:gosec // test fixture, not a real credential + AccessKeySecret: ptr.Ref("secret-old"), + }, + }, + }) + require.NoError(t, err) + + _, err = client.UpdateAIProvider(ctx, created.Name, codersdk.UpdateAIProviderRequest{ + Settings: &codersdk.AIProviderSettings{ + Bedrock: &codersdk.AIProviderBedrockSettings{ + Region: "us-east-1", + AccessKey: ptr.Ref("AKIA-new"), //nolint:gosec // test fixture, not a real credential + AccessKeySecret: ptr.Ref("secret-new"), + }, + }, + }) + require.NoError(t, err) + + //nolint:gocritic // Test reads the row to verify write-only fields. + row, err := db.GetAIProviderByID(dbauthz.AsSystemRestricted(ctx), created.ID) + require.NoError(t, err) + persisted, err := db2sdk.AIProviderSettings(row.Settings) + require.NoError(t, err) + require.NotNil(t, persisted.Bedrock) + require.NotNil(t, persisted.Bedrock.AccessKey) + require.Equal(t, "AKIA-new", *persisted.Bedrock.AccessKey) + require.NotNil(t, persisted.Bedrock.AccessKeySecret) + require.Equal(t, "secret-new", *persisted.Bedrock.AccessKeySecret) + }) +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 670fbbee31..38bc8348a4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d35fbb4f82..a51fe7a59b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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"], diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index a26924552b..0b85b0e700 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -35,7 +35,6 @@ type Auditable interface { database.TaskTable | database.AiSeatState | database.AIProvider | - database.AIProviderKey | database.Chat | database.AuditableGroupAiBudget | database.UserSecret | diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 0f99fe4d6a..396f7b15bb 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -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 diff --git a/coderd/coderd.go b/coderd/coderd.go index 4d51087c0f..a72f7f290b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 0f9fcf4380..e324b8dd41 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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 diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9367e19f5b..f8733b410a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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) { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 52bab5bc8a..22fb3902f7 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 03f452bdf9..2e8cb7c306 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -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 diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e108093376..46e61d8cbb 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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. diff --git a/coderd/database/migrations/testdata/fixtures/000495_ai_providers.up.sql b/coderd/database/migrations/testdata/fixtures/000495_ai_providers.up.sql index 59df6d793e..8da3e7cbdc 100644 --- a/coderd/database/migrations/testdata/fixtures/000495_ai_providers.up.sql +++ b/coderd/database/migrations/testdata/fixtures/000495_ai_providers.up.sql @@ -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', diff --git a/coderd/database/querier.go b/coderd/database/querier.go index aa4e556042..274c8eb179 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2c49a960de..5fbc873c70 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/ai_provider_keys.sql b/coderd/database/queries/ai_provider_keys.sql index 018c434ef6..1d152d013a 100644 --- a/coderd/database/queries/ai_provider_keys.sql +++ b/coderd/database/queries/ai_provider_keys.sql @@ -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 ( diff --git a/codersdk/aiproviders.go b/codersdk/aiproviders.go index 75d4639f88..e3da8ee97e 100644 --- a/codersdk/aiproviders.go +++ b/codersdk/aiproviders.go @@ -6,12 +6,20 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "regexp" + "strings" "time" "github.com/google/uuid" "golang.org/x/xerrors" ) +// AIProviderNameRegex mirrors the CHECK constraint on ai_providers.name. +// Provider names are lowercase alphanumeric with hyphen separators so +// they are safe in URLs. +var AIProviderNameRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + // AIProviderType identifies the protocol Coder uses to communicate // with an upstream AI provider. type AIProviderType string @@ -147,8 +155,9 @@ func marshalSettings(s settingsTyped) ([]byte, error) { } // AIProvider represents an AI provider configuration row as returned -// by the API. API keys are stored in a separate ai_provider_keys -// table and managed via the keys sub-endpoints; secret fields on +// by the API. Each APIKey entry carries the row's ID so callers can +// reference it in an UpdateAIProviderRequest; the plaintext value is +// never echoed back (see AIProviderKey.Masked). Secret fields on // Settings are never included in responses. type AIProvider struct { ID uuid.UUID `json:"id" format:"uuid"` @@ -157,52 +166,214 @@ type AIProvider struct { DisplayName string `json:"display_name"` Enabled bool `json:"enabled"` BaseURL string `json:"base_url"` + APIKeys []AIProviderKey `json:"api_keys"` Settings AIProviderSettings `json:"settings"` CreatedAt time.Time `json:"created_at" format:"date-time"` UpdatedAt time.Time `json:"updated_at" format:"date-time"` } +// AIProviderKey is a single API key registered on a provider. The +// plaintext is never returned; Masked is a one-way rendering safe for +// display (see aibridge utils MaskSecret). ID lets clients reference +// the row in an UpdateAIProviderRequest without re-sending plaintext. +type AIProviderKey struct { + ID uuid.UUID `json:"id" format:"uuid"` + Masked string `json:"masked"` + CreatedAt time.Time `json:"created_at" format:"date-time"` +} + // CreateAIProviderRequest is the payload for creating a new AI -// provider. Name, Type, and BaseURL are required. API keys for -// OpenAI/Anthropic providers are added via the keys sub-endpoint -// after the provider is created; Bedrock providers carry their -// credentials in Settings and do not use the keys sub-endpoint. +// provider. Name, Type, and BaseURL are required. APIKeys carries +// the plaintext keys for OpenAI/Anthropic providers; Bedrock +// providers authenticate via Settings and must omit APIKeys. type CreateAIProviderRequest struct { Type AIProviderType `json:"type"` Name string `json:"name"` DisplayName string `json:"display_name,omitempty"` Enabled bool `json:"enabled"` BaseURL string `json:"base_url"` + APIKeys []string `json:"api_keys,omitempty"` Settings AIProviderSettings `json:"settings,omitzero"` } +// Validate returns the field-level validation errors for a create +// request. An empty slice indicates the request is valid. +func (req CreateAIProviderRequest) Validate() []ValidationError { + var validations []ValidationError + switch req.Type { + case AIProviderTypeOpenAI, AIProviderTypeAnthropic: + case "": + validations = append(validations, ValidationError{Field: "type", Detail: "type is required"}) + default: + validations = append(validations, ValidationError{ + Field: "type", + Detail: fmt.Sprintf("unsupported provider type %q; expected one of: openai, anthropic", req.Type), + }) + } + validations = append(validations, validateAIProviderName(req.Name)...) + if req.BaseURL == "" { + validations = append(validations, ValidationError{Field: "base_url", Detail: "base_url is required"}) + } else { + validations = append(validations, validateAIProviderBaseURL(req.BaseURL)...) + } + validations = append(validations, validateAIProviderAPIKeys(req.APIKeys)...) + if req.Settings.Bedrock != nil && req.Type != AIProviderTypeAnthropic { + validations = append(validations, ValidationError{ + Field: "settings", + Detail: "bedrock settings are only valid for type=anthropic", + }) + } + return validations +} + // UpdateAIProviderRequest is the payload for partially updating an // AI provider. At least one field must be non-nil. Pointer fields // distinguish "not sent" (nil) from "set to empty/zero" (a pointer -// to the zero value). +// to the zero value). When APIKeys is non-nil, the supplied list +// describes the post-patch state of the key set; see +// AIProviderKeyMutation for the per-entry semantics. An empty slice +// clears all keys. type UpdateAIProviderRequest struct { - DisplayName *string `json:"display_name,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - BaseURL *string `json:"base_url,omitempty"` - Settings *AIProviderSettings `json:"settings,omitempty"` + DisplayName *string `json:"display_name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + BaseURL *string `json:"base_url,omitempty"` + APIKeys *[]AIProviderKeyMutation `json:"api_keys,omitempty"` + Settings *AIProviderSettings `json:"settings,omitempty"` } -// AIProviderKey represents a single API key registered against an -// AI provider, as returned by the API. The plaintext APIKey is -// write-only and never included in responses. -type AIProviderKey struct { - ID uuid.UUID `json:"id" format:"uuid"` - ProviderID uuid.UUID `json:"provider_id" format:"uuid"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` +// AIProviderKeyMutation describes the intended state of a single key +// in an UpdateAIProviderRequest. Exactly one of ID or APIKey must be +// set: +// +// - ID set, APIKey nil: keep this existing key (matched by ID). +// - ID nil, APIKey set: insert this new plaintext as a new key. +// +// Any existing key whose ID is absent from the request is deleted. +type AIProviderKeyMutation struct { + ID *uuid.UUID `json:"id,omitempty" format:"uuid"` + APIKey *string `json:"api_key,omitempty"` } -// CreateAIProviderKeyRequest is the payload for adding an API key to -// an AI provider. Only meaningful for openai and anthropic providers; -// Bedrock providers reject this call because they use the access -// credentials stored in Settings. -type CreateAIProviderKeyRequest struct { - APIKey string `json:"api_key"` +// Validate returns the field-level validation errors for an update +// request. An empty slice indicates the request is valid. Callers +// should reject empty patches with IsEmpty before invoking Validate. +func (req UpdateAIProviderRequest) Validate() []ValidationError { + var validations []ValidationError + switch { + case req.BaseURL == nil: + case *req.BaseURL == "": + validations = append(validations, ValidationError{Field: "base_url", Detail: "base_url cannot be empty"}) + default: + validations = append(validations, validateAIProviderBaseURL(*req.BaseURL)...) + } + if req.APIKeys != nil { + validations = append(validations, validateAIProviderKeyMutations(*req.APIKeys)...) + } + return validations +} + +// IsEmpty reports whether the patch carries no fields. +func (req UpdateAIProviderRequest) IsEmpty() bool { + return req.DisplayName == nil && req.Enabled == nil && req.BaseURL == nil && req.APIKeys == nil && req.Settings == nil +} + +func validateAIProviderName(name string) []ValidationError { + var validations []ValidationError + switch { + case name == "": + validations = append(validations, ValidationError{Field: "name", Detail: "name is required"}) + case !AIProviderNameRegex.MatchString(name): + validations = append(validations, ValidationError{ + Field: "name", + Detail: fmt.Sprintf("name must match %s (lowercase alphanumeric, hyphens between words)", AIProviderNameRegex), + }) + } + return validations +} + +func validateAIProviderBaseURL(raw string) []ValidationError { + var validations []ValidationError + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + validations = append(validations, ValidationError{ + Field: "base_url", + Detail: "base_url must be an absolute URL (e.g. https://api.example.com/)", + }) + return validations + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + validations = append(validations, ValidationError{ + Field: "base_url", + Detail: fmt.Sprintf("base_url scheme must be http or https, got %q", parsed.Scheme), + }) + } + return validations +} + +// validateAIProviderAPIKeys checks that each supplied key is non-empty +// and free of leading/trailing whitespace. An empty slice itself is +// permitted: on create it means "no keys yet"; on update it means +// "clear all keys". Keys are stored verbatim; surrounding whitespace +// would silently corrupt the credential, so callers must trim before +// sending. +func validateAIProviderAPIKeys(keys []string) []ValidationError { + var validations []ValidationError + for i, key := range keys { + switch { + case key == "": + validations = append(validations, ValidationError{ + Field: fmt.Sprintf("api_keys[%d]", i), + Detail: "api_keys entries must not be empty", + }) + case strings.TrimSpace(key) != key: + validations = append(validations, ValidationError{ + Field: fmt.Sprintf("api_keys[%d]", i), + Detail: "api_keys entries must not contain leading or trailing whitespace", + }) + } + } + return validations +} + +// validateAIProviderKeyMutations checks each entry has exactly one of +// ID or APIKey set, that plaintexts are non-empty after trimming, and +// that no ID is referenced twice in the same request. An empty slice +// itself is permitted (it clears all keys). +func validateAIProviderKeyMutations(muts []AIProviderKeyMutation) []ValidationError { + var validations []ValidationError + seen := make(map[uuid.UUID]int, len(muts)) + for i, m := range muts { + hasID := m.ID != nil + hasKey := m.APIKey != nil + switch { + case hasID == hasKey: + validations = append(validations, ValidationError{ + Field: fmt.Sprintf("api_keys[%d]", i), + Detail: "exactly one of id or api_key must be set", + }) + case hasKey && *m.APIKey == "": + validations = append(validations, ValidationError{ + Field: fmt.Sprintf("api_keys[%d].api_key", i), + Detail: "api_key must not be empty", + }) + case hasKey && strings.TrimSpace(*m.APIKey) != *m.APIKey: + validations = append(validations, ValidationError{ + Field: fmt.Sprintf("api_keys[%d].api_key", i), + Detail: "api_key must not contain leading or trailing whitespace", + }) + } + if hasID && !hasKey { + if prev, ok := seen[*m.ID]; ok { + validations = append(validations, ValidationError{ + Field: fmt.Sprintf("api_keys[%d].id", i), + Detail: fmt.Sprintf("id %s already referenced at api_keys[%d]", *m.ID, prev), + }) + } else { + seen[*m.ID] = i + } + } + } + return validations } // AIProviders lists all (non-deleted) AI providers. @@ -275,31 +446,3 @@ func (c *Client) DeleteAIProvider(ctx context.Context, idOrName string) error { } return nil } - -// CreateAIProviderKey registers a new API key against an AI -// provider identified by ID or name. -func (c *Client) CreateAIProviderKey(ctx context.Context, idOrName string, req CreateAIProviderKeyRequest) (AIProviderKey, error) { - res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/ai/providers/%s/keys", idOrName), req) - if err != nil { - return AIProviderKey{}, err - } - defer res.Body.Close() - if res.StatusCode != http.StatusCreated { - return AIProviderKey{}, ReadBodyAsError(res) - } - var key AIProviderKey - return key, json.NewDecoder(res.Body).Decode(&key) -} - -// DeleteAIProviderKey removes a single API key from an AI provider. -func (c *Client) DeleteAIProviderKey(ctx context.Context, idOrName string, keyID uuid.UUID) error { - res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/ai/providers/%s/keys/%s", idOrName, keyID), nil) - if err != nil { - return err - } - defer res.Body.Close() - if res.StatusCode != http.StatusNoContent { - return ReadBodyAsError(res) - } - return nil -} diff --git a/codersdk/aiproviders_bedrock.go b/codersdk/aiproviders_bedrock.go index 003cd9a57b..6d7a028248 100644 --- a/codersdk/aiproviders_bedrock.go +++ b/codersdk/aiproviders_bedrock.go @@ -10,7 +10,10 @@ const AIProviderBedrockSettingsVersion = 1 // AIProviderBedrockSettings configures providers that authenticate // against AWS Bedrock. AccessKey and AccessKeySecret are write-only: -// servers strip them from GET and list responses. +// servers strip them from GET and list responses. Both secret fields +// use a pointer so a PATCH can distinguish "leave untouched" (omitted) +// from "explicitly clear" (empty string), e.g. when migrating to +// IAM role-based authentication. type AIProviderBedrockSettings struct { // Region is the AWS region used to construct the Bedrock endpoint // URL when BaseURL is not set on the parent provider. @@ -23,10 +26,10 @@ type AIProviderBedrockSettings struct { SmallFastModel string `json:"small_fast_model,omitempty"` // AccessKey is the AWS access key ID used to authenticate against // Bedrock. Write-only. - AccessKey string `json:"access_key,omitempty"` + AccessKey *string `json:"access_key,omitempty"` // AccessKeySecret is the AWS secret access key paired with // AccessKey. Write-only. - AccessKeySecret string `json:"access_key_secret,omitempty"` + AccessKeySecret *string `json:"access_key_secret,omitempty"` } func (AIProviderBedrockSettings) settingsType() string { diff --git a/codersdk/aiproviders_test.go b/codersdk/aiproviders_test.go index 596e607090..97baad6535 100644 --- a/codersdk/aiproviders_test.go +++ b/codersdk/aiproviders_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" ) @@ -27,8 +28,8 @@ func TestAIProviderSettings_Marshal(t *testing.T) { Region: "us-east-1", Model: "anthropic.claude-3-5-sonnet", SmallFastModel: "anthropic.claude-3-5-haiku", - AccessKey: "AKIA-test", //nolint:gosec // fixture - AccessKeySecret: "secret", + AccessKey: ptr.Ref("AKIA-test"), //nolint:gosec // fixture + AccessKeySecret: ptr.Ref("secret"), }, }) require.NoError(t, err) @@ -145,8 +146,8 @@ func TestAIProviderSettings_Roundtrip(t *testing.T) { Region: "us-west-2", Model: "anthropic.claude-sonnet-4-5", SmallFastModel: "anthropic.claude-haiku-4-5", - AccessKey: "AKIA-roundtrip", //nolint:gosec // fixture - AccessKeySecret: "secret-roundtrip", + AccessKey: ptr.Ref("AKIA-roundtrip"), //nolint:gosec // fixture + AccessKeySecret: ptr.Ref("secret-roundtrip"), }, } encoded, err := json.Marshal(orig) diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 712724e064..ee109541b3 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -16,7 +16,6 @@ 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/manifest.json b/docs/manifest.json index 2fcc912791..b781df758b 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1482,6 +1482,10 @@ "title": "AI Bridge", "path": "./reference/api/aibridge.md" }, + { + "title": "AI Providers", + "path": "./reference/api/aiproviders.md" + }, { "title": "Agents", "path": "./reference/api/agents.md" diff --git a/docs/reference/api/aiproviders.md b/docs/reference/api/aiproviders.md new file mode 100644 index 0000000000..5adc51d4cf --- /dev/null +++ b/docs/reference/api/aiproviders.md @@ -0,0 +1,294 @@ +# AI Providers + +## List AI providers + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/ai/providers \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/ai/providers` + +### Example responses + +> 200 Response + +```json +[ + { + "api_keys": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "masked": "string" + } + ], + "base_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "settings": {}, + "type": "openai", + "updated_at": "2019-08-24T14:15:22Z" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|---------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.AIProvider](schemas.md#codersdkaiprovider) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +|------------------|----------------------------------------------------------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» api_keys` | array | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» masked` | string | false | | | +| `» base_url` | string | false | | | +| `» created_at` | string(date-time) | false | | | +| `» display_name` | string | false | | | +| `» enabled` | boolean | false | | | +| `» id` | string(uuid) | false | | | +| `» name` | string | false | | | +| `» settings` | [codersdk.AIProviderSettings](schemas.md#codersdkaiprovidersettings) | false | | | +| `» type` | [codersdk.AIProviderType](schemas.md#codersdkaiprovidertype) | false | | | +| `» updated_at` | string(date-time) | false | | | + +#### Enumerated Values + +| Property | Value(s) | +|----------|----------------------------------------------------------------------------------------------| +| `type` | `anthropic`, `azure`, `bedrock`, `google`, `openai`, `openai-compat`, `openrouter`, `vercel` | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Create an AI provider + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/ai/providers \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/v2/ai/providers` + +> Body parameter + +```json +{ + "api_keys": [ + "string" + ], + "base_url": "string", + "display_name": "string", + "enabled": true, + "name": "string", + "settings": {}, + "type": "openai" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------------------------------------------------------------------------|----------|----------------------------| +| `body` | body | [codersdk.CreateAIProviderRequest](schemas.md#codersdkcreateaiproviderrequest) | true | Create AI provider request | + +### Example responses + +> 201 Response + +```json +{ + "api_keys": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "masked": "string" + } + ], + "base_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "settings": {}, + "type": "openai", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|--------------------------------------------------------------|-------------|------------------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.AIProvider](schemas.md#codersdkaiprovider) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get an AI provider + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/ai/providers/{idOrName} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/ai/providers/{idOrName}` + +### Parameters + +| Name | In | Type | Required | Description | +|------------|------|--------|----------|---------------------| +| `idOrName` | path | string | true | Provider ID or name | + +### Example responses + +> 200 Response + +```json +{ + "api_keys": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "masked": "string" + } + ], + "base_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "settings": {}, + "type": "openai", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.AIProvider](schemas.md#codersdkaiprovider) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete an AI provider + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/ai/providers/{idOrName} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /api/v2/ai/providers/{idOrName}` + +### Parameters + +| Name | In | Type | Required | Description | +|------------|------|--------|----------|---------------------| +| `idOrName` | path | string | true | Provider ID or name | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update an AI provider + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/ai/providers/{idOrName} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /api/v2/ai/providers/{idOrName}` + +> Body parameter + +```json +{ + "api_keys": [ + { + "api_key": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08" + } + ], + "base_url": "string", + "display_name": "string", + "enabled": true, + "settings": {} +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|------------|------|--------------------------------------------------------------------------------|----------|----------------------------| +| `idOrName` | path | string | true | Provider ID or name | +| `body` | body | [codersdk.UpdateAIProviderRequest](schemas.md#codersdkupdateaiproviderrequest) | true | Update AI provider request | + +### Example responses + +> 200 Response + +```json +{ + "api_keys": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "masked": "string" + } + ], + "base_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "settings": {}, + "type": "openai", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.AIProvider](schemas.md#codersdkaiprovider) | + +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 fb9221c315..590f274a93 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1298,6 +1298,44 @@ | `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | | | `chat` | [codersdk.ChatConfig](#codersdkchatconfig) | false | | | +## codersdk.AIProvider + +```json +{ + "api_keys": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "masked": "string" + } + ], + "base_url": "string", + "created_at": "2019-08-24T14:15:22Z", + "display_name": "string", + "enabled": true, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "settings": {}, + "type": "openai", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|------------------------------------------------------------|----------|--------------|-------------| +| `api_keys` | array of [codersdk.AIProviderKey](#codersdkaiproviderkey) | false | | | +| `base_url` | string | false | | | +| `created_at` | string | false | | | +| `display_name` | string | false | | | +| `enabled` | boolean | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `settings` | [codersdk.AIProviderSettings](#codersdkaiprovidersettings) | false | | | +| `type` | [codersdk.AIProviderType](#codersdkaiprovidertype) | false | | | +| `updated_at` | string | false | | | + ## codersdk.AIProviderConfig ```json @@ -1324,6 +1362,64 @@ | `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.AIProviderKey + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "masked": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------------|--------|----------|--------------|-------------| +| `created_at` | string | false | | | +| `id` | string | false | | | +| `masked` | string | false | | | + +## codersdk.AIProviderKeyMutation + +```json +{ + "api_key": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-----------|--------|----------|--------------|-------------| +| `api_key` | string | false | | | +| `id` | string | false | | | + +## codersdk.AIProviderSettings + +```json +{} +``` + +### Properties + +None + +## codersdk.AIProviderType + +```json +"openai" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|----------------------------------------------------------------------------------------------| +| `anthropic`, `azure`, `bedrock`, `google`, `openai`, `openai-compat`, `openrouter`, `vercel` | + ## codersdk.APIAllowListTarget ```json @@ -4140,6 +4236,34 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `password` | string | true | | | | `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. | +## codersdk.CreateAIProviderRequest + +```json +{ + "api_keys": [ + "string" + ], + "base_url": "string", + "display_name": "string", + "enabled": true, + "name": "string", + "settings": {}, + "type": "openai" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|------------------------------------------------------------|----------|--------------|-------------| +| `api_keys` | array of string | false | | | +| `base_url` | string | false | | | +| `display_name` | string | false | | | +| `enabled` | boolean | false | | | +| `name` | string | false | | | +| `settings` | [codersdk.AIProviderSettings](#codersdkaiprovidersettings) | false | | | +| `type` | [codersdk.AIProviderType](#codersdkaiprovidertype) | false | | | + ## codersdk.CreateChatMessageRequest ```json @@ -12698,6 +12822,33 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `p50` | integer | false | | | | `p95` | integer | false | | | +## codersdk.UpdateAIProviderRequest + +```json +{ + "api_keys": [ + { + "api_key": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08" + } + ], + "base_url": "string", + "display_name": "string", + "enabled": true, + "settings": {} +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|---------------------------------------------------------------------------|----------|--------------|-------------| +| `api_keys` | array of [codersdk.AIProviderKeyMutation](#codersdkaiproviderkeymutation) | false | | | +| `base_url` | string | false | | | +| `display_name` | string | false | | | +| `enabled` | boolean | false | | | +| `settings` | [codersdk.AIProviderSettings](#codersdkaiprovidersettings) | false | | | + ## codersdk.UpdateActiveTemplateVersion ```json diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index a1dbee6f73..565072ef8c 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -392,14 +392,6 @@ var auditableResourcesTypes = map[any]map[string]Action{ "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/dbcrypt/cliutil.go b/enterprise/dbcrypt/cliutil.go index ef85fde2cb..4760b3309e 100644 --- a/enterprise/dbcrypt/cliutil.go +++ b/enterprise/dbcrypt/cliutil.go @@ -186,7 +186,7 @@ func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciphe log.Debug(ctx, "encrypted ai provider settings", slog.F("ai_provider_id", ap.ID), slog.F("name", ap.Name), slog.F("current", idx+1), slog.F("cipher", ciphers[0].HexDigest())) } - aiProviderKeys, err := cryptDB.GetAIProviderKeys(ctx) + aiProviderKeys, err := cryptDB.GetAIProviderKeys(ctx, true) if err != nil { return xerrors.Errorf("get ai provider keys: %w", err) } @@ -392,7 +392,7 @@ func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciph log.Debug(ctx, "decrypted ai provider", slog.F("ai_provider_id", ap.ID), slog.F("name", ap.Name), slog.F("current", idx+1)) } - aiProviderKeys, err := cryptDB.GetAIProviderKeys(ctx) + aiProviderKeys, err := cryptDB.GetAIProviderKeys(ctx, true) if err != nil { return xerrors.Errorf("get ai provider keys: %w", err) } diff --git a/enterprise/dbcrypt/dbcrypt.go b/enterprise/dbcrypt/dbcrypt.go index bc0e231aa1..ac3256c9cd 100644 --- a/enterprise/dbcrypt/dbcrypt.go +++ b/enterprise/dbcrypt/dbcrypt.go @@ -538,13 +538,13 @@ func (db *dbCrypt) InsertAIProviderKey(ctx context.Context, params database.Inse return key, nil } -// GetAIProviderKeys returns every AI provider key row, including -// those whose provider has been soft-deleted, with their api_key -// decrypted. The dbcrypt key rotation utility uses this to walk -// every row holding a foreign-key reference to dbcrypt_keys before -// old keys are revoked. -func (db *dbCrypt) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) { - keys, err := db.Store.GetAIProviderKeys(ctx) +// GetAIProviderKeys returns AI provider key rows with their api_key +// decrypted. The list handler relies on the default scope (live +// providers only); the dbcrypt key rotation utility calls with +// includeDeleted=TRUE so it can walk every row holding a foreign-key +// reference to dbcrypt_keys before old keys are revoked. +func (db *dbCrypt) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]database.AIProviderKey, error) { + keys, err := db.Store.GetAIProviderKeys(ctx, includeDeleted) if err != nil { return nil, err } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f4b245220e..df85b819c2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -301,8 +301,9 @@ export interface AIConfig { // From codersdk/aiproviders.go /** * AIProvider represents an AI provider configuration row as returned - * by the API. API keys are stored in a separate ai_provider_keys - * table and managed via the keys sub-endpoints; secret fields on + * by the API. Each APIKey entry carries the row's ID so callers can + * reference it in an UpdateAIProviderRequest; the plaintext value is + * never echoed back (see AIProviderKey.Masked). Secret fields on * Settings are never included in responses. */ export interface AIProvider { @@ -312,6 +313,7 @@ export interface AIProvider { readonly display_name: string; readonly enabled: boolean; readonly base_url: string; + readonly api_keys: readonly AIProviderKey[]; readonly settings: AIProviderSettings; readonly created_at: string; readonly updated_at: string; @@ -321,7 +323,10 @@ export interface AIProvider { /** * AIProviderBedrockSettings configures providers that authenticate * against AWS Bedrock. AccessKey and AccessKeySecret are write-only: - * servers strip them from GET and list responses. + * servers strip them from GET and list responses. Both secret fields + * use a pointer so a PATCH can distinguish "leave untouched" (omitted) + * from "explicitly clear" (empty string), e.g. when migrating to + * IAM role-based authentication. */ export interface AIProviderBedrockSettings { /** @@ -389,15 +394,31 @@ export interface AIProviderConfig { // From codersdk/aiproviders.go /** - * AIProviderKey represents a single API key registered against an - * AI provider, as returned by the API. The plaintext APIKey is - * write-only and never included in responses. + * AIProviderKey is a single API key registered on a provider. The + * plaintext is never returned; Masked is a one-way rendering safe for + * display (see aibridge utils MaskSecret). ID lets clients reference + * the row in an UpdateAIProviderRequest without re-sending plaintext. */ export interface AIProviderKey { readonly id: string; - readonly provider_id: string; + readonly masked: string; readonly created_at: string; - readonly updated_at: string; +} + +// From codersdk/aiproviders.go +/** + * AIProviderKeyMutation describes the intended state of a single key + * in an UpdateAIProviderRequest. Exactly one of ID or APIKey must be + * set: + * + * - ID set, APIKey nil: keep this existing key (matched by ID). + * - ID nil, APIKey set: insert this new plaintext as a new key. + * + * Any existing key whose ID is absent from the request is deleted. + */ +export interface AIProviderKeyMutation { + readonly id?: string; + readonly api_key?: string; } // From codersdk/aiproviders.go @@ -3149,24 +3170,12 @@ export interface ConvertLoginRequest { readonly password: string; } -// From codersdk/aiproviders.go -/** - * CreateAIProviderKeyRequest is the payload for adding an API key to - * an AI provider. Only meaningful for openai and anthropic providers; - * Bedrock providers reject this call because they use the access - * credentials stored in Settings. - */ -export interface CreateAIProviderKeyRequest { - readonly api_key: string; -} - // From codersdk/aiproviders.go /** * CreateAIProviderRequest is the payload for creating a new AI - * provider. Name, Type, and BaseURL are required. API keys for - * OpenAI/Anthropic providers are added via the keys sub-endpoint - * after the provider is created; Bedrock providers carry their - * credentials in Settings and do not use the keys sub-endpoint. + * provider. Name, Type, and BaseURL are required. APIKeys carries + * the plaintext keys for OpenAI/Anthropic providers; Bedrock + * providers authenticate via Settings and must omit APIKeys. */ export interface CreateAIProviderRequest { readonly type: AIProviderType; @@ -3174,6 +3183,7 @@ export interface CreateAIProviderRequest { readonly display_name?: string; readonly enabled: boolean; readonly base_url: string; + readonly api_keys?: readonly string[]; readonly settings?: AIProviderSettings; } @@ -8306,12 +8316,16 @@ export interface TransitionStats { * UpdateAIProviderRequest is the payload for partially updating an * AI provider. At least one field must be non-nil. Pointer fields * distinguish "not sent" (nil) from "set to empty/zero" (a pointer - * to the zero value). + * to the zero value). When APIKeys is non-nil, the supplied list + * describes the post-patch state of the key set; see + * AIProviderKeyMutation for the per-entry semantics. An empty slice + * clears all keys. */ export interface UpdateAIProviderRequest { readonly display_name?: string; readonly enabled?: boolean; readonly base_url?: string; + readonly api_keys?: AIProviderKeyMutation[]; readonly settings?: AIProviderSettings; }