@ouraihub/aether-worker 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,505 @@
1
+ import {
2
+ isContract,
3
+ type Endpoint,
4
+ type EndpointKeySave,
5
+ type Model,
6
+ type ModelFilter,
7
+ type ModelOption,
8
+ type ModelRef,
9
+ type ModelType,
10
+ type ProbeResult,
11
+ type ProbeTarget,
12
+ type Provider,
13
+ type ProviderSave,
14
+ } from '@ouraihub/aether-contracts'
15
+ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
16
+ import {
17
+ prepare,
18
+ validateHeaders,
19
+ validateModel,
20
+ validateUrl,
21
+ apiBase,
22
+ charCount,
23
+ type EndpointSnapshot,
24
+ } from '@ouraihub/aether-core/providers/connection'
25
+ import { inferReasoning } from '@ouraihub/aether-core/providers/infer'
26
+ import { probe as probeEndpoint } from '@ouraihub/aether-core/providers/probe'
27
+ import type { Connection } from '@ouraihub/aether-core/types'
28
+ import type { OutboundHttp } from '@ouraihub/aether-core/ports'
29
+ import { ownerKey, type Actor, type WorkerContext } from '../context.js'
30
+ import { isSealedCredential, type CredentialPort, type CredentialScope, type SealedCredential } from '../credentials.js'
31
+ import type { DocumentStore, StoredDocument } from '../storage.js'
32
+
33
+ interface Secret {
34
+ endpointId: string
35
+ keyId: string
36
+ sealed: SealedCredential
37
+ }
38
+ interface Catalog {
39
+ providers: Provider[]
40
+ secrets: Secret[]
41
+ inferred: string[]
42
+ }
43
+ export interface ProviderServiceOptions {
44
+ documents: DocumentStore
45
+ credentials: CredentialPort
46
+ outbound: OutboundHttp
47
+ authorizeReveal: (request: Request, context: WorkerContext) => Promise<void>
48
+ }
49
+ function record(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
51
+ }
52
+ function parseCatalog(value: unknown): Catalog {
53
+ if (
54
+ !record(value) ||
55
+ !Array.isArray(value.providers) ||
56
+ !Array.isArray(value.secrets) ||
57
+ !Array.isArray(value.inferred)
58
+ )
59
+ throw new KitFailure('incompatible_db', 'Invalid provider catalog')
60
+ const providers: Provider[] = []
61
+ const secrets: Secret[] = []
62
+ const inferred: string[] = []
63
+ for (const item of value.providers) {
64
+ if (!isContract('Provider', item)) throw new KitFailure('incompatible_db', 'Invalid provider document')
65
+ providers.push(item)
66
+ }
67
+ for (const item of value.secrets) {
68
+ if (
69
+ !record(item) ||
70
+ typeof item.endpointId !== 'string' ||
71
+ typeof item.keyId !== 'string' ||
72
+ !isSealedCredential(item.sealed)
73
+ )
74
+ throw new KitFailure('incompatible_db', 'Invalid provider credential')
75
+ secrets.push({ endpointId: item.endpointId, keyId: item.keyId, sealed: item.sealed })
76
+ }
77
+ for (const item of value.inferred) {
78
+ if (typeof item !== 'string') throw new KitFailure('incompatible_db', 'Invalid reasoning declaration')
79
+ inferred.push(item)
80
+ }
81
+ const automatic = new Set(inferred)
82
+ for (const provider of providers)
83
+ for (const endpoint of provider.endpoints)
84
+ for (const model of endpoint.models)
85
+ if (automatic.has(JSON.stringify([endpoint.id, model.id])))
86
+ model.reasoning =
87
+ model.modelType === 'chat'
88
+ ? inferReasoning(model.id, endpoint.protocol, endpoint.baseUrl, model.maxOutput)
89
+ : null
90
+ return { providers, secrets, inferred }
91
+ }
92
+ const missing = (entity: string): KitFailure => new KitFailure('not_found', `Unknown ${entity}`, { entity })
93
+ const reasoningKey = (endpointId: string, modelId: string): string => JSON.stringify([endpointId, modelId])
94
+ export class ProviderService {
95
+ private readonly cursors = new Map<string, number>()
96
+ private readonly options: ProviderServiceOptions
97
+ constructor(options: ProviderServiceOptions) {
98
+ this.options = options
99
+ }
100
+ private scope(actor: Actor, keyId: string): CredentialScope {
101
+ return {
102
+ applicationId: actor.applicationId,
103
+ tenantId: actor.tenantId,
104
+ subjectId: actor.subjectId,
105
+ purpose: 'provider',
106
+ recordId: keyId,
107
+ }
108
+ }
109
+ private async load(actor: Actor): Promise<{ document: StoredDocument | null; catalog: Catalog }> {
110
+ const document = await this.options.documents.get(actor, 'providers', 'catalog')
111
+ return { document, catalog: document ? parseCatalog(document.value) : { providers: [], secrets: [], inferred: [] } }
112
+ }
113
+ private async commit(actor: Actor, document: StoredDocument | null, catalog: Catalog): Promise<Provider[]> {
114
+ await this.options.documents.compareAndSet(actor, 'providers', 'catalog', document?.revision ?? null, catalog)
115
+ return this.list(actor)
116
+ }
117
+ private endpoint(catalog: Catalog, endpointId: string): { provider: Provider; endpoint: Endpoint } {
118
+ for (const provider of catalog.providers) {
119
+ const endpoint = provider.endpoints.find((item) => item.id === endpointId)
120
+ if (endpoint) return { provider, endpoint }
121
+ }
122
+ throw missing('endpoint')
123
+ }
124
+ private async open(actor: Actor, secret: Secret): Promise<string> {
125
+ const bytes = await this.options.credentials.open(this.scope(actor, secret.keyId), secret.sealed)
126
+ try {
127
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
128
+ } finally {
129
+ bytes.fill(0)
130
+ }
131
+ }
132
+ async list(actor: Actor): Promise<Provider[]> {
133
+ const { catalog } = await this.load(actor)
134
+ for (const provider of catalog.providers)
135
+ for (const endpoint of provider.endpoints)
136
+ for (const key of endpoint.keys) {
137
+ const secret = catalog.secrets.find((item) => item.endpointId === endpoint.id && item.keyId === key.id)
138
+ let plain: string | null = null
139
+ if (secret)
140
+ try {
141
+ plain = await this.open(actor, secret)
142
+ } catch {
143
+ /* Unreadable credentials remain visible but cannot execute. */
144
+ }
145
+ key.readable = plain !== null
146
+ key.masked = plain === null ? null : plain.length > 8 ? `${plain.slice(0, 4)}…${plain.slice(-4)}` : '********'
147
+ }
148
+ return catalog.providers
149
+ }
150
+ async save(actor: Actor, input: ProviderSave): Promise<Provider[]> {
151
+ if (!isContract('ProviderSave', input)) throw badRequest('provider', 'invalid')
152
+ const { document, catalog } = await this.load(actor)
153
+ const name = input.name.trim()
154
+ if (!name || charCount(name) > 80) throw badRequest('name', 'invalid')
155
+ if (input.notes !== undefined && charCount(input.notes) > 10000) throw badRequest('notes', 'too_long', 10000)
156
+ if (input.endpoints.length > 20) throw badRequest('endpoints', 'too_many', 20)
157
+ if (input.presetId && !input.id) {
158
+ if (catalog.providers.some((item) => item.presetId === input.presetId)) return this.list(actor)
159
+ const adopted = catalog.providers.find((item) => item.name === name && item.presetId === null)
160
+ if (adopted) {
161
+ adopted.presetId = input.presetId
162
+ return this.commit(actor, document, catalog)
163
+ }
164
+ }
165
+ const old = input.id ? catalog.providers.find((item) => item.id === input.id) : undefined
166
+ if (input.id && !old) throw missing('provider')
167
+ if (input.presetId && old && input.presetId !== old.presetId) throw badRequest('presetId', 'immutable')
168
+ const provider: Provider = {
169
+ id: old?.id ?? crypto.randomUUID(),
170
+ name,
171
+ enabled: old?.enabled ?? !input.presetId,
172
+ notes: input.notes ?? old?.notes ?? '',
173
+ presetId: old?.presetId ?? input.presetId ?? null,
174
+ endpoints: [],
175
+ }
176
+ if (input.enabled !== undefined && !(!old && input.presetId)) provider.enabled = input.enabled
177
+ const slugs = new Set<string>()
178
+ const endpointIds = new Set<string>()
179
+ const inferred = new Set(catalog.inferred)
180
+ for (const saved of input.endpoints) {
181
+ if (!/^[a-z0-9-]{1,40}$/.test(saved.slug) || slugs.has(saved.slug)) throw badRequest('slug', 'invalid')
182
+ slugs.add(saved.slug)
183
+ const previous = saved.id ? old?.endpoints.find((item) => item.id === saved.id) : undefined
184
+ if (saved.id && !previous) throw missing('endpoint')
185
+ const id = previous?.id ?? crypto.randomUUID()
186
+ if (endpointIds.has(id)) throw badRequest('endpoints', 'duplicate')
187
+ endpointIds.add(id)
188
+ validateUrl('baseUrl', saved.baseUrl)
189
+ const override = (field: 'imageBaseUrl' | 'imageEditBaseUrl'): string | null => {
190
+ const value = saved[field] === undefined ? (previous?.[field] ?? null) : saved[field]
191
+ if (value) validateUrl(field, value)
192
+ return value?.trim() || null
193
+ }
194
+ if (saved.models.length > 1000) throw badRequest('models', 'too_many', 1000)
195
+ const models: Model[] = []
196
+ const modelIds = new Set<string>()
197
+ for (const savedModel of saved.models) {
198
+ if (modelIds.has(savedModel.id)) throw badRequest('models', 'duplicate')
199
+ modelIds.add(savedModel.id)
200
+ const prior = previous?.models.find((item) => item.id === savedModel.id)
201
+ const key = reasoningKey(id, savedModel.id)
202
+ const infer = savedModel.reasoning === undefined && (!prior || inferred.has(key))
203
+ const reasoning =
204
+ savedModel.reasoning === undefined
205
+ ? infer
206
+ ? savedModel.modelType === 'chat'
207
+ ? inferReasoning(savedModel.id, saved.protocol, saved.baseUrl, savedModel.maxOutput)
208
+ : null
209
+ : (prior?.reasoning ?? null)
210
+ : savedModel.reasoning
211
+ if (infer) inferred.add(key)
212
+ else inferred.delete(key)
213
+ const model: Model = {
214
+ ...savedModel,
215
+ name: savedModel.name?.trim() || null,
216
+ groupName: savedModel.groupName?.trim() || null,
217
+ reasoning,
218
+ }
219
+ validateModel(model, saved.protocol)
220
+ models.push(model)
221
+ }
222
+ const endpoint: Endpoint = {
223
+ id,
224
+ slug: saved.slug,
225
+ baseUrl: saved.baseUrl.trim(),
226
+ protocol: saved.protocol,
227
+ keys: previous?.keys ?? [],
228
+ keyStrategy: 'round_robin',
229
+ probeUrl: `${apiBase(saved.baseUrl)}/models`,
230
+ models,
231
+ imageBaseUrl: override('imageBaseUrl'),
232
+ imageEditBaseUrl: override('imageEditBaseUrl'),
233
+ headers: validateHeaders(saved.headers ?? previous?.headers ?? {}),
234
+ }
235
+ provider.endpoints.push(endpoint)
236
+ }
237
+ catalog.providers = old
238
+ ? catalog.providers.map((item) => (item.id === old.id ? provider : item))
239
+ : [...catalog.providers, provider]
240
+ const allEndpoints = new Set(catalog.providers.flatMap((item) => item.endpoints.map((endpoint) => endpoint.id)))
241
+ catalog.secrets = catalog.secrets.filter((item) => allEndpoints.has(item.endpointId))
242
+ catalog.inferred = [...inferred].filter((key) =>
243
+ catalog.providers.some((item) =>
244
+ item.endpoints.some((endpoint) => endpoint.models.some((model) => reasoningKey(endpoint.id, model.id) === key)),
245
+ ),
246
+ )
247
+ return this.commit(actor, document, catalog)
248
+ }
249
+ async remove(actor: Actor, id: string): Promise<Provider[]> {
250
+ const { document, catalog } = await this.load(actor)
251
+ const provider = catalog.providers.find((item) => item.id === id)
252
+ if (!provider) throw missing('provider')
253
+ if (provider.presetId) throw badRequest('provider', 'preset')
254
+ catalog.providers = catalog.providers.filter((item) => item.id !== id)
255
+ const ids = new Set(provider.endpoints.map((endpoint) => endpoint.id))
256
+ catalog.secrets = catalog.secrets.filter((item) => !ids.has(item.endpointId))
257
+ catalog.inferred = catalog.inferred.filter(
258
+ (key) =>
259
+ !provider.endpoints.some((endpoint) =>
260
+ endpoint.models.some((model) => reasoningKey(endpoint.id, model.id) === key),
261
+ ),
262
+ )
263
+ return this.commit(actor, document, catalog)
264
+ }
265
+ async setEnabled(actor: Actor, id: string, enabled: boolean): Promise<Provider[]> {
266
+ const { document, catalog } = await this.load(actor)
267
+ const provider = catalog.providers.find((item) => item.id === id)
268
+ if (!provider) throw missing('provider')
269
+ provider.enabled = enabled
270
+ return this.commit(actor, document, catalog)
271
+ }
272
+ async saveKey(actor: Actor, input: EndpointKeySave): Promise<Provider[]> {
273
+ if (!isContract('EndpointKeySave', input)) throw badRequest('key', 'invalid')
274
+ const { document, catalog } = await this.load(actor)
275
+ const { endpoint } = this.endpoint(catalog, input.endpointId)
276
+ const old = input.id ? endpoint.keys.find((item) => item.id === input.id) : undefined
277
+ if (input.id && !old) throw missing('key')
278
+ if (!old && endpoint.keys.length >= 16) throw badRequest('keys', 'too_many', 16)
279
+ if (!old && input.key === undefined) throw badRequest('apiKey', 'empty')
280
+ const label = input.label?.trim() ?? old?.label ?? ''
281
+ if (charCount(label) > 40) throw badRequest('label', 'too_long', 40)
282
+ const id = old?.id ?? crypto.randomUUID()
283
+ if (input.key !== undefined) {
284
+ const plain = input.key.trim()
285
+ if (!plain) throw badRequest('apiKey', 'empty')
286
+ if (
287
+ new TextEncoder().encode(plain).length > 4096 ||
288
+ [...plain].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)
289
+ )
290
+ throw badRequest('apiKey', 'too_long', 4096)
291
+ for (const secret of catalog.secrets)
292
+ if (secret.endpointId === endpoint.id && secret.keyId !== id) {
293
+ let existing: string
294
+ try {
295
+ existing = await this.open(actor, secret)
296
+ } catch {
297
+ throw new KitFailure('needs_config', 'Credential cannot be read', { reason: 'key_unreadable' })
298
+ }
299
+ if (existing === plain) throw badRequest('apiKey', 'duplicate')
300
+ }
301
+ const bytes = new TextEncoder().encode(plain)
302
+ let sealed: SealedCredential
303
+ try {
304
+ sealed = await this.options.credentials.seal(this.scope(actor, id), bytes)
305
+ } finally {
306
+ bytes.fill(0)
307
+ }
308
+ catalog.secrets = catalog.secrets.filter((item) => item.keyId !== id)
309
+ catalog.secrets.push({ endpointId: endpoint.id, keyId: id, sealed })
310
+ }
311
+ const key = {
312
+ id,
313
+ label,
314
+ enabled: input.enabled ?? old?.enabled ?? true,
315
+ masked: null,
316
+ readable: true,
317
+ exhaustedUntil: input.restore ? null : (old?.exhaustedUntil ?? null),
318
+ exhaustedReason: input.restore ? null : (old?.exhaustedReason ?? null),
319
+ }
320
+ endpoint.keys = old ? endpoint.keys.map((item) => (item.id === id ? key : item)) : [...endpoint.keys, key]
321
+ return this.commit(actor, document, catalog)
322
+ }
323
+ async removeKey(actor: Actor, endpointId: string, keyId: string): Promise<Provider[]> {
324
+ const { document, catalog } = await this.load(actor)
325
+ const { endpoint } = this.endpoint(catalog, endpointId)
326
+ if (!endpoint.keys.some((item) => item.id === keyId)) throw missing('key')
327
+ endpoint.keys = endpoint.keys.filter((item) => item.id !== keyId)
328
+ catalog.secrets = catalog.secrets.filter((item) => !(item.keyId === keyId && item.endpointId === endpointId))
329
+ return this.commit(actor, document, catalog)
330
+ }
331
+ async revealKey(actor: Actor, keyId: string): Promise<string> {
332
+ const { catalog } = await this.load(actor)
333
+ const secret = catalog.secrets.find((item) => item.keyId === keyId)
334
+ if (!secret) throw missing('key')
335
+ try {
336
+ return await this.open(actor, secret)
337
+ } catch {
338
+ throw new KitFailure('needs_config', 'Credential cannot be read', { reason: 'key_unreadable' })
339
+ }
340
+ }
341
+ private async snapshotFrom(
342
+ actor: Actor,
343
+ catalog: Catalog,
344
+ endpointId: string,
345
+ keyId?: string,
346
+ ): Promise<EndpointSnapshot> {
347
+ const { endpoint } = this.endpoint(catalog, endpointId)
348
+ const chosen =
349
+ keyId === undefined ? endpoint.keys.filter((key) => key.enabled) : endpoint.keys.filter((key) => key.id === keyId)
350
+ if (keyId !== undefined && !chosen.length) throw missing('key')
351
+ const keys: string[] = []
352
+ for (const key of chosen) {
353
+ const secret = catalog.secrets.find((item) => item.keyId === key.id && item.endpointId === endpoint.id)
354
+ if (!secret) throw new KitFailure('needs_config', 'Credential cannot be read', { reason: 'key_unreadable' })
355
+ try {
356
+ keys.push(await this.open(actor, secret))
357
+ } catch {
358
+ throw new KitFailure('needs_config', 'Credential cannot be read', { reason: 'key_unreadable' })
359
+ }
360
+ }
361
+ if (keyId === undefined && keys.length) {
362
+ const cursorKey = JSON.stringify([ownerKey(actor), endpointId])
363
+ const start = (this.cursors.get(cursorKey) ?? 0) % keys.length
364
+ this.cursors.set(cursorKey, (start + 1) % keys.length)
365
+ keys.push(...keys.splice(0, start))
366
+ }
367
+ return {
368
+ endpointId,
369
+ baseUrl: endpoint.baseUrl,
370
+ protocol: endpoint.protocol,
371
+ keys,
372
+ headers: validateHeaders(endpoint.headers),
373
+ imageBaseUrl: endpoint.imageBaseUrl,
374
+ imageEditBaseUrl: endpoint.imageEditBaseUrl,
375
+ }
376
+ }
377
+ async endpointSnapshot(actor: Actor, endpointId: string): Promise<EndpointSnapshot> {
378
+ const { catalog } = await this.load(actor)
379
+ return this.snapshotFrom(actor, catalog, endpointId)
380
+ }
381
+ async resolve(actor: Actor, reference: ModelRef, requiredType?: ModelType): Promise<Connection> {
382
+ const { catalog } = await this.load(actor)
383
+ const provider = catalog.providers.find(
384
+ (item) => item.enabled && item.endpoints.some((endpoint) => endpoint.id === reference.endpointId),
385
+ )
386
+ const endpoint = provider?.endpoints.find((item) => item.id === reference.endpointId)
387
+ const model = endpoint?.models.find((item) => item.id === reference.modelId)
388
+ if (!endpoint || !model) throw missing('model')
389
+ if (requiredType !== undefined && model.modelType !== requiredType) throw badRequest('model', 'unsupported_type')
390
+ prepare({
391
+ endpoint: {
392
+ endpointId: endpoint.id,
393
+ baseUrl: endpoint.baseUrl,
394
+ protocol: endpoint.protocol,
395
+ keys: [],
396
+ headers: validateHeaders(endpoint.headers),
397
+ imageBaseUrl: endpoint.imageBaseUrl,
398
+ imageEditBaseUrl: endpoint.imageEditBaseUrl,
399
+ },
400
+ model,
401
+ })
402
+ return prepare({ endpoint: await this.snapshotFrom(actor, catalog, endpoint.id), model })
403
+ }
404
+ async modelOptions(actor: Actor, filter: ModelFilter = {}): Promise<ModelOption[]> {
405
+ const { catalog } = await this.load(actor)
406
+ return catalog.providers
407
+ .filter((provider) => provider.enabled)
408
+ .flatMap((provider) =>
409
+ provider.endpoints.flatMap((endpoint) =>
410
+ endpoint.models
411
+ .filter(
412
+ (model) =>
413
+ (filter.modelType === undefined || model.modelType === filter.modelType) &&
414
+ (filter.supportsTools === undefined || model.supportsTools === filter.supportsTools),
415
+ )
416
+ .map((model) => ({
417
+ endpointId: endpoint.id,
418
+ modelId: model.id,
419
+ label: `${provider.name} · ${endpoint.slug} · ${model.id}`,
420
+ name: model.name,
421
+ modelType: model.modelType,
422
+ supportsTools: model.supportsTools,
423
+ reasoning: model.reasoning,
424
+ nativeWebSearch: model.nativeWebSearch,
425
+ canVision: model.canVision,
426
+ })),
427
+ ),
428
+ )
429
+ }
430
+ async probe(actor: Actor, target: ProbeTarget, signal: AbortSignal): Promise<ProbeResult> {
431
+ if (!isContract('ProbeTarget', target)) throw badRequest('target', 'invalid')
432
+ const { catalog } = await this.load(actor)
433
+ let endpoint: EndpointSnapshot
434
+ if (target.kind === 'saved') endpoint = await this.snapshotFrom(actor, catalog, target.endpointId)
435
+ else {
436
+ if (target.keyId !== undefined && target.endpointId === undefined) throw missing('key')
437
+ endpoint = target.endpointId
438
+ ? {
439
+ ...(await this.snapshotFrom(actor, catalog, target.endpointId, target.keyId)),
440
+ baseUrl: target.baseUrl,
441
+ protocol: target.protocol,
442
+ }
443
+ : { endpointId: null, baseUrl: target.baseUrl, protocol: target.protocol, keys: [], headers: {} }
444
+ }
445
+ return probeEndpoint(this.options.outbound, { endpoint, signal })
446
+ }
447
+ async handle(request: Request, context: WorkerContext): Promise<Response | null> {
448
+ const url = new URL(request.url)
449
+ const path = url.pathname
450
+ const root = '/api/v1/providers'
451
+ if (path !== root && !path.startsWith(`${root}/`)) return null
452
+ const actor = context.actor
453
+ const json = (value: unknown): Response => Response.json(value)
454
+ if (path === root && request.method === 'GET') return json(await this.list(actor))
455
+ if (path === root && request.method === 'POST') {
456
+ const value: unknown = await request.json()
457
+ if (!isContract('ProviderSave', value)) throw badRequest('provider', 'invalid')
458
+ return json(await this.save(actor, value))
459
+ }
460
+ if (path === `${root}/model-options` && request.method === 'GET') {
461
+ const value: unknown = {
462
+ ...(url.searchParams.has('modelType') ? { modelType: url.searchParams.get('modelType') } : {}),
463
+ ...(url.searchParams.has('supportsTools')
464
+ ? { supportsTools: url.searchParams.get('supportsTools') === 'true' }
465
+ : {}),
466
+ }
467
+ if (
468
+ url.searchParams.has('supportsTools') &&
469
+ !['true', 'false'].includes(url.searchParams.get('supportsTools') ?? '')
470
+ )
471
+ throw badRequest('supportsTools', 'invalid')
472
+ if (!isContract('ModelFilter', value)) throw badRequest('filter', 'invalid')
473
+ return json(await this.modelOptions(actor, value))
474
+ }
475
+ if (path === `${root}/probe` && request.method === 'POST') {
476
+ const value: unknown = await request.json()
477
+ if (!isContract('ProbeTarget', value)) throw badRequest('target', 'invalid')
478
+ return json(await this.probe(actor, value, context.signal))
479
+ }
480
+ if (path === `${root}/keys` && request.method === 'POST') {
481
+ const value: unknown = await request.json()
482
+ if (!isContract('EndpointKeySave', value)) throw badRequest('key', 'invalid')
483
+ return json(await this.saveKey(actor, value))
484
+ }
485
+ const reveal = path.match(/^\/api\/v1\/providers\/keys\/([^/]+)\/reveal$/)
486
+ if (reveal && request.method === 'POST') {
487
+ await this.options.authorizeReveal(request, context)
488
+ return json(await this.revealKey(actor, decodeURIComponent(reveal[1] ?? '')))
489
+ }
490
+ const key = path.match(/^\/api\/v1\/providers\/([^/]+)\/keys\/([^/]+)$/)
491
+ if (key && request.method === 'DELETE')
492
+ return json(await this.removeKey(actor, decodeURIComponent(key[1] ?? ''), decodeURIComponent(key[2] ?? '')))
493
+ const enabled = path.match(/^\/api\/v1\/providers\/([^/]+)\/enabled$/)
494
+ if (enabled && request.method === 'POST') {
495
+ const value: unknown = await request.json()
496
+ if (!record(value) || Object.keys(value).length !== 1 || typeof value.enabled !== 'boolean')
497
+ throw badRequest('enabled', 'invalid')
498
+ return json(await this.setEnabled(actor, decodeURIComponent(enabled[1] ?? ''), value.enabled))
499
+ }
500
+ const provider = path.match(/^\/api\/v1\/providers\/([^/]+)$/)
501
+ if (provider && request.method === 'DELETE')
502
+ return json(await this.remove(actor, decodeURIComponent(provider[1] ?? '')))
503
+ throw missing('route')
504
+ }
505
+ }