mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add AI providers HTTP CRUD handlers (#24894)
This commit is contained in:
@@ -0,0 +1,642 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// aiProvidersHandler registers the CRUD HTTP routes for runtime AI
|
||||
// provider configuration at /api/v2/ai/providers.
|
||||
func aiProvidersHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) {
|
||||
return func(r chi.Router) {
|
||||
r.Use(middlewares...)
|
||||
r.Get("/", api.aiProvidersList)
|
||||
r.Post("/", api.aiProvidersCreate)
|
||||
r.Route("/{idOrName}", func(r chi.Router) {
|
||||
r.Get("/", api.aiProvidersGet)
|
||||
r.Patch("/", api.aiProvidersUpdate)
|
||||
r.Delete("/", api.aiProvidersDelete)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary List AI providers
|
||||
// @ID list-ai-providers
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags AI Providers
|
||||
// @Success 200 {array} codersdk.AIProvider
|
||||
// @Router /api/v2/ai/providers [get]
|
||||
func (api *API) aiProvidersList(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
rows, err := api.Database.GetAIProviders(ctx, database.GetAIProvidersParams{
|
||||
IncludeDisabled: true,
|
||||
})
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
api.Logger.Error(ctx, "list AI providers", slog.Error(err))
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "list AI providers", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error listing AI providers.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
keysByProvider, err := loadAIProviderKeysByProvider(ctx, api.Database)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "list AI provider keys", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error loading AI provider keys.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]codersdk.AIProvider, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
sdk, err := db2sdk.AIProvider(row, keysByProvider[row.ID])
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", row.ID), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error converting AI provider.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
out = append(out, sdk)
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// @Summary Get an AI provider
|
||||
// @ID get-an-ai-provider
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags AI Providers
|
||||
// @Param idOrName path string true "Provider ID or name"
|
||||
// @Success 200 {object} codersdk.AIProvider
|
||||
// @Router /api/v2/ai/providers/{idOrName} [get]
|
||||
func (api *API) aiProvidersGet(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
row, err := lookupAIProvider(ctx, api.Database, chi.URLParam(r, "idOrName"))
|
||||
if err != nil {
|
||||
writeAIProviderError(ctx, api.Logger, rw, err, "lookup AI provider", "Internal error fetching AI provider.")
|
||||
return
|
||||
}
|
||||
|
||||
keys, err := api.Database.GetAIProviderKeysByProviderID(ctx, row.ID)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "fetch AI provider keys", slog.F("provider_id", row.ID), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error loading AI provider keys.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sdk, err := db2sdk.AIProvider(row, keys)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", row.ID), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error converting AI provider.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, sdk)
|
||||
}
|
||||
|
||||
// @Summary Create an AI provider
|
||||
// @ID create-an-ai-provider
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags AI Providers
|
||||
// @Param request body codersdk.CreateAIProviderRequest true "Create AI provider request"
|
||||
// @Success 201 {object} codersdk.AIProvider
|
||||
// @Router /api/v2/ai/providers [post]
|
||||
func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
auditor = api.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.AIProvider](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionCreate,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
|
||||
var req codersdk.CreateAIProviderRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if validations := req.Validate(); len(validations) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid AI provider request.",
|
||||
Validations: validations,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Bedrock providers authenticate via the settings blob, not via a
|
||||
// bearer key, so registering an api_keys list against them would
|
||||
// be silently unused.
|
||||
if req.Settings.Bedrock != nil && len(req.APIKeys) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Bedrock providers do not accept api_keys; configure access credentials via settings.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := encodeAIProviderSettings(req.Settings)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "encode AI provider settings", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error encoding settings.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
row database.AIProvider
|
||||
keys []database.AIProviderKey
|
||||
)
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
var txErr error
|
||||
row, txErr = tx.InsertAIProvider(ctx, database.InsertAIProviderParams{
|
||||
ID: uuid.New(),
|
||||
Type: database.AIProviderType(req.Type),
|
||||
Name: req.Name,
|
||||
DisplayName: sql.NullString{String: req.DisplayName, Valid: req.DisplayName != ""},
|
||||
Enabled: req.Enabled,
|
||||
BaseUrl: req.BaseURL,
|
||||
Settings: settings,
|
||||
// SettingsKeyID is set by the dbcrypt wrapper.
|
||||
SettingsKeyID: sql.NullString{},
|
||||
})
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
keys, txErr = insertAIProviderKeys(ctx, tx, row.ID, req.APIKeys)
|
||||
return txErr
|
||||
}, &database.TxOptions{TxIdentifier: "create_ai_provider"})
|
||||
if err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
api.Logger.Warn(ctx, "create AI provider: duplicate name", slog.F("name", req.Name), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||||
Message: fmt.Sprintf("AI provider %q already exists.", req.Name),
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
api.Logger.Error(ctx, "create AI provider", slog.Error(err))
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
api.Logger.Error(ctx, "create AI provider", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error creating AI provider.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
aReq.New = row
|
||||
|
||||
sdk, err := db2sdk.AIProvider(row, keys)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", row.ID), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error converting AI provider.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, sdk)
|
||||
}
|
||||
|
||||
// @Summary Update an AI provider
|
||||
// @ID update-an-ai-provider
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags AI Providers
|
||||
// @Param idOrName path string true "Provider ID or name"
|
||||
// @Param request body codersdk.UpdateAIProviderRequest true "Update AI provider request"
|
||||
// @Success 200 {object} codersdk.AIProvider
|
||||
// @Router /api/v2/ai/providers/{idOrName} [patch]
|
||||
func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
auditor = api.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.AIProvider](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
|
||||
var req codersdk.UpdateAIProviderRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.IsEmpty() {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "At least one field must be provided.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if validations := req.Validate(); len(validations) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid AI provider request.",
|
||||
Validations: validations,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
idOrName := chi.URLParam(r, "idOrName")
|
||||
|
||||
var (
|
||||
updated database.AIProvider
|
||||
keys []database.AIProviderKey
|
||||
)
|
||||
err := api.Database.InTx(func(tx database.Store) error {
|
||||
old, err := lookupAIProvider(ctx, tx, idOrName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aReq.Old = old
|
||||
|
||||
// Decode the existing settings to merge with the patch. The dbcrypt
|
||||
// wrapper has already decrypted the blob for us.
|
||||
existing, err := db2sdk.AIProviderSettings(old.Settings)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("decode existing settings: %w", err)
|
||||
}
|
||||
if req.Settings != nil {
|
||||
existing = mergeAIProviderSettings(existing, *req.Settings)
|
||||
}
|
||||
// Bedrock settings are only meaningful for anthropic-typed
|
||||
// providers; rejecting the mismatch keeps a misconfiguration
|
||||
// from sitting silently in the encrypted blob.
|
||||
if existing.Bedrock != nil && old.Type != database.AiProviderTypeAnthropic {
|
||||
return errAIProviderBedrockTypeMismatch
|
||||
}
|
||||
settings, err := encodeAIProviderSettings(existing)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("encode settings: %w", err)
|
||||
}
|
||||
|
||||
// Reject keys against Bedrock providers (whether the existing
|
||||
// row is Bedrock or the patch would make it so).
|
||||
if req.APIKeys != nil && existing.Bedrock != nil && len(*req.APIKeys) > 0 {
|
||||
return errBedrockRejectsAPIKeys
|
||||
}
|
||||
|
||||
displayName := old.DisplayName
|
||||
if req.DisplayName != nil {
|
||||
// Empty string clears the column.
|
||||
displayName = sql.NullString{String: *req.DisplayName, Valid: *req.DisplayName != ""}
|
||||
}
|
||||
params := database.UpdateAIProviderParams{
|
||||
ID: old.ID,
|
||||
DisplayName: displayName,
|
||||
Enabled: ptr.NilToDefault(req.Enabled, old.Enabled),
|
||||
BaseUrl: ptr.NilToDefault(req.BaseURL, old.BaseUrl),
|
||||
Settings: settings,
|
||||
// SettingsKeyID is set by the dbcrypt wrapper.
|
||||
SettingsKeyID: sql.NullString{},
|
||||
}
|
||||
|
||||
updated, err = tx.UpdateAIProvider(ctx, params)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update ai provider: %w", err)
|
||||
}
|
||||
aReq.New = updated
|
||||
|
||||
if req.APIKeys != nil {
|
||||
keys, err = applyAIProviderKeyOps(ctx, tx, updated.ID, *req.APIKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
keys, err = tx.GetAIProviderKeysByProviderID(ctx, updated.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("load ai provider keys: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, &database.TxOptions{TxIdentifier: "update_ai_provider"})
|
||||
if errors.Is(err, errBedrockRejectsAPIKeys) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Bedrock providers do not accept api_keys; configure access credentials via settings.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errAIProviderBedrockTypeMismatch) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Bedrock settings are only valid for type=anthropic.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errAIProviderKeyUnknown) {
|
||||
// Use the sentinel directly so the response message does not
|
||||
// leak the "execute transaction:" wrapper xerrors added on the
|
||||
// way out of InTx.
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: errAIProviderKeyUnknown.Error(),
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeAIProviderError(ctx, api.Logger, rw, err, "update AI provider", "Internal error updating AI provider.")
|
||||
return
|
||||
}
|
||||
|
||||
sdk, err := db2sdk.AIProvider(updated, keys)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", updated.ID), slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error converting AI provider.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, sdk)
|
||||
}
|
||||
|
||||
// @Summary Delete an AI provider
|
||||
// @ID delete-an-ai-provider
|
||||
// @Security CoderSessionToken
|
||||
// @Tags AI Providers
|
||||
// @Param idOrName path string true "Provider ID or name"
|
||||
// @Success 204
|
||||
// @Router /api/v2/ai/providers/{idOrName} [delete]
|
||||
func (api *API) aiProvidersDelete(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
auditor = api.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.AIProvider](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionDelete,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
|
||||
idOrName := chi.URLParam(r, "idOrName")
|
||||
|
||||
err := api.Database.InTx(func(tx database.Store) error {
|
||||
row, err := lookupAIProvider(ctx, tx, idOrName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aReq.Old = row
|
||||
|
||||
// Soft-delete UPDATE; :exec, so re-deletion is a silent no-op.
|
||||
if err := tx.DeleteAIProviderByID(ctx, row.ID); err != nil {
|
||||
return xerrors.Errorf("delete ai provider: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, &database.TxOptions{TxIdentifier: "delete_ai_provider"})
|
||||
if err != nil {
|
||||
writeAIProviderError(ctx, api.Logger, rw, err, "delete AI provider", "Internal error deleting AI provider.")
|
||||
return
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// errBedrockRejectsAPIKeys is the sentinel returned from inside the
|
||||
// update transaction when a caller attempts to attach api_keys to a
|
||||
// Bedrock-typed provider; the outer handler translates it into a 400.
|
||||
var errBedrockRejectsAPIKeys = xerrors.New("bedrock providers do not accept api_keys")
|
||||
|
||||
// errAIProviderBedrockTypeMismatch is the sentinel returned from
|
||||
// inside the update transaction when the post-merge settings carry a
|
||||
// Bedrock block but the provider is not anthropic-typed; the outer
|
||||
// handler translates it into a 400.
|
||||
var errAIProviderBedrockTypeMismatch = xerrors.New("bedrock settings are only valid for type=anthropic")
|
||||
|
||||
// errAIProviderInvalidName is returned from lookupAIProvider when the
|
||||
// idOrName parameter is neither a UUID nor a syntactically-valid name.
|
||||
// The handler translates this into a 400 so an integrator gets a hint
|
||||
// about the path shape instead of a misleading 404.
|
||||
var errAIProviderInvalidName = xerrors.New("invalid provider id or name")
|
||||
|
||||
// lookupAIProvider resolves a UUID-or-name path parameter against a Store.
|
||||
// Soft-deleted providers are not returned; lookup by name searches active
|
||||
// rows only.
|
||||
func lookupAIProvider(ctx context.Context, store database.Store, idOrName string) (database.AIProvider, error) {
|
||||
if id, err := uuid.Parse(idOrName); err == nil {
|
||||
row, err := store.GetAIProviderByID(ctx, id)
|
||||
if err != nil {
|
||||
return database.AIProvider{}, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
if !codersdk.AIProviderNameRegex.MatchString(idOrName) {
|
||||
// Bail before hitting the DB: the regex matches the CHECK
|
||||
// constraint on ai_providers.name, so a non-matching string
|
||||
// could not have been inserted.
|
||||
return database.AIProvider{}, errAIProviderInvalidName
|
||||
}
|
||||
return store.GetAIProviderByName(ctx, idOrName)
|
||||
}
|
||||
|
||||
// writeAIProviderError translates an error from the AI provider
|
||||
// lookup/update/delete paths into the right HTTP status code. logMsg
|
||||
// labels the log line for operator debugging, and userMsg is the
|
||||
// internal-error response message shown to the API consumer when no
|
||||
// more specific branch fires.
|
||||
func writeAIProviderError(ctx context.Context, logger slog.Logger, rw http.ResponseWriter, err error, logMsg, userMsg string) {
|
||||
if errors.Is(err, errAIProviderInvalidName) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Invalid provider id or name: must be a UUID or match %s.", codersdk.AIProviderNameRegex),
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
if dbauthz.IsNotAuthorizedError(err) {
|
||||
logger.Error(ctx, logMsg, slog.Error(err))
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
logger.Error(ctx, logMsg, slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: userMsg,
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// loadAIProviderKeysByProvider fetches keys for every live provider in
|
||||
// one query and buckets the rows by ProviderID, so the list handler
|
||||
// can avoid an N+1 fetch. Soft-deleted providers' keys are excluded
|
||||
// by the query.
|
||||
func loadAIProviderKeysByProvider(ctx context.Context, store database.Store) (map[uuid.UUID][]database.AIProviderKey, error) {
|
||||
rows, err := store.GetAIProviderKeys(ctx, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uuid.UUID][]database.AIProviderKey, len(rows))
|
||||
for _, row := range rows {
|
||||
out[row.ProviderID] = append(out[row.ProviderID], row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// insertAIProviderKeys writes a fresh set of key rows for a provider
|
||||
// inside a transaction. It returns the inserted rows in insertion
|
||||
// order so callers can render them in a response.
|
||||
func insertAIProviderKeys(ctx context.Context, tx database.Store, providerID uuid.UUID, plaintexts []string) ([]database.AIProviderKey, error) {
|
||||
out := make([]database.AIProviderKey, 0, len(plaintexts))
|
||||
now := dbtime.Now()
|
||||
for _, key := range plaintexts {
|
||||
row, err := tx.InsertAIProviderKey(ctx, database.InsertAIProviderKeyParams{
|
||||
ID: uuid.New(),
|
||||
ProviderID: providerID,
|
||||
APIKey: key,
|
||||
// ApiKeyKeyID is set by the dbcrypt wrapper.
|
||||
ApiKeyKeyID: sql.NullString{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("insert ai provider key: %w", err)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// applyAIProviderKeyOps reconciles a provider's keys against the
|
||||
// supplied mutation list inside a transaction: kept-by-ID rows stay,
|
||||
// rows whose ID is absent from the list are deleted, and entries
|
||||
// carrying a plaintext APIKey are inserted as new rows. Caller is
|
||||
// responsible for prior validation (XOR per entry, no duplicate IDs).
|
||||
// IDs that do not belong to this provider return errAIProviderKeyUnknown.
|
||||
func applyAIProviderKeyOps(ctx context.Context, tx database.Store, providerID uuid.UUID, muts []codersdk.AIProviderKeyMutation) ([]database.AIProviderKey, error) {
|
||||
existing, err := tx.GetAIProviderKeysByProviderID(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("load existing ai provider keys: %w", err)
|
||||
}
|
||||
existingByID := make(map[uuid.UUID]struct{}, len(existing))
|
||||
for _, k := range existing {
|
||||
existingByID[k.ID] = struct{}{}
|
||||
}
|
||||
|
||||
keep := make(map[uuid.UUID]struct{}, len(muts))
|
||||
var inserts []string
|
||||
for _, m := range muts {
|
||||
switch {
|
||||
case m.ID != nil:
|
||||
if _, ok := existingByID[*m.ID]; !ok {
|
||||
return nil, xerrors.Errorf("%w: %s", errAIProviderKeyUnknown, *m.ID)
|
||||
}
|
||||
keep[*m.ID] = struct{}{}
|
||||
case m.APIKey != nil:
|
||||
inserts = append(inserts, *m.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range existing {
|
||||
if _, ok := keep[k.ID]; ok {
|
||||
continue
|
||||
}
|
||||
if err := tx.DeleteAIProviderKey(ctx, k.ID); err != nil {
|
||||
return nil, xerrors.Errorf("delete ai provider key %s: %w", k.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := insertAIProviderKeys(ctx, tx, providerID, inserts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := tx.GetAIProviderKeysByProviderID(ctx, providerID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("reload ai provider keys: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// errAIProviderKeyUnknown is the sentinel returned by
|
||||
// applyAIProviderKeyOps when a mutation references an ID that does not
|
||||
// belong to the provider being patched; the outer handler translates it
|
||||
// into a 400.
|
||||
var errAIProviderKeyUnknown = xerrors.New("api_keys references an unknown id for this provider")
|
||||
|
||||
// encodeAIProviderSettings serializes a settings value into the
|
||||
// discriminated JSON form stored in ai_providers.settings. Empty
|
||||
// settings return an invalid sql.NullString so the row stores SQL NULL
|
||||
// and skips dbcrypt encryption entirely.
|
||||
func encodeAIProviderSettings(s codersdk.AIProviderSettings) (sql.NullString, error) {
|
||||
if s.IsZero() {
|
||||
return sql.NullString{}, nil
|
||||
}
|
||||
out, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return sql.NullString{}, err
|
||||
}
|
||||
return sql.NullString{String: string(out), Valid: true}, nil
|
||||
}
|
||||
|
||||
// mergeAIProviderSettings overlays a patch onto an existing settings
|
||||
// value. Write-only fields (Bedrock AccessKey and AccessKeySecret) use
|
||||
// pointers so the patch can distinguish "omitted, keep existing" (nil)
|
||||
// from "explicitly clear" (pointer to empty string) - e.g. when an
|
||||
// admin migrates from static AWS credentials to IAM role-based auth
|
||||
// in a single PATCH.
|
||||
func mergeAIProviderSettings(existing, patch codersdk.AIProviderSettings) codersdk.AIProviderSettings {
|
||||
if patch.Bedrock == nil {
|
||||
// Patch carries no type-specific data; treat as a clear.
|
||||
return codersdk.AIProviderSettings{}
|
||||
}
|
||||
merged := *patch.Bedrock
|
||||
if existing.Bedrock != nil {
|
||||
if merged.AccessKey == nil {
|
||||
merged.AccessKey = existing.Bedrock.AccessKey
|
||||
}
|
||||
if merged.AccessKeySecret == nil {
|
||||
merged.AccessKeySecret = existing.Bedrock.AccessKeySecret
|
||||
}
|
||||
}
|
||||
return codersdk.AIProviderSettings{Bedrock: &merged}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+316
@@ -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": [
|
||||
|
||||
Generated
+294
@@ -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"],
|
||||
|
||||
@@ -35,7 +35,6 @@ type Auditable interface {
|
||||
database.TaskTable |
|
||||
database.AiSeatState |
|
||||
database.AIProvider |
|
||||
database.AIProviderKey |
|
||||
database.Chat |
|
||||
database.AuditableGroupAiBudget |
|
||||
database.UserSecret |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
aibridgeutils "github.com/coder/coder/v2/aibridge/utils"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
|
||||
@@ -42,6 +43,80 @@ func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget
|
||||
}
|
||||
}
|
||||
|
||||
// AIProvider converts a database row plus its API keys into the
|
||||
// codersdk shape. The caller is responsible for ensuring the row and
|
||||
// keys have been decrypted (i.e. fetched through the dbcrypt-wrapped
|
||||
// store). Each api_key is masked via aibridge utils.MaskSecret and
|
||||
// write-only fields on Settings are stripped, so the result is safe
|
||||
// to echo back in API responses.
|
||||
func AIProvider(row database.AIProvider, keys []database.AIProviderKey) (codersdk.AIProvider, error) {
|
||||
display := row.Name
|
||||
if row.DisplayName.Valid && row.DisplayName.String != "" {
|
||||
display = row.DisplayName.String
|
||||
}
|
||||
out := codersdk.AIProvider{
|
||||
ID: row.ID,
|
||||
Type: codersdk.AIProviderType(row.Type),
|
||||
Name: row.Name,
|
||||
DisplayName: display,
|
||||
Enabled: row.Enabled,
|
||||
BaseURL: row.BaseUrl,
|
||||
APIKeys: maskAIProviderKeys(keys),
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
s, err := AIProviderSettings(row.Settings)
|
||||
if err != nil {
|
||||
return codersdk.AIProvider{}, xerrors.Errorf("decode settings: %w", err)
|
||||
}
|
||||
out.Settings = redactAIProviderSettings(s)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AIProviderSettings parses the on-disk JSON form back into a codersdk
|
||||
// settings value. SQL NULL and the empty string decode to the zero
|
||||
// value.
|
||||
func AIProviderSettings(col sql.NullString) (codersdk.AIProviderSettings, error) {
|
||||
if !col.Valid || col.String == "" {
|
||||
return codersdk.AIProviderSettings{}, nil
|
||||
}
|
||||
var s codersdk.AIProviderSettings
|
||||
if err := json.Unmarshal([]byte(col.String), &s); err != nil {
|
||||
return codersdk.AIProviderSettings{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// maskAIProviderKeys converts the supplied database rows into the
|
||||
// public-facing AIProviderKey shape, preserving order. Plaintext is
|
||||
// replaced by a non-reversible mask (see aibridgeutils.MaskSecret) so
|
||||
// the result is safe to embed in API responses.
|
||||
func maskAIProviderKeys(keys []database.AIProviderKey) []codersdk.AIProviderKey {
|
||||
out := make([]codersdk.AIProviderKey, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, codersdk.AIProviderKey{
|
||||
ID: k.ID,
|
||||
Masked: aibridgeutils.MaskSecret(k.APIKey),
|
||||
CreatedAt: k.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// redactAIProviderSettings strips write-only fields from a settings
|
||||
// value so it can be safely echoed back in API responses.
|
||||
func redactAIProviderSettings(s codersdk.AIProviderSettings) codersdk.AIProviderSettings {
|
||||
out := s
|
||||
if out.Bedrock != nil {
|
||||
// Deep-copy so we don't mutate the caller's struct.
|
||||
b := *out.Bedrock
|
||||
b.AccessKey = nil
|
||||
b.AccessKeySecret = nil
|
||||
out.Bedrock = &b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type ExternalAuthMeta struct {
|
||||
Authenticated bool
|
||||
ValidateError string
|
||||
|
||||
@@ -2543,15 +2543,15 @@ func (q *querier) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (datab
|
||||
return q.db.GetAIProviderKeyByID(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) {
|
||||
// This query intentionally returns every key row, including those
|
||||
// whose provider has been soft-deleted, so the dbcrypt key rotation
|
||||
// utility can re-encrypt every row that holds a foreign-key
|
||||
// reference to dbcrypt_keys.
|
||||
func (q *querier) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]database.AIProviderKey, error) {
|
||||
// Callers pass include_deleted=TRUE only from the dbcrypt key
|
||||
// rotation utility, which needs to re-encrypt every row that holds
|
||||
// a foreign-key reference to dbcrypt_keys regardless of whether
|
||||
// the parent provider is still live.
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAIProvider); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetAIProviderKeys(ctx)
|
||||
return q.db.GetAIProviderKeys(ctx, includeDeleted)
|
||||
}
|
||||
|
||||
func (q *querier) GetAIProviderKeysByProviderID(ctx context.Context, providerID uuid.UUID) ([]database.AIProviderKey, error) {
|
||||
|
||||
@@ -6517,8 +6517,8 @@ func (s *MethodTestSuite) TestAIBridge() {
|
||||
s.Run("GetAIProviderKeys", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
keyA := testutil.Fake(s.T(), faker, database.AIProviderKey{})
|
||||
keyB := testutil.Fake(s.T(), faker, database.AIProviderKey{})
|
||||
dbm.EXPECT().GetAIProviderKeys(gomock.Any()).Return([]database.AIProviderKey{keyA, keyB}, nil).AnyTimes()
|
||||
check.Args().Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns([]database.AIProviderKey{keyA, keyB})
|
||||
dbm.EXPECT().GetAIProviderKeys(gomock.Any(), gomock.Any()).Return([]database.AIProviderKey{keyA, keyB}, nil).AnyTimes()
|
||||
check.Args(false).Asserts(rbac.ResourceAIProvider, policy.ActionRead).Returns([]database.AIProviderKey{keyA, keyB})
|
||||
}))
|
||||
s.Run("InsertAIProviderKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
provider := testutil.Fake(s.T(), faker, database.AIProvider{})
|
||||
|
||||
@@ -1041,9 +1041,9 @@ func (m queryMetricsStore) GetAIProviderKeyByID(ctx context.Context, id uuid.UUI
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) {
|
||||
func (m queryMetricsStore) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]database.AIProviderKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetAIProviderKeys(ctx)
|
||||
r0, r1 := m.s.GetAIProviderKeys(ctx, includeDeleted)
|
||||
m.queryLatencies.WithLabelValues("GetAIProviderKeys").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIProviderKeys").Inc()
|
||||
return r0, r1
|
||||
|
||||
@@ -1802,18 +1802,18 @@ func (mr *MockStoreMockRecorder) GetAIProviderKeyByID(ctx, id any) *gomock.Call
|
||||
}
|
||||
|
||||
// GetAIProviderKeys mocks base method.
|
||||
func (m *MockStore) GetAIProviderKeys(ctx context.Context) ([]database.AIProviderKey, error) {
|
||||
func (m *MockStore) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]database.AIProviderKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAIProviderKeys", ctx)
|
||||
ret := m.ctrl.Call(m, "GetAIProviderKeys", ctx, includeDeleted)
|
||||
ret0, _ := ret[0].([]database.AIProviderKey)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetAIProviderKeys indicates an expected call of GetAIProviderKeys.
|
||||
func (mr *MockStoreMockRecorder) GetAIProviderKeys(ctx any) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) GetAIProviderKeys(ctx, includeDeleted any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderKeys", reflect.TypeOf((*MockStore)(nil).GetAIProviderKeys), ctx)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIProviderKeys", reflect.TypeOf((*MockStore)(nil).GetAIProviderKeys), ctx, includeDeleted)
|
||||
}
|
||||
|
||||
// GetAIProviderKeysByProviderID mocks base method.
|
||||
|
||||
@@ -26,7 +26,7 @@ INSERT INTO ai_providers (
|
||||
TRUE,
|
||||
FALSE,
|
||||
'https://bedrock-runtime.us-west-2.amazonaws.com/',
|
||||
'{"bedrock_region":"us-west-2","bedrock_model":"global.anthropic.claude-sonnet-4-5-20250929-v1:0","bedrock_access_key":"fixture-bedrock-access-key","bedrock_access_key_secret":"fixture-bedrock-access-key-secret"}'
|
||||
'{"_type":"bedrock","_version":1,"region":"us-west-2","model":"global.anthropic.claude-sonnet-4-5-20250929-v1:0","access_key":"fixture-bedrock-access-key","access_key_secret":"fixture-bedrock-access-key-secret"}'
|
||||
),
|
||||
(
|
||||
'8e3c6e18-2b75-4c3f-9b35-9d1c6f4e1a03',
|
||||
|
||||
@@ -253,10 +253,12 @@ type sqlcQuerier interface {
|
||||
GetAIProviderByID(ctx context.Context, id uuid.UUID) (AIProvider, error)
|
||||
GetAIProviderByName(ctx context.Context, name string) (AIProvider, error)
|
||||
GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (AIProviderKey, error)
|
||||
// Returns every AI provider key row, including those belonging to a
|
||||
// soft-deleted provider, so the dbcrypt key rotation utility can
|
||||
// re-encrypt their api_key and clear references to retired keys.
|
||||
GetAIProviderKeys(ctx context.Context) ([]AIProviderKey, error)
|
||||
// Returns AI provider key rows. By default, only rows whose parent
|
||||
// provider is live (deleted = FALSE) are returned, so the API list
|
||||
// handler can fetch every visible provider's keys in a single query.
|
||||
// The dbcrypt key rotation utility passes include_deleted=TRUE to
|
||||
// re-encrypt rows that belong to soft-deleted providers as well.
|
||||
GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]AIProviderKey, error)
|
||||
// Returns all keys for a provider, ordered by created_at ASC so the
|
||||
// oldest key is returned first. AI Bridge currently uses the oldest
|
||||
// key per provider; multiple keys are stored to support future
|
||||
|
||||
@@ -148,20 +148,25 @@ func (q *sqlQuerier) GetAIProviderKeyByID(ctx context.Context, id uuid.UUID) (AI
|
||||
|
||||
const getAIProviderKeys = `-- name: GetAIProviderKeys :many
|
||||
SELECT
|
||||
id, provider_id, api_key, api_key_key_id, created_at, updated_at
|
||||
ai_provider_keys.id, ai_provider_keys.provider_id, ai_provider_keys.api_key, ai_provider_keys.api_key_key_id, ai_provider_keys.created_at, ai_provider_keys.updated_at
|
||||
FROM
|
||||
ai_provider_keys
|
||||
JOIN ai_providers ON ai_providers.id = ai_provider_keys.provider_id
|
||||
WHERE
|
||||
$1::boolean OR NOT ai_providers.deleted
|
||||
ORDER BY
|
||||
provider_id ASC,
|
||||
created_at ASC,
|
||||
id ASC
|
||||
ai_provider_keys.provider_id ASC,
|
||||
ai_provider_keys.created_at ASC,
|
||||
ai_provider_keys.id ASC
|
||||
`
|
||||
|
||||
// Returns every AI provider key row, including those belonging to a
|
||||
// soft-deleted provider, so the dbcrypt key rotation utility can
|
||||
// re-encrypt their api_key and clear references to retired keys.
|
||||
func (q *sqlQuerier) GetAIProviderKeys(ctx context.Context) ([]AIProviderKey, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAIProviderKeys)
|
||||
// Returns AI provider key rows. By default, only rows whose parent
|
||||
// provider is live (deleted = FALSE) are returned, so the API list
|
||||
// handler can fetch every visible provider's keys in a single query.
|
||||
// The dbcrypt key rotation utility passes include_deleted=TRUE to
|
||||
// re-encrypt rows that belong to soft-deleted providers as well.
|
||||
func (q *sqlQuerier) GetAIProviderKeys(ctx context.Context, includeDeleted bool) ([]AIProviderKey, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAIProviderKeys, includeDeleted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -22,17 +22,22 @@ ORDER BY
|
||||
id ASC;
|
||||
|
||||
-- name: GetAIProviderKeys :many
|
||||
-- Returns every AI provider key row, including those belonging to a
|
||||
-- soft-deleted provider, so the dbcrypt key rotation utility can
|
||||
-- re-encrypt their api_key and clear references to retired keys.
|
||||
-- Returns AI provider key rows. By default, only rows whose parent
|
||||
-- provider is live (deleted = FALSE) are returned, so the API list
|
||||
-- handler can fetch every visible provider's keys in a single query.
|
||||
-- The dbcrypt key rotation utility passes include_deleted=TRUE to
|
||||
-- re-encrypt rows that belong to soft-deleted providers as well.
|
||||
SELECT
|
||||
*
|
||||
ai_provider_keys.*
|
||||
FROM
|
||||
ai_provider_keys
|
||||
JOIN ai_providers ON ai_providers.id = ai_provider_keys.provider_id
|
||||
WHERE
|
||||
@include_deleted::boolean OR NOT ai_providers.deleted
|
||||
ORDER BY
|
||||
provider_id ASC,
|
||||
created_at ASC,
|
||||
id ASC;
|
||||
ai_provider_keys.provider_id ASC,
|
||||
ai_provider_keys.created_at ASC,
|
||||
ai_provider_keys.id ASC;
|
||||
|
||||
-- name: InsertAIProviderKey :one
|
||||
INSERT INTO ai_provider_keys (
|
||||
|
||||
+196
-53
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,7 +16,6 @@ We track the following resources:
|
||||
| <b>Resource<b> | | |
|
||||
|-----------------------------------------------------------------|----------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AIProvider<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>base_url</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>enabled</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>settings</td><td>true</td></tr><tr><td>settings_key_id</td><td>false</td></tr><tr><td>type</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| AIProviderKey<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>api_key</td><td>true</td></tr><tr><td>api_key_key_id</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>provider_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
|
||||
| APIKey<br><i>login, logout, register, create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>allow_list</td><td>false</td></tr><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scopes</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| AiSeatState<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>first_used_at</td><td>true</td></tr><tr><td>last_event_description</td><td>true</td></tr><tr><td>last_event_type</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+294
@@ -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) |
|
||||
|
||||
<h3 id="list-ai-providers-responseschema">Response Schema</h3>
|
||||
|
||||
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).
|
||||
Generated
+151
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+38
-24
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user