forked from coder/coder
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathai_providers.go
More file actions
738 lines (680 loc) · 24.9 KB
/
ai_providers.go
File metadata and controls
738 lines (680 loc) · 24.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
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"
aibridgeutils "github.com/coder/coder/v2/aibridge/utils"
"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/httpmw"
"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
auditAIProviderKeyChanges(ctx, r, *auditor, api.Logger, aiProviderKeyChanges{Added: keys})
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) {
// keyOpsAudit attaches per-key add/remove/keep counts to the audit
// entry. Keys live in a separate table, so a key-only PATCH would
// otherwise produce an empty diff and hide rotation from the log.
keyOpsAudit := &aiProviderKeyOpsAudit{}
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,
AdditionalFields: keyOpsAudit,
})
)
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
keyChanges aiProviderKeyChanges
)
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 {
var ops aiProviderKeyOpsAudit
keys, ops, keyChanges, err = applyAIProviderKeyOps(ctx, tx, updated.ID, *req.APIKeys)
if err != nil {
return err
}
*keyOpsAudit = ops
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
}
auditAIProviderKeyChanges(ctx, r, *auditor, api.Logger, keyChanges)
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
}
// aiProviderKeyOpsAudit is serialized into the audit entry's
// additional_fields. Surfacing the per-key ID and masked secret for
// adds and removes gives operators a precise record of which keys
// rotated on a PATCH whose top-level diff would otherwise look empty.
// Kept is a count: a steady-state rotation commonly retains many keys,
// and per-entry detail there is noise.
type aiProviderKeyOpsAudit struct {
Added []aiProviderKeyOp `json:"added"`
Removed []aiProviderKeyOp `json:"removed"`
Kept int `json:"kept"`
}
// aiProviderKeyOp identifies a single key affected by a PATCH. Masked
// is the one-way rendering produced by aibridgeutils.MaskSecret, so
// plaintext never lands in the audit log.
type aiProviderKeyOp struct {
ID uuid.UUID `json:"id"`
Masked string `json:"masked"`
}
// aiProviderKeyChanges captures the rows added and removed by
// applyAIProviderKeyOps so the caller can emit one audit entry per
// affected key after the transaction commits.
type aiProviderKeyChanges struct {
Added []database.AIProviderKey
Removed []database.AIProviderKey
}
// auditAIProviderKeyChanges emits one audit entry per added or removed
// key, attributed to the actor on the HTTP request. Per-key entries
// keep key rotation visible in the audit log because the parent
// AIProvider audit diff is empty for key-only PATCHes (keys live in a
// separate table).
//
// APIKey is replaced with the masked rendering before the row reaches
// the audit pipeline so plaintext keys never land in the diff or any
// audit backend, independent of the api_key column's audit policy.
func auditAIProviderKeyChanges(ctx context.Context, r *http.Request, auditor audit.Auditor, log slog.Logger, changes aiProviderKeyChanges) {
if len(changes.Added) == 0 && len(changes.Removed) == 0 {
return
}
key, ok := httpmw.APIKeyOptional(r)
if !ok {
return
}
requestID, _ := httpmw.RequestIDOptional(r)
emit := func(action database.AuditAction, before, after database.AIProviderKey) {
before.APIKey = aibridgeutils.MaskSecret(before.APIKey)
after.APIKey = aibridgeutils.MaskSecret(after.APIKey)
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.AIProviderKey]{
Audit: auditor,
Log: log,
UserID: key.UserID,
RequestID: requestID,
Status: http.StatusOK,
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
Action: action,
Old: before,
New: after,
})
}
for _, k := range changes.Removed {
emit(database.AuditActionDelete, k, database.AIProviderKey{})
}
for _, k := range changes.Added {
emit(database.AuditActionCreate, database.AIProviderKey{}, k)
}
}
// 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, aiProviderKeyOpsAudit, aiProviderKeyChanges, error) {
var (
ops aiProviderKeyOpsAudit
changes aiProviderKeyChanges
)
existing, err := tx.GetAIProviderKeysByProviderID(ctx, providerID)
if err != nil {
return nil, ops, changes, 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, ops, changes, 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, ops, changes, xerrors.Errorf("delete ai provider key %s: %w", k.ID, err)
}
ops.Removed = append(ops.Removed, aiProviderKeyOp{ID: k.ID, Masked: aibridgeutils.MaskSecret(k.APIKey)})
changes.Removed = append(changes.Removed, k)
}
added, err := insertAIProviderKeys(ctx, tx, providerID, inserts)
if err != nil {
return nil, ops, changes, err
}
for _, k := range added {
ops.Added = append(ops.Added, aiProviderKeyOp{ID: k.ID, Masked: aibridgeutils.MaskSecret(k.APIKey)})
}
changes.Added = append(changes.Added, added...)
ops.Kept = len(keep)
out, err := tx.GetAIProviderKeysByProviderID(ctx, providerID)
if err != nil {
return nil, ops, changes, xerrors.Errorf("reload ai provider keys: %w", err)
}
return out, ops, changes, 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}
}