@ouraihub/aether-worker 0.1.0 → 0.1.2
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/package.json +5 -4
- package/src/app.ts +52 -16
- package/src/context.ts +9 -3
- package/src/conversations/action.ts +31 -8
- package/src/conversations/index.ts +144 -141
- package/src/conversations/model.ts +36 -11
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +54 -126
- package/src/credentials.ts +22 -4
- package/src/files.ts +17 -5
- package/src/host.ts +10 -2
- package/src/identity.ts +54 -25
- package/src/images/index.ts +39 -12
- package/src/logger.ts +15 -4
- package/src/mcp/auth.ts +65 -17
- package/src/mcp/client.ts +89 -24
- package/src/mcp/common.ts +7 -3
- package/src/mcp/index.ts +74 -21
- package/src/mcp/oauth.ts +82 -23
- package/src/mcp/types.ts +9 -2
- package/src/outbound.ts +23 -8
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +103 -163
- package/src/providers/model-options.ts +28 -0
- package/src/runtime-input.ts +44 -0
- package/src/runtime-tools.ts +298 -0
- package/src/runtime.ts +105 -264
- package/src/search/index.ts +89 -23
- package/src/skills/persistence.ts +18 -5
- package/src/skills/service.ts +60 -13
- package/src/storage.ts +108 -13
- package/src/testing/fakes.ts +97 -0
|
@@ -8,10 +8,12 @@ import type {
|
|
|
8
8
|
} from '@ouraihub/aether-contracts'
|
|
9
9
|
import type { RunInput, RunSummary } from '@ouraihub/aether-core/types'
|
|
10
10
|
import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
|
|
11
|
-
import { canonicalJson
|
|
11
|
+
import { canonicalJson } from '@ouraihub/aether-core/guard'
|
|
12
12
|
import { decodeImage, parseImage } from '@ouraihub/aether-core/images'
|
|
13
13
|
import type { Actor, WorkerContext } from '../context.ts'
|
|
14
|
-
import
|
|
14
|
+
import { handleConversationRequest } from './routes.ts'
|
|
15
|
+
import { retryCas } from '../storage.ts'
|
|
16
|
+
import type { DocumentStore, RetryOptions, StoredDocument } from '../storage.ts'
|
|
15
17
|
import { authoritative, fingerprint, resolveAction } from './action.ts'
|
|
16
18
|
import type { PreparedAction, ReservationInput } from './action.ts'
|
|
17
19
|
import {
|
|
@@ -51,17 +53,28 @@ export interface ConversationOptions {
|
|
|
51
53
|
store: DocumentStore
|
|
52
54
|
now?: () => number
|
|
53
55
|
newId?: () => string
|
|
56
|
+
/** CAS retry schedule; tests inject sleep/random to make contention deterministic. */
|
|
57
|
+
retry?: RetryOptions
|
|
54
58
|
}
|
|
55
59
|
export class ConversationService {
|
|
56
60
|
private readonly store: DocumentStore
|
|
57
61
|
private readonly now: () => number
|
|
58
62
|
private readonly newId: () => string
|
|
63
|
+
private readonly retry: RetryOptions
|
|
59
64
|
constructor(options: ConversationOptions) {
|
|
60
65
|
this.store = options.store
|
|
61
66
|
this.now = options.now ?? Date.now
|
|
62
67
|
this.newId = options.newId ?? (() => crypto.randomUUID())
|
|
68
|
+
this.retry = options.retry ?? {}
|
|
63
69
|
}
|
|
64
|
-
|
|
70
|
+
/** Reloads and re-applies `attempt` on revision conflicts; see `retryCas`. */
|
|
71
|
+
private update<T>(attempt: Parameters<typeof retryCas<T>>[0]): Promise<T> {
|
|
72
|
+
return retryCas(attempt, () => conflict('conversation_contended'), this.retry)
|
|
73
|
+
}
|
|
74
|
+
private async load(
|
|
75
|
+
id: string,
|
|
76
|
+
actor: Actor,
|
|
77
|
+
): Promise<{ revision: number; document: ConversationDocument }> {
|
|
65
78
|
checkId('conversationId', id)
|
|
66
79
|
const stored = await this.store.get(actor, COLLECTION, id)
|
|
67
80
|
if (!stored) throw notFound('conversation')
|
|
@@ -70,7 +83,10 @@ export class ConversationService {
|
|
|
70
83
|
private async *documents(actor: Actor): AsyncGenerator<StoredDocument> {
|
|
71
84
|
let after: string | undefined
|
|
72
85
|
while (true) {
|
|
73
|
-
const page = await this.store.list(actor, COLLECTION, {
|
|
86
|
+
const page = await this.store.list(actor, COLLECTION, {
|
|
87
|
+
...(after === undefined ? {} : { after }),
|
|
88
|
+
limit: 200,
|
|
89
|
+
})
|
|
74
90
|
for (const stored of page) yield stored
|
|
75
91
|
if (page.length < 200) return
|
|
76
92
|
const last = page.at(-1)?.key
|
|
@@ -86,14 +102,18 @@ export class ConversationService {
|
|
|
86
102
|
const summary = parseDocument(stored.value, stored.key).summary
|
|
87
103
|
if (scope === null || summary.scope === scope) summaries.push(summary)
|
|
88
104
|
}
|
|
89
|
-
return summaries
|
|
105
|
+
return summaries
|
|
106
|
+
.sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
|
|
107
|
+
.slice(0, 200)
|
|
90
108
|
}
|
|
91
109
|
async get(id: string, actor: Actor): Promise<Conversation> {
|
|
92
110
|
return project((await this.load(id, actor)).document)
|
|
93
111
|
}
|
|
94
112
|
async resolve(request: RunRequest, actor: Actor): Promise<PreparedAction> {
|
|
95
113
|
const snapshot = structuredClone(request)
|
|
96
|
-
const document = snapshot.conversationId
|
|
114
|
+
const document = snapshot.conversationId
|
|
115
|
+
? (await this.load(snapshot.conversationId, actor)).document
|
|
116
|
+
: null
|
|
97
117
|
return resolveAction(snapshot, document)
|
|
98
118
|
}
|
|
99
119
|
plan(prepared: PreparedAction, runId: string): AcceptedHandle {
|
|
@@ -110,7 +130,7 @@ export class ConversationService {
|
|
|
110
130
|
validate?: (input: ReservationInput, execution: RunInput) => RunInput,
|
|
111
131
|
): Promise<TurnReservation> {
|
|
112
132
|
const handle = this.plan(prepared, runId)
|
|
113
|
-
|
|
133
|
+
return this.update(async (cas) => {
|
|
114
134
|
const stored = await this.store.get(actor, COLLECTION, handle.conversationId)
|
|
115
135
|
if (prepared.request.conversationId && !stored) throw conflict('preparation_changed')
|
|
116
136
|
if (!prepared.request.conversationId && stored) throw conflict('run_identity_exists')
|
|
@@ -123,8 +143,13 @@ export class ConversationService {
|
|
|
123
143
|
throw conflict('preparation_changed')
|
|
124
144
|
}
|
|
125
145
|
if (
|
|
126
|
-
(await fingerprint(
|
|
127
|
-
|
|
146
|
+
(await fingerprint(
|
|
147
|
+
prepared.request,
|
|
148
|
+
prepared.action,
|
|
149
|
+
prepared.selectionGuard,
|
|
150
|
+
document,
|
|
151
|
+
input,
|
|
152
|
+
)) !== prepared.expected
|
|
128
153
|
)
|
|
129
154
|
throw conflict('preparation_changed')
|
|
130
155
|
const runInput = validate ? validate(input, validatedInput) : validatedInput
|
|
@@ -140,9 +165,14 @@ export class ConversationService {
|
|
|
140
165
|
runInput.connection.endpointId !== input.sourceModel.endpointId ||
|
|
141
166
|
runInput.connection.modelId !== input.sourceModel.modelId)
|
|
142
167
|
)
|
|
143
|
-
throw new KitFailure('needs_config', 'Source model unavailable', {
|
|
168
|
+
throw new KitFailure('needs_config', 'Source model unavailable', {
|
|
169
|
+
reason: 'source_model_unavailable',
|
|
170
|
+
})
|
|
144
171
|
const now = this.now(),
|
|
145
|
-
source =
|
|
172
|
+
source =
|
|
173
|
+
document && prepared.action.kind !== 'reply'
|
|
174
|
+
? target(document, prepared.action.sourceId)
|
|
175
|
+
: null
|
|
146
176
|
const parentId =
|
|
147
177
|
prepared.action.kind === 'reply'
|
|
148
178
|
? prepared.action.parentId
|
|
@@ -191,30 +221,33 @@ export class ConversationService {
|
|
|
191
221
|
next.turns.push(turn)
|
|
192
222
|
next.summary.updatedAt = now
|
|
193
223
|
const checked = parseDocument(next, handle.conversationId)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
224
|
+
await cas(() =>
|
|
225
|
+
this.store.compareAndSet(
|
|
226
|
+
actor,
|
|
227
|
+
COLLECTION,
|
|
228
|
+
handle.conversationId,
|
|
229
|
+
stored?.revision ?? null,
|
|
230
|
+
checked,
|
|
231
|
+
),
|
|
232
|
+
)
|
|
200
233
|
return {
|
|
201
234
|
handle,
|
|
202
235
|
accepted: { runId, conversationId: handle.conversationId, turnId: handle.turnId },
|
|
203
236
|
input,
|
|
204
237
|
runInput,
|
|
205
238
|
}
|
|
206
|
-
}
|
|
207
|
-
throw conflict('conversation_contended')
|
|
239
|
+
})
|
|
208
240
|
}
|
|
209
241
|
private checkHandle(document: ConversationDocument, handle: AcceptedHandle): StoredTurn {
|
|
210
242
|
if (document.summary.id !== handle.conversationId) throw notFound('turn')
|
|
211
243
|
const turn = target(document, handle.turnId)
|
|
212
|
-
if (turn.runId !== handle.runId || turn.reservationId !== handle.reservationId)
|
|
244
|
+
if (turn.runId !== handle.runId || turn.reservationId !== handle.reservationId)
|
|
245
|
+
throw notFound('turn')
|
|
213
246
|
if (turn.status !== 'running') throw conflict('turn_finished')
|
|
214
247
|
return turn
|
|
215
248
|
}
|
|
216
249
|
async finish(handle: AcceptedHandle, summary: RunSummary, actor: Actor): Promise<Conversation> {
|
|
217
|
-
|
|
250
|
+
return this.update(async (cas) => {
|
|
218
251
|
const { document, revision } = await this.load(handle.conversationId, actor),
|
|
219
252
|
turn = this.checkHandle(document, handle)
|
|
220
253
|
turn.status = summary.status
|
|
@@ -224,88 +257,101 @@ export class ConversationService {
|
|
|
224
257
|
turn.contextUsage = structuredClone(summary.contextUsage)
|
|
225
258
|
turn.errorCode = summary.status === 'failed' ? (summary.error?.code ?? 'internal') : null
|
|
226
259
|
document.summary.updatedAt = this.now()
|
|
227
|
-
if (summary.status === 'completed' && turn.supersedesId === null)
|
|
260
|
+
if (summary.status === 'completed' && turn.supersedesId === null)
|
|
261
|
+
document.summary.selectedTurnId = turn.id
|
|
228
262
|
const checked = parseDocument(document, document.summary.id)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (error instanceof KitFailure && error.code === 'conflict' && attempt < 7) continue
|
|
233
|
-
throw error
|
|
234
|
-
}
|
|
263
|
+
await cas(() =>
|
|
264
|
+
this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, checked),
|
|
265
|
+
)
|
|
235
266
|
return project(checked)
|
|
236
|
-
}
|
|
237
|
-
throw conflict('conversation_contended')
|
|
267
|
+
})
|
|
238
268
|
}
|
|
239
269
|
/** Only the coordinator, having established interruption, may recover a planned run. */
|
|
240
270
|
async recover(handle: AcceptedHandle, actor: Actor): Promise<Conversation> {
|
|
241
|
-
|
|
271
|
+
return this.update(async (cas) => {
|
|
242
272
|
const { document, revision } = await this.load(handle.conversationId, actor),
|
|
243
273
|
turn = this.checkHandle(document, handle)
|
|
244
274
|
turn.status = 'failed'
|
|
245
275
|
turn.errorCode = 'interrupted'
|
|
246
276
|
turn.contextUsage = null
|
|
247
277
|
document.summary.updatedAt = this.now()
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (error instanceof KitFailure && error.code === 'conflict' && attempt < 7) continue
|
|
252
|
-
throw error
|
|
253
|
-
}
|
|
278
|
+
await cas(() =>
|
|
279
|
+
this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, document),
|
|
280
|
+
)
|
|
254
281
|
return project(document)
|
|
255
|
-
}
|
|
256
|
-
throw conflict('conversation_contended')
|
|
282
|
+
})
|
|
257
283
|
}
|
|
258
|
-
async addGenerated(
|
|
284
|
+
async addGenerated(
|
|
285
|
+
handle: AcceptedHandle,
|
|
286
|
+
images: readonly ImagePart[],
|
|
287
|
+
actor: Actor,
|
|
288
|
+
): Promise<ImageRef[]> {
|
|
259
289
|
for (const image of images) parseImage(image)
|
|
260
|
-
|
|
290
|
+
return this.update(async (cas) => {
|
|
261
291
|
const { document, revision } = await this.load(handle.conversationId, actor),
|
|
262
292
|
turn = this.checkHandle(document, handle),
|
|
263
293
|
first = turn.generated.length
|
|
264
294
|
const now = this.now()
|
|
265
295
|
turn.generated.push(...images.map((image) => ({ ...image, createdAt: now })))
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
throw conflict('conversation_contended')
|
|
296
|
+
await cas(() =>
|
|
297
|
+
this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, document),
|
|
298
|
+
)
|
|
299
|
+
return imageRefs(handle.conversationId, handle.turnId, 'generated', turn.generated).slice(
|
|
300
|
+
first,
|
|
301
|
+
)
|
|
302
|
+
})
|
|
275
303
|
}
|
|
276
304
|
async purgeImages(beforeMs: number, actor: Actor): Promise<number> {
|
|
277
305
|
if (!Number.isSafeInteger(beforeMs) || beforeMs < 0) throw badRequest('beforeMs', 'invalid')
|
|
278
306
|
let total = 0
|
|
279
|
-
for await (const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
307
|
+
for await (const listed of this.documents(actor)) {
|
|
308
|
+
// The listed revision is tried first; after a conflict the document is reloaded, and one
|
|
309
|
+
// deleted meanwhile has nothing left to purge.
|
|
310
|
+
let current: StoredDocument | null = listed
|
|
311
|
+
total += await this.update(async (cas) => {
|
|
312
|
+
current ??= await this.store.get(actor, COLLECTION, listed.key)
|
|
313
|
+
const stored = current
|
|
314
|
+
current = null
|
|
315
|
+
if (stored === null) return 0
|
|
316
|
+
const document = parseDocument(stored.value, stored.key)
|
|
317
|
+
let count = 0
|
|
318
|
+
for (const turn of document.turns)
|
|
319
|
+
for (const image of [...turn.images, ...turn.generated])
|
|
320
|
+
if (image.data !== null && image.createdAt < beforeMs) {
|
|
321
|
+
image.data = null
|
|
322
|
+
count++
|
|
323
|
+
}
|
|
324
|
+
if (count)
|
|
325
|
+
await cas(() =>
|
|
326
|
+
this.store.compareAndSet(actor, COLLECTION, stored.key, stored.revision, document),
|
|
327
|
+
)
|
|
328
|
+
return count
|
|
329
|
+
})
|
|
292
330
|
}
|
|
293
331
|
return total
|
|
294
332
|
}
|
|
295
333
|
async rename(id: string, title: string, actor: Actor): Promise<ConversationSummary> {
|
|
296
334
|
checkText('title', title, 200, true)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
335
|
+
return this.update(async (cas) => {
|
|
336
|
+
const { document, revision } = await this.load(id, actor)
|
|
337
|
+
idle(document)
|
|
338
|
+
document.summary.title = title.trim()
|
|
339
|
+
await cas(() => this.store.compareAndSet(actor, COLLECTION, id, revision, document))
|
|
340
|
+
return document.summary
|
|
341
|
+
})
|
|
302
342
|
}
|
|
303
343
|
async delete(id: string, actor: Actor): Promise<void> {
|
|
304
344
|
const { document, revision } = await this.load(id, actor)
|
|
305
345
|
idle(document)
|
|
306
346
|
await this.store.remove(actor, COLLECTION, id, revision)
|
|
307
347
|
}
|
|
308
|
-
async editTurn(
|
|
348
|
+
async editTurn(
|
|
349
|
+
id: string,
|
|
350
|
+
turnId: string,
|
|
351
|
+
text: string,
|
|
352
|
+
expectedText: string,
|
|
353
|
+
actor: Actor,
|
|
354
|
+
): Promise<Conversation> {
|
|
309
355
|
checkText('text', text, 24_000, true)
|
|
310
356
|
const { document, revision } = await this.load(id, actor)
|
|
311
357
|
idle(document)
|
|
@@ -377,7 +423,8 @@ export class ConversationService {
|
|
|
377
423
|
ids = new Map(path.map((t) => [t.id, this.newId()])),
|
|
378
424
|
questions = new Map<string, string>()
|
|
379
425
|
for (const turn of path)
|
|
380
|
-
if (!questions.has(turn.questionId))
|
|
426
|
+
if (!questions.has(turn.questionId))
|
|
427
|
+
questions.set(turn.questionId, ids.get(turn.questionId) ?? this.newId())
|
|
381
428
|
const now = this.now(),
|
|
382
429
|
selected = ids.get(turnId)
|
|
383
430
|
if (!selected) throw notFound('turn')
|
|
@@ -396,82 +443,38 @@ export class ConversationService {
|
|
|
396
443
|
})
|
|
397
444
|
const fork: ConversationDocument = {
|
|
398
445
|
version: 1,
|
|
399
|
-
summary: {
|
|
446
|
+
summary: {
|
|
447
|
+
...document.summary,
|
|
448
|
+
id: forkId,
|
|
449
|
+
selectedTurnId: selected,
|
|
450
|
+
createdAt: now,
|
|
451
|
+
updatedAt: now,
|
|
452
|
+
},
|
|
400
453
|
turns,
|
|
401
454
|
}
|
|
402
455
|
await this.store.compareAndSet(actor, COLLECTION, forkId, null, fork)
|
|
403
456
|
return project(fork)
|
|
404
457
|
}
|
|
405
458
|
async handle(request: Request, context: WorkerContext): Promise<Response | null> {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (parts.length === 1 && request.method === 'GET') return json(await this.get(id, actor))
|
|
427
|
-
if (parts.length === 1 && request.method === 'DELETE') {
|
|
428
|
-
await this.delete(id, actor)
|
|
429
|
-
return json({ ok: true })
|
|
430
|
-
}
|
|
431
|
-
if (parts.length === 6 && parts[1] === 'turns' && parts[3] === 'images' && request.method === 'GET') {
|
|
432
|
-
const turnId = parts[2],
|
|
433
|
-
kind = parts[4],
|
|
434
|
-
index = parts[5]
|
|
435
|
-
if (!turnId || (kind !== 'input' && kind !== 'generated') || !index || !/^(0|[1-9]\d*)$/u.test(index))
|
|
436
|
-
throw notFound('image')
|
|
437
|
-
const { document } = await this.load(id, actor),
|
|
438
|
-
turn = target(document, turnId),
|
|
439
|
-
image = (kind === 'input' ? turn.images : turn.generated)[Number(index)]
|
|
440
|
-
if (!image || image.data === null) throw notFound('image')
|
|
441
|
-
const bytes = decodeImage({ mime: image.mime, data: image.data })
|
|
442
|
-
return new Response(bytes, {
|
|
443
|
-
headers: {
|
|
444
|
-
'Content-Type': image.mime,
|
|
445
|
-
'Cache-Control': 'private, no-store',
|
|
446
|
-
'X-Content-Type-Options': 'nosniff',
|
|
447
|
-
'Content-Security-Policy': "default-src 'none'",
|
|
448
|
-
},
|
|
449
|
-
})
|
|
450
|
-
}
|
|
451
|
-
if (parts.length !== 2 || request.method !== 'POST') throw notFound('route')
|
|
452
|
-
let body: unknown
|
|
453
|
-
try {
|
|
454
|
-
body = await request.json()
|
|
455
|
-
} catch {
|
|
456
|
-
throw badRequest('body', 'json')
|
|
457
|
-
}
|
|
458
|
-
if (!isRecord(body)) throw badRequest('body', 'object')
|
|
459
|
-
const action = parts[1],
|
|
460
|
-
fields =
|
|
461
|
-
action === 'rename' ? ['title'] : action === 'edit-turn' ? ['turnId', 'text', 'expectedText'] : ['turnId']
|
|
462
|
-
if (Object.keys(body).some((key) => !fields.includes(key))) throw badRequest('body', 'unknown_field')
|
|
463
|
-
if (action === 'rename') {
|
|
464
|
-
if (typeof body.title !== 'string') throw badRequest('title', 'required')
|
|
465
|
-
return json(await this.rename(id, body.title, actor))
|
|
466
|
-
}
|
|
467
|
-
if (typeof body.turnId !== 'string') throw badRequest('turnId', 'required')
|
|
468
|
-
if (action === 'edit-turn') {
|
|
469
|
-
if (typeof body.text !== 'string' || typeof body.expectedText !== 'string') throw badRequest('text', 'required')
|
|
470
|
-
return json(await this.editTurn(id, body.turnId, body.text, body.expectedText, actor))
|
|
471
|
-
}
|
|
472
|
-
if (action === 'remove-turn') return json(await this.removeTurn(id, body.turnId, actor))
|
|
473
|
-
if (action === 'select-turn') return json(await this.selectTurn(id, body.turnId, actor))
|
|
474
|
-
if (action === 'fork') return json(await this.fork(id, body.turnId, actor))
|
|
475
|
-
throw notFound('route')
|
|
459
|
+
return handleConversationRequest(
|
|
460
|
+
this,
|
|
461
|
+
request,
|
|
462
|
+
context,
|
|
463
|
+
async (id, turnId, kind, index, actor) => {
|
|
464
|
+
const { document } = await this.load(id, actor),
|
|
465
|
+
turn = target(document, turnId),
|
|
466
|
+
image = (kind === 'input' ? turn.images : turn.generated)[index]
|
|
467
|
+
if (!image || image.data === null) throw notFound('image')
|
|
468
|
+
const bytes = decodeImage({ mime: image.mime, data: image.data })
|
|
469
|
+
return new Response(bytes, {
|
|
470
|
+
headers: {
|
|
471
|
+
'Content-Type': image.mime,
|
|
472
|
+
'Cache-Control': 'private, no-store',
|
|
473
|
+
'X-Content-Type-Options': 'nosniff',
|
|
474
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
475
|
+
},
|
|
476
|
+
})
|
|
477
|
+
},
|
|
478
|
+
)
|
|
476
479
|
}
|
|
477
480
|
}
|
|
@@ -53,7 +53,8 @@ export interface ConversationDocument {
|
|
|
53
53
|
}
|
|
54
54
|
export const conflict = (reason: string): KitFailure =>
|
|
55
55
|
new KitFailure('conflict', 'Conversation changed or unavailable', { reason })
|
|
56
|
-
export const notFound = (entity: string): KitFailure =>
|
|
56
|
+
export const notFound = (entity: string): KitFailure =>
|
|
57
|
+
new KitFailure('not_found', 'Resource not found', { entity })
|
|
57
58
|
const corrupt = (): never => {
|
|
58
59
|
throw new KitFailure('incompatible_db', 'Invalid stored conversation')
|
|
59
60
|
}
|
|
@@ -164,9 +165,18 @@ export function validateImages(images: readonly ImagePart[]): void {
|
|
|
164
165
|
function reasoning(value: unknown): ReasoningBlock {
|
|
165
166
|
const v = record(value),
|
|
166
167
|
protocol = v.protocol
|
|
167
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
protocol !== 'openai-chat' &&
|
|
170
|
+
protocol !== 'openai-responses' &&
|
|
171
|
+
protocol !== 'anthropic-messages'
|
|
172
|
+
)
|
|
168
173
|
return corrupt()
|
|
169
|
-
return {
|
|
174
|
+
return {
|
|
175
|
+
protocol,
|
|
176
|
+
endpointId: nullableText(v.endpointId),
|
|
177
|
+
modelId: nullableText(v.modelId),
|
|
178
|
+
value: json(v.value),
|
|
179
|
+
}
|
|
170
180
|
}
|
|
171
181
|
function json(value: unknown, depth = 0): unknown {
|
|
172
182
|
if (depth > 64) return corrupt()
|
|
@@ -179,7 +189,9 @@ function json(value: unknown, depth = 0): unknown {
|
|
|
179
189
|
return value
|
|
180
190
|
if (Array.isArray(value)) return value.map((item: unknown) => json(item, depth + 1))
|
|
181
191
|
if (isRecord(value))
|
|
182
|
-
return Object.fromEntries(
|
|
192
|
+
return Object.fromEntries(
|
|
193
|
+
Object.entries(value).map(([key, item]) => [key, json(item, depth + 1)]),
|
|
194
|
+
)
|
|
183
195
|
return corrupt()
|
|
184
196
|
}
|
|
185
197
|
function call(value: unknown): ToolCall {
|
|
@@ -192,7 +204,8 @@ function jsonRecord(value: unknown): Record<string, unknown> {
|
|
|
192
204
|
}
|
|
193
205
|
export function parseMessage(value: unknown): Message {
|
|
194
206
|
const v = record(value)
|
|
195
|
-
if (['user', 'assistant', 'tool'].filter((key) => Object.hasOwn(v, key)).length !== 1)
|
|
207
|
+
if (['user', 'assistant', 'tool'].filter((key) => Object.hasOwn(v, key)).length !== 1)
|
|
208
|
+
return corrupt()
|
|
196
209
|
if (typeof v.user === 'string') {
|
|
197
210
|
if (v.images === undefined) return { user: v.user }
|
|
198
211
|
return {
|
|
@@ -243,7 +256,10 @@ export function parseDocument(value: unknown, key: string): ConversationDocument
|
|
|
243
256
|
status = t.status,
|
|
244
257
|
kind = t.kind
|
|
245
258
|
if (
|
|
246
|
-
(status !== 'running' &&
|
|
259
|
+
(status !== 'running' &&
|
|
260
|
+
status !== 'completed' &&
|
|
261
|
+
status !== 'failed' &&
|
|
262
|
+
status !== 'cancelled') ||
|
|
247
263
|
(kind !== 'reply' && kind !== 'translation')
|
|
248
264
|
)
|
|
249
265
|
return corrupt()
|
|
@@ -280,7 +296,8 @@ export function parseDocument(value: unknown, key: string): ConversationDocument
|
|
|
280
296
|
let running = 0,
|
|
281
297
|
last = 0
|
|
282
298
|
for (const t of turns) {
|
|
283
|
-
if (!t.id.trim() || charCount(t.id) > 128 || ids.has(t.id) || seqs.has(t.seq) || t.seq <= last)
|
|
299
|
+
if (!t.id.trim() || charCount(t.id) > 128 || ids.has(t.id) || seqs.has(t.seq) || t.seq <= last)
|
|
300
|
+
return corrupt()
|
|
284
301
|
if (
|
|
285
302
|
!t.questionId.trim() ||
|
|
286
303
|
charCount(t.questionId) > 128 ||
|
|
@@ -296,7 +313,8 @@ export function parseDocument(value: unknown, key: string): ConversationDocument
|
|
|
296
313
|
last = t.seq
|
|
297
314
|
if (t.status === 'running') running++
|
|
298
315
|
}
|
|
299
|
-
if (running > 1 || (summary.selectedTurnId !== null && !ids.has(summary.selectedTurnId)))
|
|
316
|
+
if (running > 1 || (summary.selectedTurnId !== null && !ids.has(summary.selectedTurnId)))
|
|
317
|
+
return corrupt()
|
|
300
318
|
for (const t of turns) {
|
|
301
319
|
if (
|
|
302
320
|
(t.parentId !== null && !ids.has(t.parentId)) ||
|
|
@@ -323,7 +341,9 @@ export function target(document: ConversationDocument, id: string): StoredTurn {
|
|
|
323
341
|
return turn
|
|
324
342
|
}
|
|
325
343
|
export function visibleText(turn: StoredTurn): string {
|
|
326
|
-
return turn.messages
|
|
344
|
+
return turn.messages
|
|
345
|
+
.flatMap((m) => ('assistant' in m && m.assistant.text ? [m.assistant.text] : []))
|
|
346
|
+
.join('\n\n')
|
|
327
347
|
}
|
|
328
348
|
export function reasoningText(turn: StoredTurn): string {
|
|
329
349
|
let text = ''
|
|
@@ -338,9 +358,14 @@ export function reasoningText(turn: StoredTurn): string {
|
|
|
338
358
|
typeof block.value.thinking === 'string'
|
|
339
359
|
)
|
|
340
360
|
text += block.value.thinking
|
|
341
|
-
else if (
|
|
361
|
+
else if (
|
|
362
|
+
block.protocol === 'openai-responses' &&
|
|
363
|
+
isRecord(block.value) &&
|
|
364
|
+
Array.isArray(block.value.summary)
|
|
365
|
+
) {
|
|
342
366
|
for (const part of block.value.summary)
|
|
343
|
-
if (isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string')
|
|
367
|
+
if (isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string')
|
|
368
|
+
text += part.text
|
|
344
369
|
}
|
|
345
370
|
}
|
|
346
371
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ConversationService } from './index.ts'
|
|
2
|
+
import { badRequest, KitFailure } from '@ouraihub/aether-core/error'
|
|
3
|
+
import type { Actor, WorkerContext } from '../context.ts'
|
|
4
|
+
import { isRecord } from '@ouraihub/aether-core/guard'
|
|
5
|
+
|
|
6
|
+
const notFound = (entity: string): KitFailure =>
|
|
7
|
+
new KitFailure('not_found', 'Resource not found', { entity })
|
|
8
|
+
|
|
9
|
+
type ConversationRouteService = Pick<
|
|
10
|
+
ConversationService,
|
|
11
|
+
'list' | 'get' | 'delete' | 'rename' | 'editTurn' | 'removeTurn' | 'selectTurn' | 'fork'
|
|
12
|
+
>
|
|
13
|
+
type ImageResponse = (
|
|
14
|
+
id: string,
|
|
15
|
+
turnId: string,
|
|
16
|
+
kind: 'input' | 'generated',
|
|
17
|
+
index: number,
|
|
18
|
+
actor: Actor,
|
|
19
|
+
) => Promise<Response>
|
|
20
|
+
|
|
21
|
+
export async function handleConversationRequest(
|
|
22
|
+
service: ConversationRouteService,
|
|
23
|
+
request: Request,
|
|
24
|
+
context: WorkerContext,
|
|
25
|
+
imageResponse: ImageResponse,
|
|
26
|
+
): Promise<Response | null> {
|
|
27
|
+
const url = new URL(request.url),
|
|
28
|
+
base = '/api/v1/conversations',
|
|
29
|
+
actor = context.actor
|
|
30
|
+
if (url.pathname !== base && !url.pathname.startsWith(`${base}/`)) return null
|
|
31
|
+
const parts = url.pathname
|
|
32
|
+
.slice(base.length)
|
|
33
|
+
.split('/')
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map((part) => {
|
|
36
|
+
try {
|
|
37
|
+
return decodeURIComponent(part)
|
|
38
|
+
} catch {
|
|
39
|
+
throw badRequest('path', 'encoding')
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
const json = (value: unknown): Response => Response.json(value)
|
|
43
|
+
if (parts.length === 0 && request.method === 'GET')
|
|
44
|
+
return json(await service.list(url.searchParams.get('scope'), actor))
|
|
45
|
+
const id = parts[0]
|
|
46
|
+
if (!id) throw notFound('route')
|
|
47
|
+
if (parts.length === 1 && request.method === 'GET') return json(await service.get(id, actor))
|
|
48
|
+
if (parts.length === 1 && request.method === 'DELETE') {
|
|
49
|
+
await service.delete(id, actor)
|
|
50
|
+
return json({ ok: true })
|
|
51
|
+
}
|
|
52
|
+
if (
|
|
53
|
+
parts.length === 6 &&
|
|
54
|
+
parts[1] === 'turns' &&
|
|
55
|
+
parts[3] === 'images' &&
|
|
56
|
+
request.method === 'GET'
|
|
57
|
+
) {
|
|
58
|
+
const turnId = parts[2],
|
|
59
|
+
kind = parts[4],
|
|
60
|
+
index = parts[5]
|
|
61
|
+
if (
|
|
62
|
+
!turnId ||
|
|
63
|
+
(kind !== 'input' && kind !== 'generated') ||
|
|
64
|
+
!index ||
|
|
65
|
+
!/^(0|[1-9]\d*)$/u.test(index)
|
|
66
|
+
)
|
|
67
|
+
throw notFound('image')
|
|
68
|
+
return imageResponse(id, turnId, kind, Number(index), actor)
|
|
69
|
+
}
|
|
70
|
+
if (parts.length !== 2 || request.method !== 'POST') throw notFound('route')
|
|
71
|
+
let body: unknown
|
|
72
|
+
try {
|
|
73
|
+
body = await request.json()
|
|
74
|
+
} catch {
|
|
75
|
+
throw badRequest('body', 'json')
|
|
76
|
+
}
|
|
77
|
+
if (!isRecord(body)) throw badRequest('body', 'object')
|
|
78
|
+
const action = parts[1],
|
|
79
|
+
fields =
|
|
80
|
+
action === 'rename'
|
|
81
|
+
? ['title']
|
|
82
|
+
: action === 'edit-turn'
|
|
83
|
+
? ['turnId', 'text', 'expectedText']
|
|
84
|
+
: ['turnId']
|
|
85
|
+
if (Object.keys(body).some((key) => !fields.includes(key)))
|
|
86
|
+
throw badRequest('body', 'unknown_field')
|
|
87
|
+
if (action === 'rename') {
|
|
88
|
+
if (typeof body.title !== 'string') throw badRequest('title', 'required')
|
|
89
|
+
return json(await service.rename(id, body.title, actor))
|
|
90
|
+
}
|
|
91
|
+
if (typeof body.turnId !== 'string') throw badRequest('turnId', 'required')
|
|
92
|
+
if (action === 'edit-turn') {
|
|
93
|
+
if (typeof body.text !== 'string' || typeof body.expectedText !== 'string')
|
|
94
|
+
throw badRequest('text', 'required')
|
|
95
|
+
return json(await service.editTurn(id, body.turnId, body.text, body.expectedText, actor))
|
|
96
|
+
}
|
|
97
|
+
if (action === 'remove-turn') return json(await service.removeTurn(id, body.turnId, actor))
|
|
98
|
+
if (action === 'select-turn') return json(await service.selectTurn(id, body.turnId, actor))
|
|
99
|
+
if (action === 'fork') return json(await service.fork(id, body.turnId, actor))
|
|
100
|
+
throw notFound('route')
|
|
101
|
+
}
|