@ouraihub/aether-worker 0.0.0-stage → 0.1.1

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,445 @@
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 { inferReasoning } from '@ouraihub/aether-core/providers/infer'
17
+ import { probe as probeEndpoint } from '@ouraihub/aether-core/providers/probe'
18
+ import {
19
+ prepare,
20
+ validateHeaders,
21
+ validateModel,
22
+ validateUrl,
23
+ apiBase,
24
+ charCount,
25
+ type EndpointSnapshot,
26
+ } from '@ouraihub/aether-core/providers/connection'
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 type { CredentialPort, CredentialScope, SealedCredential } from '../credentials.js'
31
+ import type { DocumentStore, StoredDocument } from '../storage.js'
32
+ import { parseCatalog, type Catalog, type Secret } from './catalog.js'
33
+ import { modelOptions } from './model-options.js'
34
+ import { handleProviderRequest } from './http.js'
35
+
36
+ export interface ProviderServiceOptions {
37
+ documents: DocumentStore
38
+ credentials: CredentialPort
39
+ outbound: OutboundHttp
40
+ authorizeReveal: (request: Request, context: WorkerContext) => Promise<void>
41
+ }
42
+
43
+ const missing = (entity: string): KitFailure =>
44
+ new KitFailure('not_found', `Unknown ${entity}`, { entity })
45
+ const reasoningKey = (endpointId: string, modelId: string): string =>
46
+ JSON.stringify([endpointId, modelId])
47
+ export class ProviderService {
48
+ private readonly cursors = new Map<string, number>()
49
+ private readonly options: ProviderServiceOptions
50
+ constructor(options: ProviderServiceOptions) {
51
+ this.options = options
52
+ }
53
+ private scope(actor: Actor, keyId: string): CredentialScope {
54
+ return {
55
+ applicationId: actor.applicationId,
56
+ tenantId: actor.tenantId,
57
+ subjectId: actor.subjectId,
58
+ purpose: 'provider',
59
+ recordId: keyId,
60
+ }
61
+ }
62
+ private async load(actor: Actor): Promise<{ document: StoredDocument | null; catalog: Catalog }> {
63
+ const document = await this.options.documents.get(actor, 'providers', 'catalog')
64
+ return {
65
+ document,
66
+ catalog: document
67
+ ? parseCatalog(document.value)
68
+ : { providers: [], secrets: [], inferred: [] },
69
+ }
70
+ }
71
+ private async commit(
72
+ actor: Actor,
73
+ document: StoredDocument | null,
74
+ catalog: Catalog,
75
+ ): Promise<Provider[]> {
76
+ await this.options.documents.compareAndSet(
77
+ actor,
78
+ 'providers',
79
+ 'catalog',
80
+ document?.revision ?? null,
81
+ catalog,
82
+ )
83
+ return this.list(actor)
84
+ }
85
+ private endpoint(
86
+ catalog: Catalog,
87
+ endpointId: string,
88
+ ): { provider: Provider; endpoint: Endpoint } {
89
+ for (const provider of catalog.providers) {
90
+ const endpoint = provider.endpoints.find((item) => item.id === endpointId)
91
+ if (endpoint) return { provider, endpoint }
92
+ }
93
+ throw missing('endpoint')
94
+ }
95
+ private async open(actor: Actor, secret: Secret): Promise<string> {
96
+ const bytes = await this.options.credentials.open(
97
+ this.scope(actor, secret.keyId),
98
+ secret.sealed,
99
+ )
100
+ try {
101
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
102
+ } finally {
103
+ bytes.fill(0)
104
+ }
105
+ }
106
+ async list(actor: Actor): Promise<Provider[]> {
107
+ const { catalog } = await this.load(actor)
108
+ for (const provider of catalog.providers)
109
+ for (const endpoint of provider.endpoints)
110
+ for (const key of endpoint.keys) {
111
+ const secret = catalog.secrets.find(
112
+ (item) => item.endpointId === endpoint.id && item.keyId === key.id,
113
+ )
114
+ let plain: string | null = null
115
+ if (secret)
116
+ try {
117
+ plain = await this.open(actor, secret)
118
+ } catch {
119
+ /* Unreadable credentials remain visible but cannot execute. */
120
+ }
121
+ key.readable = plain !== null
122
+ key.masked =
123
+ plain === null
124
+ ? null
125
+ : plain.length > 8
126
+ ? `${plain.slice(0, 4)}…${plain.slice(-4)}`
127
+ : '********'
128
+ }
129
+ return catalog.providers
130
+ }
131
+ async save(actor: Actor, input: ProviderSave): Promise<Provider[]> {
132
+ if (!isContract('ProviderSave', input)) throw badRequest('provider', 'invalid')
133
+ const { document, catalog } = await this.load(actor)
134
+ const name = input.name.trim()
135
+ if (!name || charCount(name) > 80) throw badRequest('name', 'invalid')
136
+ if (input.notes !== undefined && charCount(input.notes) > 10000)
137
+ throw badRequest('notes', 'too_long', 10000)
138
+ if (input.endpoints.length > 20) throw badRequest('endpoints', 'too_many', 20)
139
+ if (input.presetId && !input.id) {
140
+ if (catalog.providers.some((item) => item.presetId === input.presetId))
141
+ return this.list(actor)
142
+ const adopted = catalog.providers.find((item) => item.name === name && item.presetId === null)
143
+ if (adopted) {
144
+ adopted.presetId = input.presetId
145
+ return this.commit(actor, document, catalog)
146
+ }
147
+ }
148
+ const old = input.id ? catalog.providers.find((item) => item.id === input.id) : undefined
149
+ if (input.id && !old) throw missing('provider')
150
+ if (input.presetId && old && input.presetId !== old.presetId)
151
+ throw badRequest('presetId', 'immutable')
152
+ const provider: Provider = {
153
+ id: old?.id ?? crypto.randomUUID(),
154
+ name,
155
+ enabled: old?.enabled ?? !input.presetId,
156
+ notes: input.notes ?? old?.notes ?? '',
157
+ presetId: old?.presetId ?? input.presetId ?? null,
158
+ endpoints: [],
159
+ }
160
+ if (input.enabled !== undefined && !(!old && input.presetId)) provider.enabled = input.enabled
161
+ const slugs = new Set<string>()
162
+ const endpointIds = new Set<string>()
163
+ const inferred = new Set(catalog.inferred)
164
+ for (const saved of input.endpoints) {
165
+ if (!/^[a-z0-9-]{1,40}$/.test(saved.slug) || slugs.has(saved.slug))
166
+ throw badRequest('slug', 'invalid')
167
+ slugs.add(saved.slug)
168
+ const previous = saved.id ? old?.endpoints.find((item) => item.id === saved.id) : undefined
169
+ if (saved.id && !previous) throw missing('endpoint')
170
+ const id = previous?.id ?? crypto.randomUUID()
171
+ if (endpointIds.has(id)) throw badRequest('endpoints', 'duplicate')
172
+ endpointIds.add(id)
173
+ validateUrl('baseUrl', saved.baseUrl)
174
+ const override = (field: 'imageBaseUrl' | 'imageEditBaseUrl'): string | null => {
175
+ const value = saved[field] === undefined ? (previous?.[field] ?? null) : saved[field]
176
+ if (value) validateUrl(field, value)
177
+ return value?.trim() || null
178
+ }
179
+ if (saved.models.length > 1000) throw badRequest('models', 'too_many', 1000)
180
+ const models: Model[] = []
181
+ const modelIds = new Set<string>()
182
+ for (const savedModel of saved.models) {
183
+ if (modelIds.has(savedModel.id)) throw badRequest('models', 'duplicate')
184
+ modelIds.add(savedModel.id)
185
+ const prior = previous?.models.find((item) => item.id === savedModel.id)
186
+ const key = reasoningKey(id, savedModel.id)
187
+ const infer = savedModel.reasoning === undefined && (!prior || inferred.has(key))
188
+ const reasoning =
189
+ savedModel.reasoning === undefined
190
+ ? infer
191
+ ? savedModel.modelType === 'chat'
192
+ ? inferReasoning(savedModel.id, saved.protocol, saved.baseUrl, savedModel.maxOutput)
193
+ : null
194
+ : (prior?.reasoning ?? null)
195
+ : savedModel.reasoning
196
+ if (infer) inferred.add(key)
197
+ else inferred.delete(key)
198
+ const model: Model = {
199
+ ...savedModel,
200
+ name: savedModel.name?.trim() || null,
201
+ groupName: savedModel.groupName?.trim() || null,
202
+ reasoning,
203
+ }
204
+ validateModel(model, saved.protocol)
205
+ models.push(model)
206
+ }
207
+ const endpoint: Endpoint = {
208
+ id,
209
+ slug: saved.slug,
210
+ baseUrl: saved.baseUrl.trim(),
211
+ protocol: saved.protocol,
212
+ keys: previous?.keys ?? [],
213
+ keyStrategy: 'round_robin',
214
+ probeUrl: `${apiBase(saved.baseUrl)}/models`,
215
+ models,
216
+ imageBaseUrl: override('imageBaseUrl'),
217
+ imageEditBaseUrl: override('imageEditBaseUrl'),
218
+ headers: validateHeaders(saved.headers ?? previous?.headers ?? {}),
219
+ }
220
+ provider.endpoints.push(endpoint)
221
+ }
222
+ catalog.providers = old
223
+ ? catalog.providers.map((item) => (item.id === old.id ? provider : item))
224
+ : [...catalog.providers, provider]
225
+ const allEndpoints = new Set(
226
+ catalog.providers.flatMap((item) => item.endpoints.map((endpoint) => endpoint.id)),
227
+ )
228
+ catalog.secrets = catalog.secrets.filter((item) => allEndpoints.has(item.endpointId))
229
+ catalog.inferred = [...inferred].filter((key) =>
230
+ catalog.providers.some((item) =>
231
+ item.endpoints.some((endpoint) =>
232
+ endpoint.models.some((model) => reasoningKey(endpoint.id, model.id) === key),
233
+ ),
234
+ ),
235
+ )
236
+ return this.commit(actor, document, catalog)
237
+ }
238
+ async remove(actor: Actor, id: string): Promise<Provider[]> {
239
+ const { document, catalog } = await this.load(actor)
240
+ const provider = catalog.providers.find((item) => item.id === id)
241
+ if (!provider) throw missing('provider')
242
+ if (provider.presetId) throw badRequest('provider', 'preset')
243
+ catalog.providers = catalog.providers.filter((item) => item.id !== id)
244
+ const ids = new Set(provider.endpoints.map((endpoint) => endpoint.id))
245
+ catalog.secrets = catalog.secrets.filter((item) => !ids.has(item.endpointId))
246
+ catalog.inferred = catalog.inferred.filter(
247
+ (key) =>
248
+ !provider.endpoints.some((endpoint) =>
249
+ endpoint.models.some((model) => reasoningKey(endpoint.id, model.id) === key),
250
+ ),
251
+ )
252
+ return this.commit(actor, document, catalog)
253
+ }
254
+ async setEnabled(actor: Actor, id: string, enabled: boolean): Promise<Provider[]> {
255
+ const { document, catalog } = await this.load(actor)
256
+ const provider = catalog.providers.find((item) => item.id === id)
257
+ if (!provider) throw missing('provider')
258
+ provider.enabled = enabled
259
+ return this.commit(actor, document, catalog)
260
+ }
261
+ async saveKey(actor: Actor, input: EndpointKeySave): Promise<Provider[]> {
262
+ if (!isContract('EndpointKeySave', input)) throw badRequest('key', 'invalid')
263
+ const { document, catalog } = await this.load(actor)
264
+ const { endpoint } = this.endpoint(catalog, input.endpointId)
265
+ const old = input.id ? endpoint.keys.find((item) => item.id === input.id) : undefined
266
+ if (input.id && !old) throw missing('key')
267
+ if (!old && endpoint.keys.length >= 16) throw badRequest('keys', 'too_many', 16)
268
+ if (!old && input.key === undefined) throw badRequest('apiKey', 'empty')
269
+ const label = input.label?.trim() ?? old?.label ?? ''
270
+ if (charCount(label) > 40) throw badRequest('label', 'too_long', 40)
271
+ const id = old?.id ?? crypto.randomUUID()
272
+ if (input.key !== undefined) {
273
+ const plain = input.key.trim()
274
+ if (!plain) throw badRequest('apiKey', 'empty')
275
+ if (
276
+ new TextEncoder().encode(plain).length > 4096 ||
277
+ [...plain].some(
278
+ (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
279
+ )
280
+ )
281
+ throw badRequest('apiKey', 'too_long', 4096)
282
+ for (const secret of catalog.secrets)
283
+ if (secret.endpointId === endpoint.id && secret.keyId !== id) {
284
+ let existing: string
285
+ try {
286
+ existing = await this.open(actor, secret)
287
+ } catch {
288
+ throw new KitFailure('needs_config', 'Credential cannot be read', {
289
+ reason: 'key_unreadable',
290
+ })
291
+ }
292
+ if (existing === plain) throw badRequest('apiKey', 'duplicate')
293
+ }
294
+ const bytes = new TextEncoder().encode(plain)
295
+ let sealed: SealedCredential
296
+ try {
297
+ sealed = await this.options.credentials.seal(this.scope(actor, id), bytes)
298
+ } finally {
299
+ bytes.fill(0)
300
+ }
301
+ catalog.secrets = catalog.secrets.filter((item) => item.keyId !== id)
302
+ catalog.secrets.push({ endpointId: endpoint.id, keyId: id, sealed })
303
+ }
304
+ const key = {
305
+ id,
306
+ label,
307
+ enabled: input.enabled ?? old?.enabled ?? true,
308
+ masked: null,
309
+ readable: true,
310
+ exhaustedUntil: input.restore ? null : (old?.exhaustedUntil ?? null),
311
+ exhaustedReason: input.restore ? null : (old?.exhaustedReason ?? null),
312
+ }
313
+ endpoint.keys = old
314
+ ? endpoint.keys.map((item) => (item.id === id ? key : item))
315
+ : [...endpoint.keys, key]
316
+ return this.commit(actor, document, catalog)
317
+ }
318
+ async removeKey(actor: Actor, endpointId: string, keyId: string): Promise<Provider[]> {
319
+ const { document, catalog } = await this.load(actor)
320
+ const { endpoint } = this.endpoint(catalog, endpointId)
321
+ if (!endpoint.keys.some((item) => item.id === keyId)) throw missing('key')
322
+ endpoint.keys = endpoint.keys.filter((item) => item.id !== keyId)
323
+ catalog.secrets = catalog.secrets.filter(
324
+ (item) => !(item.keyId === keyId && item.endpointId === endpointId),
325
+ )
326
+ return this.commit(actor, document, catalog)
327
+ }
328
+ async revealKey(actor: Actor, keyId: string): Promise<string> {
329
+ const { catalog } = await this.load(actor)
330
+ const secret = catalog.secrets.find((item) => item.keyId === keyId)
331
+ if (!secret) throw missing('key')
332
+ try {
333
+ return await this.open(actor, secret)
334
+ } catch {
335
+ throw new KitFailure('needs_config', 'Credential cannot be read', {
336
+ reason: 'key_unreadable',
337
+ })
338
+ }
339
+ }
340
+ private async snapshotFrom(
341
+ actor: Actor,
342
+ catalog: Catalog,
343
+ endpointId: string,
344
+ keyId?: string,
345
+ ): Promise<EndpointSnapshot> {
346
+ const { endpoint } = this.endpoint(catalog, endpointId)
347
+ const chosen =
348
+ keyId === undefined
349
+ ? endpoint.keys.filter((key) => key.enabled)
350
+ : endpoint.keys.filter((key) => key.id === keyId)
351
+ if (keyId !== undefined && !chosen.length) throw missing('key')
352
+ const keys: string[] = []
353
+ for (const key of chosen) {
354
+ const secret = catalog.secrets.find(
355
+ (item) => item.keyId === key.id && item.endpointId === endpoint.id,
356
+ )
357
+ if (!secret)
358
+ throw new KitFailure('needs_config', 'Credential cannot be read', {
359
+ reason: 'key_unreadable',
360
+ })
361
+ try {
362
+ keys.push(await this.open(actor, secret))
363
+ } catch {
364
+ throw new KitFailure('needs_config', 'Credential cannot be read', {
365
+ reason: 'key_unreadable',
366
+ })
367
+ }
368
+ }
369
+ if (keyId === undefined && keys.length) {
370
+ const cursorKey = JSON.stringify([ownerKey(actor), endpointId])
371
+ const start = (this.cursors.get(cursorKey) ?? 0) % keys.length
372
+ this.cursors.set(cursorKey, (start + 1) % keys.length)
373
+ keys.push(...keys.splice(0, start))
374
+ }
375
+ return {
376
+ endpointId,
377
+ baseUrl: endpoint.baseUrl,
378
+ protocol: endpoint.protocol,
379
+ keys,
380
+ headers: validateHeaders(endpoint.headers),
381
+ imageBaseUrl: endpoint.imageBaseUrl,
382
+ imageEditBaseUrl: endpoint.imageEditBaseUrl,
383
+ }
384
+ }
385
+ async endpointSnapshot(actor: Actor, endpointId: string): Promise<EndpointSnapshot> {
386
+ const { catalog } = await this.load(actor)
387
+ return this.snapshotFrom(actor, catalog, endpointId)
388
+ }
389
+ async resolve(actor: Actor, reference: ModelRef, requiredType?: ModelType): Promise<Connection> {
390
+ const { catalog } = await this.load(actor)
391
+ const provider = catalog.providers.find(
392
+ (item) =>
393
+ item.enabled && item.endpoints.some((endpoint) => endpoint.id === reference.endpointId),
394
+ )
395
+ const endpoint = provider?.endpoints.find((item) => item.id === reference.endpointId)
396
+ const model = endpoint?.models.find((item) => item.id === reference.modelId)
397
+ if (!endpoint || !model) throw missing('model')
398
+ if (requiredType !== undefined && model.modelType !== requiredType)
399
+ throw badRequest('model', 'unsupported_type')
400
+ prepare({
401
+ endpoint: {
402
+ endpointId: endpoint.id,
403
+ baseUrl: endpoint.baseUrl,
404
+ protocol: endpoint.protocol,
405
+ keys: [],
406
+ headers: validateHeaders(endpoint.headers),
407
+ imageBaseUrl: endpoint.imageBaseUrl,
408
+ imageEditBaseUrl: endpoint.imageEditBaseUrl,
409
+ },
410
+ model,
411
+ })
412
+ return prepare({ endpoint: await this.snapshotFrom(actor, catalog, endpoint.id), model })
413
+ }
414
+ async modelOptions(actor: Actor, filter: ModelFilter = {}): Promise<ModelOption[]> {
415
+ const { catalog } = await this.load(actor)
416
+ return modelOptions(catalog, filter)
417
+ }
418
+ async probe(actor: Actor, target: ProbeTarget, signal: AbortSignal): Promise<ProbeResult> {
419
+ if (!isContract('ProbeTarget', target)) throw badRequest('target', 'invalid')
420
+ const { catalog } = await this.load(actor)
421
+ let endpoint: EndpointSnapshot
422
+ if (target.kind === 'saved')
423
+ endpoint = await this.snapshotFrom(actor, catalog, target.endpointId)
424
+ else {
425
+ if (target.keyId !== undefined && target.endpointId === undefined) throw missing('key')
426
+ endpoint = target.endpointId
427
+ ? {
428
+ ...(await this.snapshotFrom(actor, catalog, target.endpointId, target.keyId)),
429
+ baseUrl: target.baseUrl,
430
+ protocol: target.protocol,
431
+ }
432
+ : {
433
+ endpointId: null,
434
+ baseUrl: target.baseUrl,
435
+ protocol: target.protocol,
436
+ keys: [],
437
+ headers: {},
438
+ }
439
+ }
440
+ return probeEndpoint(this.options.outbound, { endpoint, signal })
441
+ }
442
+ async handle(request: Request, context: WorkerContext): Promise<Response | null> {
443
+ return handleProviderRequest(this, request, context, this.options.authorizeReveal)
444
+ }
445
+ }
@@ -0,0 +1,28 @@
1
+ import { type ModelFilter, type ModelOption } from '@ouraihub/aether-contracts'
2
+ import type { Catalog } from './catalog.js'
3
+
4
+ export function modelOptions(catalog: Catalog, filter: ModelFilter): ModelOption[] {
5
+ return catalog.providers
6
+ .filter((provider) => provider.enabled)
7
+ .flatMap((provider) =>
8
+ provider.endpoints.flatMap((endpoint) =>
9
+ endpoint.models
10
+ .filter(
11
+ (model) =>
12
+ (filter.modelType === undefined || model.modelType === filter.modelType) &&
13
+ (filter.supportsTools === undefined || model.supportsTools === filter.supportsTools),
14
+ )
15
+ .map((model) => ({
16
+ endpointId: endpoint.id,
17
+ modelId: model.id,
18
+ label: `${provider.name} · ${endpoint.slug} · ${model.id}`,
19
+ name: model.name,
20
+ modelType: model.modelType,
21
+ supportsTools: model.supportsTools,
22
+ reasoning: model.reasoning,
23
+ nativeWebSearch: model.nativeWebSearch,
24
+ canVision: model.canVision,
25
+ })),
26
+ ),
27
+ )
28
+ }
@@ -0,0 +1,44 @@
1
+ import type { EndStatus, KitError } from '@ouraihub/aether-contracts'
2
+ import { safeError } from '@ouraihub/aether-core/error'
3
+ import { charCount } from '@ouraihub/aether-core/guard'
4
+ import { renderSystem, validate } from '@ouraihub/aether-core/engine'
5
+ import { trimHistory } from '@ouraihub/aether-core/history'
6
+ import type { HistoryTurn, RunInput, RunSummary } from '@ouraihub/aether-core/types'
7
+
8
+ export const emptySummary = (status: EndStatus, error?: KitError): RunSummary => ({
9
+ status,
10
+ messages: [],
11
+ usage: {
12
+ inputTokens: null,
13
+ outputTokens: null,
14
+ cacheReadTokens: null,
15
+ cacheWriteTokens: null,
16
+ reasoningTokens: null,
17
+ },
18
+ cost: null,
19
+ contextUsage: null,
20
+ exhausted: false,
21
+ ...(error ? { error } : {}),
22
+ })
23
+
24
+ /** Final snapshots never copy arbitrary host/upstream exception text or details. */
25
+ export const publicError = (error: unknown): KitError => ({
26
+ code: safeError(error).code,
27
+ message: 'Run could not be completed',
28
+ })
29
+
30
+ export function assemble(input: RunInput, history: readonly HistoryTurn[]): RunInput {
31
+ const checked = validate({ ...input, history: [] })
32
+ const systemChars = charCount(renderSystem(checked.system, checked.permissionMode === 'plan'))
33
+ const available =
34
+ checked.budget.contextWindow -
35
+ checked.budget.maxOutput -
36
+ Math.ceil((systemChars + charCount(checked.input)) / 3)
37
+ return {
38
+ ...checked,
39
+ history:
40
+ checked.purpose === 'writing'
41
+ ? []
42
+ : trimHistory(history, available, checked.connection.canVision),
43
+ }
44
+ }