@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,402 @@
1
+ import type {
2
+ ContextUsage,
3
+ Conversation,
4
+ ConversationSummary,
5
+ Cost,
6
+ ImagePart,
7
+ ImageRef,
8
+ ReasoningEffort,
9
+ SkillRef,
10
+ TokenUsage,
11
+ TurnStatus,
12
+ } from '@ouraihub/aether-contracts'
13
+ import type { Message, ReasoningBlock, ToolCall } from '@ouraihub/aether-core/types'
14
+ import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
15
+ import { charCount, isRecord } from '@ouraihub/aether-core/guard'
16
+ import { decodeImage, IMAGE_MIMES, parseImage } from '@ouraihub/aether-core/images'
17
+
18
+ export const COLLECTION = 'conversations'
19
+ export const IMAGE_TTL_DAYS = 7
20
+ export interface StoredImage {
21
+ mime: string
22
+ data: string | null
23
+ createdAt: number
24
+ }
25
+ export interface StoredTurn {
26
+ id: string
27
+ seq: number
28
+ runId: string
29
+ reservationId: string
30
+ parentId: string | null
31
+ questionId: string
32
+ supersedesId: string | null
33
+ kind: 'reply' | 'translation'
34
+ status: TurnStatus
35
+ input: string
36
+ messages: Message[]
37
+ modelId: string | null
38
+ endpointId: string | null
39
+ skills: SkillRef[]
40
+ errorCode: string | null
41
+ usage: TokenUsage
42
+ contextUsage: ContextUsage | null
43
+ reasoningEffort: ReasoningEffort | null
44
+ cost: Cost | null
45
+ createdAt: number
46
+ images: StoredImage[]
47
+ generated: StoredImage[]
48
+ }
49
+ export interface ConversationDocument {
50
+ version: 1
51
+ summary: ConversationSummary
52
+ turns: StoredTurn[]
53
+ }
54
+ export const conflict = (reason: string): KitFailure =>
55
+ new KitFailure('conflict', 'Conversation changed or unavailable', { reason })
56
+ export const notFound = (entity: string): KitFailure => new KitFailure('not_found', 'Resource not found', { entity })
57
+ const corrupt = (): never => {
58
+ throw new KitFailure('incompatible_db', 'Invalid stored conversation')
59
+ }
60
+ export const emptyUsage = (): TokenUsage => ({
61
+ inputTokens: null,
62
+ outputTokens: null,
63
+ cacheReadTokens: null,
64
+ cacheWriteTokens: null,
65
+ reasoningTokens: null,
66
+ })
67
+ export function checkId(field: string, id: string): void {
68
+ if (!id.trim() || charCount(id) > 128) throw badRequest(field, 'invalid')
69
+ }
70
+ export function checkText(field: string, text: string, limit: number, nonempty = false): void {
71
+ if (charCount(text) > limit) throw badRequest(field, 'too_long', limit)
72
+ if (nonempty && !text.trim()) throw badRequest(field, 'empty')
73
+ }
74
+ function record(value: unknown): Record<string, unknown> {
75
+ return isRecord(value) ? value : corrupt()
76
+ }
77
+ function text(value: unknown): string {
78
+ return typeof value === 'string' ? value : corrupt()
79
+ }
80
+ function nullableText(value: unknown): string | null {
81
+ return value === null ? null : text(value)
82
+ }
83
+ function integer(value: unknown): number {
84
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : corrupt()
85
+ }
86
+ function count(value: unknown): number | null {
87
+ const parsed = value === null ? null : integer(value)
88
+ return parsed === null || parsed <= 0xffff_ffff ? parsed : corrupt()
89
+ }
90
+ function array<T>(value: unknown, parse: (item: unknown) => T): T[] {
91
+ return Array.isArray(value) ? value.map(parse) : corrupt()
92
+ }
93
+ function effort(value: unknown): ReasoningEffort | null {
94
+ switch (value) {
95
+ case null:
96
+ case 'default':
97
+ case 'none':
98
+ case 'auto':
99
+ case 'minimal':
100
+ case 'low':
101
+ case 'medium':
102
+ case 'high':
103
+ case 'xhigh':
104
+ case 'max':
105
+ return value
106
+ default:
107
+ return corrupt()
108
+ }
109
+ }
110
+ function usage(value: unknown): TokenUsage {
111
+ const v = record(value)
112
+ return {
113
+ inputTokens: count(v.inputTokens),
114
+ outputTokens: count(v.outputTokens),
115
+ cacheReadTokens: count(v.cacheReadTokens),
116
+ cacheWriteTokens: count(v.cacheWriteTokens),
117
+ reasoningTokens: count(v.reasoningTokens),
118
+ }
119
+ }
120
+ function context(value: unknown): ContextUsage | null {
121
+ if (value === null) return null
122
+ const v = record(value),
123
+ source = v.source
124
+ if (source !== 'reported' && source !== 'estimated' && source !== 'unknown') return corrupt()
125
+ return {
126
+ tokens: count(v.tokens),
127
+ contextWindow: count(v.contextWindow),
128
+ source,
129
+ endpointId: nullableText(v.endpointId),
130
+ modelId: nullableText(v.modelId),
131
+ }
132
+ }
133
+ function cost(value: unknown): Cost | null {
134
+ if (value === null) return null
135
+ const v = record(value),
136
+ currency = v.currency
137
+ if (
138
+ typeof v.amount !== 'number' ||
139
+ !Number.isFinite(v.amount) ||
140
+ v.amount < 0 ||
141
+ (currency !== 'USD' && currency !== 'CNY')
142
+ )
143
+ return corrupt()
144
+ return { amount: v.amount, currency }
145
+ }
146
+ function image(value: unknown): StoredImage {
147
+ const v = record(value),
148
+ mime = text(v.mime),
149
+ data = nullableText(v.data)
150
+ if (!IMAGE_MIMES.some((value) => value === mime)) return corrupt()
151
+ if (data !== null) {
152
+ try {
153
+ decodeImage({ mime, data })
154
+ } catch {
155
+ return corrupt()
156
+ }
157
+ }
158
+ return { mime, data, createdAt: integer(v.createdAt) }
159
+ }
160
+ export function validateImages(images: readonly ImagePart[]): void {
161
+ if (images.length > 4) throw badRequest('images', 'too_many', 4)
162
+ for (const image of images) parseImage(image)
163
+ }
164
+ function reasoning(value: unknown): ReasoningBlock {
165
+ const v = record(value),
166
+ protocol = v.protocol
167
+ if (protocol !== 'openai-chat' && protocol !== 'openai-responses' && protocol !== 'anthropic-messages')
168
+ return corrupt()
169
+ return { protocol, endpointId: nullableText(v.endpointId), modelId: nullableText(v.modelId), value: json(v.value) }
170
+ }
171
+ function json(value: unknown, depth = 0): unknown {
172
+ if (depth > 64) return corrupt()
173
+ if (
174
+ value === null ||
175
+ typeof value === 'string' ||
176
+ typeof value === 'boolean' ||
177
+ (typeof value === 'number' && Number.isFinite(value))
178
+ )
179
+ return value
180
+ if (Array.isArray(value)) return value.map((item: unknown) => json(item, depth + 1))
181
+ if (isRecord(value))
182
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, json(item, depth + 1)]))
183
+ return corrupt()
184
+ }
185
+ function call(value: unknown): ToolCall {
186
+ const v = record(value)
187
+ return { id: text(v.id), name: text(v.name), args: jsonRecord(v.args) }
188
+ }
189
+ function jsonRecord(value: unknown): Record<string, unknown> {
190
+ const v = record(value)
191
+ return Object.fromEntries(Object.entries(v).map(([key, item]) => [key, json(item)]))
192
+ }
193
+ export function parseMessage(value: unknown): Message {
194
+ const v = record(value)
195
+ if (['user', 'assistant', 'tool'].filter((key) => Object.hasOwn(v, key)).length !== 1) return corrupt()
196
+ if (typeof v.user === 'string') {
197
+ if (v.images === undefined) return { user: v.user }
198
+ return {
199
+ user: v.user,
200
+ images: array(v.images, (item) => {
201
+ try {
202
+ return parseImage(item)
203
+ } catch {
204
+ return corrupt()
205
+ }
206
+ }),
207
+ }
208
+ }
209
+ if (isRecord(v.assistant)) {
210
+ const a = v.assistant
211
+ return {
212
+ assistant: {
213
+ text: text(a.text),
214
+ tool_calls: array(a.tool_calls, call),
215
+ ...(a.reasoning === undefined ? {} : { reasoning: array(a.reasoning, reasoning) }),
216
+ },
217
+ }
218
+ }
219
+ if (isRecord(v.tool)) return { tool: { call_id: text(v.tool.call_id), text: text(v.tool.text) } }
220
+ return corrupt()
221
+ }
222
+ export function parseDocument(value: unknown, key: string): ConversationDocument {
223
+ const v = record(value),
224
+ s = record(v.summary)
225
+ if (v.version !== 1 || s.id !== key) return corrupt()
226
+ const summary: ConversationSummary = {
227
+ id: text(s.id),
228
+ title: text(s.title),
229
+ scope: nullableText(s.scope),
230
+ selectedTurnId: nullableText(s.selectedTurnId),
231
+ createdAt: integer(s.createdAt),
232
+ updatedAt: integer(s.updatedAt),
233
+ }
234
+ if (
235
+ !summary.id.trim() ||
236
+ charCount(summary.id) > 128 ||
237
+ charCount(summary.title) > 200 ||
238
+ (summary.scope !== null && charCount(summary.scope) > 200)
239
+ )
240
+ return corrupt()
241
+ const turns = array(v.turns, (item): StoredTurn => {
242
+ const t = record(item),
243
+ status = t.status,
244
+ kind = t.kind
245
+ if (
246
+ (status !== 'running' && status !== 'completed' && status !== 'failed' && status !== 'cancelled') ||
247
+ (kind !== 'reply' && kind !== 'translation')
248
+ )
249
+ return corrupt()
250
+ return {
251
+ id: text(t.id),
252
+ seq: integer(t.seq),
253
+ runId: text(t.runId),
254
+ reservationId: text(t.reservationId),
255
+ parentId: nullableText(t.parentId),
256
+ questionId: text(t.questionId),
257
+ supersedesId: nullableText(t.supersedesId),
258
+ kind,
259
+ status,
260
+ input: text(t.input),
261
+ messages: array(t.messages, parseMessage),
262
+ modelId: nullableText(t.modelId),
263
+ endpointId: nullableText(t.endpointId),
264
+ skills: array(t.skills, (item) => {
265
+ const skill = record(item)
266
+ return { id: text(skill.id), revision: integer(skill.revision) }
267
+ }),
268
+ errorCode: nullableText(t.errorCode),
269
+ usage: usage(t.usage),
270
+ contextUsage: context(t.contextUsage),
271
+ reasoningEffort: effort(t.reasoningEffort),
272
+ cost: cost(t.cost),
273
+ createdAt: integer(t.createdAt),
274
+ images: array(t.images, image),
275
+ generated: array(t.generated, image),
276
+ }
277
+ })
278
+ const ids = new Set<string>(),
279
+ seqs = new Set<number>()
280
+ let running = 0,
281
+ last = 0
282
+ 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) return corrupt()
284
+ if (
285
+ !t.questionId.trim() ||
286
+ charCount(t.questionId) > 128 ||
287
+ !t.runId.trim() ||
288
+ !t.reservationId.trim() ||
289
+ charCount(t.input) > 100_000 ||
290
+ t.images.length > 4 ||
291
+ t.skills.length > 20
292
+ )
293
+ return corrupt()
294
+ ids.add(t.id)
295
+ seqs.add(t.seq)
296
+ last = t.seq
297
+ if (t.status === 'running') running++
298
+ }
299
+ if (running > 1 || (summary.selectedTurnId !== null && !ids.has(summary.selectedTurnId))) return corrupt()
300
+ for (const t of turns) {
301
+ if (
302
+ (t.parentId !== null && !ids.has(t.parentId)) ||
303
+ (t.supersedesId !== null && (!ids.has(t.supersedesId) || t.supersedesId === t.id))
304
+ )
305
+ return corrupt()
306
+ const seen = new Set<string>()
307
+ let node: StoredTurn | undefined = t
308
+ while (node) {
309
+ if (seen.has(node.id)) return corrupt()
310
+ seen.add(node.id)
311
+ node = turns.find((p) => p.id === node?.parentId)
312
+ }
313
+ }
314
+ return { version: 1, summary, turns }
315
+ }
316
+ export function idle(document: ConversationDocument): void {
317
+ if (document.turns.some((t) => t.status === 'running')) throw conflict('turn_running')
318
+ }
319
+ export function target(document: ConversationDocument, id: string): StoredTurn {
320
+ checkId('turnId', id)
321
+ const turn = document.turns.find((t) => t.id === id)
322
+ if (!turn) throw notFound('turn')
323
+ return turn
324
+ }
325
+ export function visibleText(turn: StoredTurn): string {
326
+ return turn.messages.flatMap((m) => ('assistant' in m && m.assistant.text ? [m.assistant.text] : [])).join('\n\n')
327
+ }
328
+ export function reasoningText(turn: StoredTurn): string {
329
+ let text = ''
330
+ for (const message of turn.messages) {
331
+ if (!('assistant' in message)) continue
332
+ for (const block of message.assistant.reasoning ?? []) {
333
+ if (block.protocol === 'openai-chat' && typeof block.value === 'string') text += block.value
334
+ else if (
335
+ block.protocol === 'anthropic-messages' &&
336
+ isRecord(block.value) &&
337
+ block.value.type === 'thinking' &&
338
+ typeof block.value.thinking === 'string'
339
+ )
340
+ text += block.value.thinking
341
+ else if (block.protocol === 'openai-responses' && isRecord(block.value) && Array.isArray(block.value.summary)) {
342
+ for (const part of block.value.summary)
343
+ if (isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string') text += part.text
344
+ }
345
+ }
346
+ }
347
+ return text
348
+ }
349
+ export function historyPath(document: ConversationDocument, head: string | null): StoredTurn[] {
350
+ const path: StoredTurn[] = [],
351
+ seen = new Set<string>()
352
+ while (head !== null) {
353
+ if (seen.has(head)) throw conflict('history_cycle')
354
+ seen.add(head)
355
+ const turn = target(document, head)
356
+ path.push(turn)
357
+ head = turn.parentId
358
+ }
359
+ return path.reverse()
360
+ }
361
+ export function imageParts(images: readonly StoredImage[]): ImagePart[] {
362
+ return images.flatMap((i) => (i.data === null ? [] : [{ mime: i.mime, data: i.data }]))
363
+ }
364
+ export function imageRefs(
365
+ conversationId: string,
366
+ turnId: string,
367
+ kind: 'input' | 'generated',
368
+ images: readonly StoredImage[],
369
+ ): ImageRef[] {
370
+ return images.map((image, index) => ({
371
+ mime: image.mime,
372
+ url:
373
+ image.data === null
374
+ ? null
375
+ : `/api/v1/conversations/${encodeURIComponent(conversationId)}/turns/${encodeURIComponent(turnId)}/images/${kind}/${index}`,
376
+ }))
377
+ }
378
+ export function project(document: ConversationDocument): Conversation {
379
+ return {
380
+ summary: document.summary,
381
+ turns: document.turns.map((t) => ({
382
+ id: t.id,
383
+ parentId: t.parentId,
384
+ questionId: t.questionId,
385
+ supersedesId: t.supersedesId,
386
+ kind: t.kind,
387
+ status: t.status,
388
+ input: t.input,
389
+ text: visibleText(t),
390
+ modelId: t.modelId,
391
+ endpointId: t.endpointId,
392
+ errorCode: t.errorCode,
393
+ usage: t.usage,
394
+ contextUsage: t.contextUsage,
395
+ reasoningEffort: t.reasoningEffort,
396
+ cost: t.cost,
397
+ reasoningText: reasoningText(t),
398
+ images: imageRefs(document.summary.id, t.id, 'input', t.images),
399
+ generated: imageRefs(document.summary.id, t.id, 'generated', t.generated),
400
+ })),
401
+ }
402
+ }