-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathaiproviders.go
More file actions
613 lines (574 loc) · 22.7 KB
/
Copy pathaiproviders.go
File metadata and controls
613 lines (574 loc) · 22.7 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
package codersdk
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws/arn"
"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
const (
AIProviderTypeOpenAI AIProviderType = "openai"
AIProviderTypeAnthropic AIProviderType = "anthropic"
// AIProviderTypeAzure, AIProviderTypeGoogle, AIProviderTypeOpenAICompat,
// AIProviderTypeOpenrouter, and AIProviderTypeVercel route through
// aibridge's OpenAI client today because chatd configures these
// providers against their OpenAI-compatible endpoints. Native
// gateway-side support arrives later without an enum change.
AIProviderTypeAzure AIProviderType = "azure"
AIProviderTypeGoogle AIProviderType = "google"
AIProviderTypeOpenAICompat AIProviderType = "openai-compat"
AIProviderTypeOpenrouter AIProviderType = "openrouter"
AIProviderTypeVercel AIProviderType = "vercel"
// AIProviderTypeBedrock routes through aibridge's Anthropic client
// using the Bedrock discriminator in Settings; native support is
// future work.
AIProviderTypeBedrock AIProviderType = "bedrock"
// AIProviderTypeCopilot routes through aibridge's Copilot client,
// which uses request-time GitHub OAuth tokens rather than pre-shared
// API keys.
AIProviderTypeCopilot AIProviderType = "copilot"
)
// AgentsUnsupportedProviderType is an AIProviderType the Coder Agents harness
// cannot use. Declaring these as an enum exposes the generated
// AgentsUnsupportedProviderTypes list to the frontend, which labels these
// providers without a per-provider field on the AIProvider response.
type AgentsUnsupportedProviderType string
const (
// AgentsUnsupportedProviderTypeCopilot is GitHub Copilot: it authenticates
// with a per-request token only an official Copilot client can mint, which
// the server-side harness is not.
AgentsUnsupportedProviderTypeCopilot AgentsUnsupportedProviderType = AgentsUnsupportedProviderType(AIProviderTypeCopilot)
)
// IsAgentsUnsupportedProviderType reports whether the Coder Agents harness
// cannot use the provider type. It is the single source of truth, shared by
// the chatd catalog predicate and, via the generated
// AgentsUnsupportedProviderTypes list, the frontend.
func IsAgentsUnsupportedProviderType(t AIProviderType) bool {
switch AgentsUnsupportedProviderType(t) {
case AgentsUnsupportedProviderTypeCopilot:
return true
default:
return false
}
}
// AIProviderSettings is the discriminated container for type-specific
// provider settings stored in ai_providers.settings. Providers that
// need no type-specific configuration (current OpenAI and standard
// Anthropic flows) leave every field nil; the wire form for those
// providers is JSON null.
//
// On the wire, settings serialize as a JSON object that always carries
// _type and _version discriminator keys alongside the type-specific
// fields. The custom (Un)MarshalJSON implementations on this type
// handle the routing automatically; callers should never marshal the
// concrete settings struct directly.
type AIProviderSettings struct {
// Bedrock, when set, indicates this provider authenticates against
// AWS Bedrock instead of api.anthropic.com. Only meaningful for
// AIProviderTypeAnthropic.
Bedrock *AIProviderBedrockSettings `json:"-"`
}
// IsZero reports whether the settings carry no type-specific data.
func (s AIProviderSettings) IsZero() bool {
return s.Bedrock == nil
}
// MarshalJSON emits the discriminated wire form. Empty settings encode
// as JSON null so the column round-trips cleanly through SQL NULL.
func (s AIProviderSettings) MarshalJSON() ([]byte, error) {
switch {
case s.Bedrock != nil:
return marshalSettings(*s.Bedrock)
default:
return []byte("null"), nil
}
}
// UnmarshalJSON inspects the _type discriminator and routes to the
// concrete settings struct that matches it.
func (s *AIProviderSettings) UnmarshalJSON(data []byte) error {
*s = AIProviderSettings{}
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil
}
var header aiProviderSettingsHeader
if err := json.Unmarshal(data, &header); err != nil {
return xerrors.Errorf("decode settings header: %w", err)
}
if header.Type == "" {
return xerrors.New("settings missing _type discriminator")
}
switch header.Type {
case AIProviderSettingsTypeBedrock:
// TODO: handle multiple versions; this will be implemented
// once needed.
if header.Version != AIProviderBedrockSettingsVersion {
return xerrors.Errorf("unsupported %q settings version %d (expected %d)",
header.Type, header.Version, AIProviderBedrockSettingsVersion)
}
var b AIProviderBedrockSettings
if err := json.Unmarshal(data, &b); err != nil {
return xerrors.Errorf("decode bedrock settings: %w", err)
}
s.Bedrock = &b
return nil
default:
return xerrors.Errorf("unknown settings type %q", header.Type)
}
}
// aiProviderSettingsHeader is the discriminator-only view of an
// encoded settings blob.
type aiProviderSettingsHeader struct {
Type string `json:"_type"`
Version int `json:"_version"`
}
// settingsTyped is implemented by concrete settings structs so that
// marshalSettings can inject the discriminator without type-asserting
// against every variant.
type settingsTyped interface {
settingsType() string
settingsVersion() int
}
// marshalSettings encodes a concrete settings struct and merges the
// _type and _version discriminator keys at the top level of the
// resulting JSON object.
func marshalSettings(s settingsTyped) ([]byte, error) {
raw, err := json.Marshal(s)
if err != nil {
return nil, err
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
if m == nil {
m = make(map[string]json.RawMessage)
}
typeRaw, err := json.Marshal(s.settingsType())
if err != nil {
return nil, err
}
versRaw, err := json.Marshal(s.settingsVersion())
if err != nil {
return nil, err
}
m["_type"] = typeRaw
m["_version"] = versRaw
return json.Marshal(m)
}
// AIProvider represents an AI provider configuration row as returned
// 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"`
Type AIProviderType `json:"type"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Icon string `json:"icon"`
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"`
// Status carries runtime routing status; nil when empty.
Status *AIProviderStatus `json:"status,omitempty"`
}
// AIProviderStatus carries non-fatal routing warnings. Direct
// routing remains available for the provider.
type AIProviderStatus struct {
Warnings []string `json:"warnings,omitempty"`
}
// 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 and Type are required. APIKeys carries the plaintext
// keys for OpenAI/Anthropic providers; Bedrock and Copilot providers
// must omit APIKeys (Bedrock authenticates via Settings, Copilot via
// request-time GitHub OAuth tokens).
type CreateAIProviderRequest struct {
Type AIProviderType `json:"type"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Icon string `json:"icon,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,
AIProviderTypeAzure,
AIProviderTypeBedrock,
AIProviderTypeCopilot,
AIProviderTypeGoogle,
AIProviderTypeOpenAICompat,
AIProviderTypeOpenrouter,
AIProviderTypeVercel:
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", req.Type),
})
}
validations = append(validations, validateAIProviderName(req.Name)...)
validations = append(validations, validateRequiredAIProviderBaseURL(req.BaseURL)...)
validations = append(validations, validateAIProviderAPIKeys(req.APIKeys)...)
if req.Settings.Bedrock != nil &&
req.Type != AIProviderTypeAnthropic &&
req.Type != AIProviderTypeBedrock {
validations = append(validations, ValidationError{
Field: "settings",
Detail: "bedrock settings are only valid for type=anthropic or type=bedrock",
})
}
if req.Type == AIProviderTypeBedrock && (req.Settings.Bedrock == nil || !req.Settings.Bedrock.IsConfigured()) {
validations = append(validations, ValidationError{
Field: "settings",
Detail: "type=bedrock requires bedrock settings",
})
}
if req.Type == AIProviderTypeBedrock && len(req.APIKeys) > 0 {
validations = append(validations, ValidationError{
Field: "api_keys",
Detail: "type=bedrock does not accept api_keys",
})
}
if req.Settings.Bedrock != nil {
validations = append(validations, validateAIProviderRoleARN(req.Settings.Bedrock.RoleARN)...)
validations = append(validations, validateAIProviderBedrockProtocol(req.Settings.Bedrock.Protocol)...)
if req.Settings.Bedrock.ExternalID != "" {
validations = append(validations, ValidationError{
Field: "settings.external_id",
Detail: "external_id is server-generated and cannot be set",
})
}
validations = append(validations, validateAIProviderBedrockMantleRegion(*req.Settings.Bedrock)...)
validations = append(validations, validateAIProviderBedrockModels(*req.Settings.Bedrock)...)
}
if req.Type == AIProviderTypeCopilot && len(req.APIKeys) > 0 {
validations = append(validations, ValidationError{
Field: "api_keys",
Detail: "type=copilot does not accept api_keys",
})
}
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). 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"`
Icon *string `json:"icon,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
BaseURL *string `json:"base_url,omitempty"`
APIKeys *[]AIProviderKeyMutation `json:"api_keys,omitempty"`
Settings *AIProviderSettings `json:"settings,omitempty"`
}
// 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"`
}
// 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
if req.BaseURL != nil {
validations = append(validations, validateRequiredAIProviderBaseURL(*req.BaseURL)...)
}
if req.APIKeys != nil {
validations = append(validations, validateAIProviderKeyMutations(*req.APIKeys)...)
}
// Despite arriving on a PATCH, a bedrock settings blob is a full
// replacement rather than a per-field patch: the caller must set every
// field, except AccessKey, AccessKeySecret, and ExternalID, which
// mergeAIProviderSettings carries forward from the stored row when
// omitted. Omitting any other field clears it, so the checks below apply
// to the patch exactly as they would to what gets stored.
if req.Settings != nil && req.Settings.Bedrock != nil {
validations = append(validations, validateAIProviderRoleARN(req.Settings.Bedrock.RoleARN)...)
validations = append(validations, validateAIProviderBedrockProtocol(req.Settings.Bedrock.Protocol)...)
validations = append(validations, validateAIProviderBedrockMantleRegion(*req.Settings.Bedrock)...)
validations = append(validations, validateAIProviderBedrockModels(*req.Settings.Bedrock)...)
}
return validations
}
// IsEmpty reports whether the patch carries no fields.
func (req UpdateAIProviderRequest) IsEmpty() bool {
return req.DisplayName == nil && req.Icon == 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 validateAIProviderBedrockProtocol(protocol AIProviderBedrockProtocol) []ValidationError {
switch protocol {
case "", AIProviderBedrockProtocolInvokeModel, AIProviderBedrockProtocolMantle:
return nil
default:
return []ValidationError{{
Field: "settings.protocol",
Detail: fmt.Sprintf("unsupported bedrock protocol %q, must be one of %q or %q", protocol, AIProviderBedrockProtocolInvokeModel, AIProviderBedrockProtocolMantle),
}}
}
}
func validateAIProviderBedrockMantleRegion(b AIProviderBedrockSettings) []ValidationError {
// The Mantle protocol signs requests with SigV4, which requires a region.
if b.ResolvedProtocol() == AIProviderBedrockProtocolMantle && b.Region == "" {
return []ValidationError{{
Field: "settings.region",
Detail: "region is required for the mantle protocol",
}}
}
return nil
}
// validateAIProviderBedrockModels requires the model identifiers that the
// invoke-model protocol substitutes into every upstream request. Without them
// the provider cannot be constructed at runtime (see
// config.AWSBedrock.Validate), so it would be skipped at gateway startup and
// every request to it would 404. The mantle protocol forwards the client's
// model unchanged and needs neither field.
func validateAIProviderBedrockModels(b AIProviderBedrockSettings) []ValidationError {
if b.ResolvedProtocol() != AIProviderBedrockProtocolInvokeModel {
return nil
}
var validations []ValidationError
if b.Model == "" {
validations = append(validations, ValidationError{
Field: "settings.model",
Detail: "model is required for the invoke-model protocol",
})
}
if b.SmallFastModel == "" {
validations = append(validations, ValidationError{
Field: "settings.small_fast_model",
Detail: "small_fast_model is required for the invoke-model protocol",
})
}
return validations
}
func validateAIProviderRoleARN(roleARN string) []ValidationError {
if roleARN == "" {
return nil
}
const exampleRoleARN = "arn:aws:iam::123456789012:role/BedrockRole"
invalid := func(detail string) []ValidationError {
return []ValidationError{{Field: "settings.role_arn", Detail: detail}}
}
parsed, err := arn.Parse(roleARN)
if err != nil {
return invalid(fmt.Sprintf("role_arn %q is not a valid ARN, e.g. %s", roleARN, exampleRoleARN))
}
if parsed.Service != "iam" {
return invalid(fmt.Sprintf("role_arn must be an IAM ARN, but resolved to service %q, e.g. %s", parsed.Service, exampleRoleARN))
}
if !strings.HasPrefix(parsed.Resource, "role/") {
return invalid(fmt.Sprintf("role_arn must reference an IAM role, but resolved to resource %q, e.g. %s", parsed.Resource, exampleRoleARN))
}
return nil
}
func validateRequiredAIProviderBaseURL(raw string) []ValidationError {
if raw == "" {
return []ValidationError{{Field: "base_url", Detail: "base_url is required"}}
}
return validateAIProviderBaseURL(raw)
}
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.
func (c *Client) AIProviders(ctx context.Context) ([]AIProvider, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/ai/providers", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var providers []AIProvider
return providers, ReadBodyAsJSON(res, &providers)
}
// AIProvider fetches a single AI provider by ID or name.
func (c *Client) AIProvider(ctx context.Context, idOrName string) (AIProvider, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/ai/providers/%s", idOrName), nil)
if err != nil {
return AIProvider{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AIProvider{}, ReadBodyAsError(res)
}
var provider AIProvider
return provider, ReadBodyAsJSON(res, &provider)
}
// CreateAIProvider creates a new AI provider.
func (c *Client) CreateAIProvider(ctx context.Context, req CreateAIProviderRequest) (AIProvider, error) {
res, err := c.Request(ctx, http.MethodPost, "/api/v2/ai/providers", req)
if err != nil {
return AIProvider{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return AIProvider{}, ReadBodyAsError(res)
}
var provider AIProvider
return provider, ReadBodyAsJSON(res, &provider)
}
// UpdateAIProvider partially updates an AI provider identified by
// ID or name.
func (c *Client) UpdateAIProvider(ctx context.Context, idOrName string, req UpdateAIProviderRequest) (AIProvider, error) {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/ai/providers/%s", idOrName), req)
if err != nil {
return AIProvider{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AIProvider{}, ReadBodyAsError(res)
}
var provider AIProvider
return provider, ReadBodyAsJSON(res, &provider)
}
// DeleteAIProvider soft-deletes an AI provider identified by ID or
// name. The row is preserved for audit/FK history.
func (c *Client) DeleteAIProvider(ctx context.Context, idOrName string) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/ai/providers/%s", idOrName), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}