@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.
- package/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +37 -3
- package/src/app.ts +327 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +49 -0
- package/src/conversations/action.ts +234 -0
- package/src/conversations/index.ts +477 -0
- package/src/conversations/model.ts +402 -0
- package/src/coordinator.ts +560 -0
- package/src/credentials.ts +153 -0
- package/src/files.ts +105 -0
- package/src/host.ts +17 -0
- package/src/identity.ts +198 -0
- package/src/images/index.ts +46 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +87 -0
- package/src/mcp/auth.ts +350 -0
- package/src/mcp/client.ts +426 -0
- package/src/mcp/common.ts +120 -0
- package/src/mcp/index.ts +239 -0
- package/src/mcp/oauth.ts +284 -0
- package/src/mcp/types.ts +74 -0
- package/src/outbound.ts +305 -0
- package/src/providers/index.ts +505 -0
- package/src/runtime.ts +635 -0
- package/src/search/index.ts +431 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +140 -0
- package/src/skills/service.ts +203 -0
- package/src/storage.ts +198 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|