@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.
- package/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +38 -3
- package/src/app.ts +363 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +55 -0
- package/src/conversations/action.ts +257 -0
- package/src/conversations/index.ts +480 -0
- package/src/conversations/model.ts +427 -0
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +488 -0
- package/src/credentials.ts +171 -0
- package/src/files.ts +117 -0
- package/src/host.ts +25 -0
- package/src/identity.ts +227 -0
- package/src/images/index.ts +73 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +98 -0
- package/src/mcp/auth.ts +398 -0
- package/src/mcp/client.ts +491 -0
- package/src/mcp/common.ts +124 -0
- package/src/mcp/index.ts +292 -0
- package/src/mcp/oauth.ts +343 -0
- package/src/mcp/types.ts +81 -0
- package/src/outbound.ts +320 -0
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +445 -0
- 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 +476 -0
- package/src/search/index.ts +497 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +153 -0
- package/src/skills/service.ts +250 -0
- package/src/storage.ts +293 -0
- package/src/testing/fakes.ts +97 -0
- package/README.md +0 -3
|
@@ -0,0 +1,427 @@
|
|
|
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 =>
|
|
57
|
+
new KitFailure('not_found', 'Resource not found', { entity })
|
|
58
|
+
const corrupt = (): never => {
|
|
59
|
+
throw new KitFailure('incompatible_db', 'Invalid stored conversation')
|
|
60
|
+
}
|
|
61
|
+
export const emptyUsage = (): TokenUsage => ({
|
|
62
|
+
inputTokens: null,
|
|
63
|
+
outputTokens: null,
|
|
64
|
+
cacheReadTokens: null,
|
|
65
|
+
cacheWriteTokens: null,
|
|
66
|
+
reasoningTokens: null,
|
|
67
|
+
})
|
|
68
|
+
export function checkId(field: string, id: string): void {
|
|
69
|
+
if (!id.trim() || charCount(id) > 128) throw badRequest(field, 'invalid')
|
|
70
|
+
}
|
|
71
|
+
export function checkText(field: string, text: string, limit: number, nonempty = false): void {
|
|
72
|
+
if (charCount(text) > limit) throw badRequest(field, 'too_long', limit)
|
|
73
|
+
if (nonempty && !text.trim()) throw badRequest(field, 'empty')
|
|
74
|
+
}
|
|
75
|
+
function record(value: unknown): Record<string, unknown> {
|
|
76
|
+
return isRecord(value) ? value : corrupt()
|
|
77
|
+
}
|
|
78
|
+
function text(value: unknown): string {
|
|
79
|
+
return typeof value === 'string' ? value : corrupt()
|
|
80
|
+
}
|
|
81
|
+
function nullableText(value: unknown): string | null {
|
|
82
|
+
return value === null ? null : text(value)
|
|
83
|
+
}
|
|
84
|
+
function integer(value: unknown): number {
|
|
85
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : corrupt()
|
|
86
|
+
}
|
|
87
|
+
function count(value: unknown): number | null {
|
|
88
|
+
const parsed = value === null ? null : integer(value)
|
|
89
|
+
return parsed === null || parsed <= 0xffff_ffff ? parsed : corrupt()
|
|
90
|
+
}
|
|
91
|
+
function array<T>(value: unknown, parse: (item: unknown) => T): T[] {
|
|
92
|
+
return Array.isArray(value) ? value.map(parse) : corrupt()
|
|
93
|
+
}
|
|
94
|
+
function effort(value: unknown): ReasoningEffort | null {
|
|
95
|
+
switch (value) {
|
|
96
|
+
case null:
|
|
97
|
+
case 'default':
|
|
98
|
+
case 'none':
|
|
99
|
+
case 'auto':
|
|
100
|
+
case 'minimal':
|
|
101
|
+
case 'low':
|
|
102
|
+
case 'medium':
|
|
103
|
+
case 'high':
|
|
104
|
+
case 'xhigh':
|
|
105
|
+
case 'max':
|
|
106
|
+
return value
|
|
107
|
+
default:
|
|
108
|
+
return corrupt()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function usage(value: unknown): TokenUsage {
|
|
112
|
+
const v = record(value)
|
|
113
|
+
return {
|
|
114
|
+
inputTokens: count(v.inputTokens),
|
|
115
|
+
outputTokens: count(v.outputTokens),
|
|
116
|
+
cacheReadTokens: count(v.cacheReadTokens),
|
|
117
|
+
cacheWriteTokens: count(v.cacheWriteTokens),
|
|
118
|
+
reasoningTokens: count(v.reasoningTokens),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function context(value: unknown): ContextUsage | null {
|
|
122
|
+
if (value === null) return null
|
|
123
|
+
const v = record(value),
|
|
124
|
+
source = v.source
|
|
125
|
+
if (source !== 'reported' && source !== 'estimated' && source !== 'unknown') return corrupt()
|
|
126
|
+
return {
|
|
127
|
+
tokens: count(v.tokens),
|
|
128
|
+
contextWindow: count(v.contextWindow),
|
|
129
|
+
source,
|
|
130
|
+
endpointId: nullableText(v.endpointId),
|
|
131
|
+
modelId: nullableText(v.modelId),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function cost(value: unknown): Cost | null {
|
|
135
|
+
if (value === null) return null
|
|
136
|
+
const v = record(value),
|
|
137
|
+
currency = v.currency
|
|
138
|
+
if (
|
|
139
|
+
typeof v.amount !== 'number' ||
|
|
140
|
+
!Number.isFinite(v.amount) ||
|
|
141
|
+
v.amount < 0 ||
|
|
142
|
+
(currency !== 'USD' && currency !== 'CNY')
|
|
143
|
+
)
|
|
144
|
+
return corrupt()
|
|
145
|
+
return { amount: v.amount, currency }
|
|
146
|
+
}
|
|
147
|
+
function image(value: unknown): StoredImage {
|
|
148
|
+
const v = record(value),
|
|
149
|
+
mime = text(v.mime),
|
|
150
|
+
data = nullableText(v.data)
|
|
151
|
+
if (!IMAGE_MIMES.some((value) => value === mime)) return corrupt()
|
|
152
|
+
if (data !== null) {
|
|
153
|
+
try {
|
|
154
|
+
decodeImage({ mime, data })
|
|
155
|
+
} catch {
|
|
156
|
+
return corrupt()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { mime, data, createdAt: integer(v.createdAt) }
|
|
160
|
+
}
|
|
161
|
+
export function validateImages(images: readonly ImagePart[]): void {
|
|
162
|
+
if (images.length > 4) throw badRequest('images', 'too_many', 4)
|
|
163
|
+
for (const image of images) parseImage(image)
|
|
164
|
+
}
|
|
165
|
+
function reasoning(value: unknown): ReasoningBlock {
|
|
166
|
+
const v = record(value),
|
|
167
|
+
protocol = v.protocol
|
|
168
|
+
if (
|
|
169
|
+
protocol !== 'openai-chat' &&
|
|
170
|
+
protocol !== 'openai-responses' &&
|
|
171
|
+
protocol !== 'anthropic-messages'
|
|
172
|
+
)
|
|
173
|
+
return corrupt()
|
|
174
|
+
return {
|
|
175
|
+
protocol,
|
|
176
|
+
endpointId: nullableText(v.endpointId),
|
|
177
|
+
modelId: nullableText(v.modelId),
|
|
178
|
+
value: json(v.value),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function json(value: unknown, depth = 0): unknown {
|
|
182
|
+
if (depth > 64) return corrupt()
|
|
183
|
+
if (
|
|
184
|
+
value === null ||
|
|
185
|
+
typeof value === 'string' ||
|
|
186
|
+
typeof value === 'boolean' ||
|
|
187
|
+
(typeof value === 'number' && Number.isFinite(value))
|
|
188
|
+
)
|
|
189
|
+
return value
|
|
190
|
+
if (Array.isArray(value)) return value.map((item: unknown) => json(item, depth + 1))
|
|
191
|
+
if (isRecord(value))
|
|
192
|
+
return Object.fromEntries(
|
|
193
|
+
Object.entries(value).map(([key, item]) => [key, json(item, depth + 1)]),
|
|
194
|
+
)
|
|
195
|
+
return corrupt()
|
|
196
|
+
}
|
|
197
|
+
function call(value: unknown): ToolCall {
|
|
198
|
+
const v = record(value)
|
|
199
|
+
return { id: text(v.id), name: text(v.name), args: jsonRecord(v.args) }
|
|
200
|
+
}
|
|
201
|
+
function jsonRecord(value: unknown): Record<string, unknown> {
|
|
202
|
+
const v = record(value)
|
|
203
|
+
return Object.fromEntries(Object.entries(v).map(([key, item]) => [key, json(item)]))
|
|
204
|
+
}
|
|
205
|
+
export function parseMessage(value: unknown): Message {
|
|
206
|
+
const v = record(value)
|
|
207
|
+
if (['user', 'assistant', 'tool'].filter((key) => Object.hasOwn(v, key)).length !== 1)
|
|
208
|
+
return corrupt()
|
|
209
|
+
if (typeof v.user === 'string') {
|
|
210
|
+
if (v.images === undefined) return { user: v.user }
|
|
211
|
+
return {
|
|
212
|
+
user: v.user,
|
|
213
|
+
images: array(v.images, (item) => {
|
|
214
|
+
try {
|
|
215
|
+
return parseImage(item)
|
|
216
|
+
} catch {
|
|
217
|
+
return corrupt()
|
|
218
|
+
}
|
|
219
|
+
}),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (isRecord(v.assistant)) {
|
|
223
|
+
const a = v.assistant
|
|
224
|
+
return {
|
|
225
|
+
assistant: {
|
|
226
|
+
text: text(a.text),
|
|
227
|
+
tool_calls: array(a.tool_calls, call),
|
|
228
|
+
...(a.reasoning === undefined ? {} : { reasoning: array(a.reasoning, reasoning) }),
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (isRecord(v.tool)) return { tool: { call_id: text(v.tool.call_id), text: text(v.tool.text) } }
|
|
233
|
+
return corrupt()
|
|
234
|
+
}
|
|
235
|
+
export function parseDocument(value: unknown, key: string): ConversationDocument {
|
|
236
|
+
const v = record(value),
|
|
237
|
+
s = record(v.summary)
|
|
238
|
+
if (v.version !== 1 || s.id !== key) return corrupt()
|
|
239
|
+
const summary: ConversationSummary = {
|
|
240
|
+
id: text(s.id),
|
|
241
|
+
title: text(s.title),
|
|
242
|
+
scope: nullableText(s.scope),
|
|
243
|
+
selectedTurnId: nullableText(s.selectedTurnId),
|
|
244
|
+
createdAt: integer(s.createdAt),
|
|
245
|
+
updatedAt: integer(s.updatedAt),
|
|
246
|
+
}
|
|
247
|
+
if (
|
|
248
|
+
!summary.id.trim() ||
|
|
249
|
+
charCount(summary.id) > 128 ||
|
|
250
|
+
charCount(summary.title) > 200 ||
|
|
251
|
+
(summary.scope !== null && charCount(summary.scope) > 200)
|
|
252
|
+
)
|
|
253
|
+
return corrupt()
|
|
254
|
+
const turns = array(v.turns, (item): StoredTurn => {
|
|
255
|
+
const t = record(item),
|
|
256
|
+
status = t.status,
|
|
257
|
+
kind = t.kind
|
|
258
|
+
if (
|
|
259
|
+
(status !== 'running' &&
|
|
260
|
+
status !== 'completed' &&
|
|
261
|
+
status !== 'failed' &&
|
|
262
|
+
status !== 'cancelled') ||
|
|
263
|
+
(kind !== 'reply' && kind !== 'translation')
|
|
264
|
+
)
|
|
265
|
+
return corrupt()
|
|
266
|
+
return {
|
|
267
|
+
id: text(t.id),
|
|
268
|
+
seq: integer(t.seq),
|
|
269
|
+
runId: text(t.runId),
|
|
270
|
+
reservationId: text(t.reservationId),
|
|
271
|
+
parentId: nullableText(t.parentId),
|
|
272
|
+
questionId: text(t.questionId),
|
|
273
|
+
supersedesId: nullableText(t.supersedesId),
|
|
274
|
+
kind,
|
|
275
|
+
status,
|
|
276
|
+
input: text(t.input),
|
|
277
|
+
messages: array(t.messages, parseMessage),
|
|
278
|
+
modelId: nullableText(t.modelId),
|
|
279
|
+
endpointId: nullableText(t.endpointId),
|
|
280
|
+
skills: array(t.skills, (item) => {
|
|
281
|
+
const skill = record(item)
|
|
282
|
+
return { id: text(skill.id), revision: integer(skill.revision) }
|
|
283
|
+
}),
|
|
284
|
+
errorCode: nullableText(t.errorCode),
|
|
285
|
+
usage: usage(t.usage),
|
|
286
|
+
contextUsage: context(t.contextUsage),
|
|
287
|
+
reasoningEffort: effort(t.reasoningEffort),
|
|
288
|
+
cost: cost(t.cost),
|
|
289
|
+
createdAt: integer(t.createdAt),
|
|
290
|
+
images: array(t.images, image),
|
|
291
|
+
generated: array(t.generated, image),
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
const ids = new Set<string>(),
|
|
295
|
+
seqs = new Set<number>()
|
|
296
|
+
let running = 0,
|
|
297
|
+
last = 0
|
|
298
|
+
for (const t of turns) {
|
|
299
|
+
if (!t.id.trim() || charCount(t.id) > 128 || ids.has(t.id) || seqs.has(t.seq) || t.seq <= last)
|
|
300
|
+
return corrupt()
|
|
301
|
+
if (
|
|
302
|
+
!t.questionId.trim() ||
|
|
303
|
+
charCount(t.questionId) > 128 ||
|
|
304
|
+
!t.runId.trim() ||
|
|
305
|
+
!t.reservationId.trim() ||
|
|
306
|
+
charCount(t.input) > 100_000 ||
|
|
307
|
+
t.images.length > 4 ||
|
|
308
|
+
t.skills.length > 20
|
|
309
|
+
)
|
|
310
|
+
return corrupt()
|
|
311
|
+
ids.add(t.id)
|
|
312
|
+
seqs.add(t.seq)
|
|
313
|
+
last = t.seq
|
|
314
|
+
if (t.status === 'running') running++
|
|
315
|
+
}
|
|
316
|
+
if (running > 1 || (summary.selectedTurnId !== null && !ids.has(summary.selectedTurnId)))
|
|
317
|
+
return corrupt()
|
|
318
|
+
for (const t of turns) {
|
|
319
|
+
if (
|
|
320
|
+
(t.parentId !== null && !ids.has(t.parentId)) ||
|
|
321
|
+
(t.supersedesId !== null && (!ids.has(t.supersedesId) || t.supersedesId === t.id))
|
|
322
|
+
)
|
|
323
|
+
return corrupt()
|
|
324
|
+
const seen = new Set<string>()
|
|
325
|
+
let node: StoredTurn | undefined = t
|
|
326
|
+
while (node) {
|
|
327
|
+
if (seen.has(node.id)) return corrupt()
|
|
328
|
+
seen.add(node.id)
|
|
329
|
+
node = turns.find((p) => p.id === node?.parentId)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { version: 1, summary, turns }
|
|
333
|
+
}
|
|
334
|
+
export function idle(document: ConversationDocument): void {
|
|
335
|
+
if (document.turns.some((t) => t.status === 'running')) throw conflict('turn_running')
|
|
336
|
+
}
|
|
337
|
+
export function target(document: ConversationDocument, id: string): StoredTurn {
|
|
338
|
+
checkId('turnId', id)
|
|
339
|
+
const turn = document.turns.find((t) => t.id === id)
|
|
340
|
+
if (!turn) throw notFound('turn')
|
|
341
|
+
return turn
|
|
342
|
+
}
|
|
343
|
+
export function visibleText(turn: StoredTurn): string {
|
|
344
|
+
return turn.messages
|
|
345
|
+
.flatMap((m) => ('assistant' in m && m.assistant.text ? [m.assistant.text] : []))
|
|
346
|
+
.join('\n\n')
|
|
347
|
+
}
|
|
348
|
+
export function reasoningText(turn: StoredTurn): string {
|
|
349
|
+
let text = ''
|
|
350
|
+
for (const message of turn.messages) {
|
|
351
|
+
if (!('assistant' in message)) continue
|
|
352
|
+
for (const block of message.assistant.reasoning ?? []) {
|
|
353
|
+
if (block.protocol === 'openai-chat' && typeof block.value === 'string') text += block.value
|
|
354
|
+
else if (
|
|
355
|
+
block.protocol === 'anthropic-messages' &&
|
|
356
|
+
isRecord(block.value) &&
|
|
357
|
+
block.value.type === 'thinking' &&
|
|
358
|
+
typeof block.value.thinking === 'string'
|
|
359
|
+
)
|
|
360
|
+
text += block.value.thinking
|
|
361
|
+
else if (
|
|
362
|
+
block.protocol === 'openai-responses' &&
|
|
363
|
+
isRecord(block.value) &&
|
|
364
|
+
Array.isArray(block.value.summary)
|
|
365
|
+
) {
|
|
366
|
+
for (const part of block.value.summary)
|
|
367
|
+
if (isRecord(part) && part.type === 'summary_text' && typeof part.text === 'string')
|
|
368
|
+
text += part.text
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return text
|
|
373
|
+
}
|
|
374
|
+
export function historyPath(document: ConversationDocument, head: string | null): StoredTurn[] {
|
|
375
|
+
const path: StoredTurn[] = [],
|
|
376
|
+
seen = new Set<string>()
|
|
377
|
+
while (head !== null) {
|
|
378
|
+
if (seen.has(head)) throw conflict('history_cycle')
|
|
379
|
+
seen.add(head)
|
|
380
|
+
const turn = target(document, head)
|
|
381
|
+
path.push(turn)
|
|
382
|
+
head = turn.parentId
|
|
383
|
+
}
|
|
384
|
+
return path.reverse()
|
|
385
|
+
}
|
|
386
|
+
export function imageParts(images: readonly StoredImage[]): ImagePart[] {
|
|
387
|
+
return images.flatMap((i) => (i.data === null ? [] : [{ mime: i.mime, data: i.data }]))
|
|
388
|
+
}
|
|
389
|
+
export function imageRefs(
|
|
390
|
+
conversationId: string,
|
|
391
|
+
turnId: string,
|
|
392
|
+
kind: 'input' | 'generated',
|
|
393
|
+
images: readonly StoredImage[],
|
|
394
|
+
): ImageRef[] {
|
|
395
|
+
return images.map((image, index) => ({
|
|
396
|
+
mime: image.mime,
|
|
397
|
+
url:
|
|
398
|
+
image.data === null
|
|
399
|
+
? null
|
|
400
|
+
: `/api/v1/conversations/${encodeURIComponent(conversationId)}/turns/${encodeURIComponent(turnId)}/images/${kind}/${index}`,
|
|
401
|
+
}))
|
|
402
|
+
}
|
|
403
|
+
export function project(document: ConversationDocument): Conversation {
|
|
404
|
+
return {
|
|
405
|
+
summary: document.summary,
|
|
406
|
+
turns: document.turns.map((t) => ({
|
|
407
|
+
id: t.id,
|
|
408
|
+
parentId: t.parentId,
|
|
409
|
+
questionId: t.questionId,
|
|
410
|
+
supersedesId: t.supersedesId,
|
|
411
|
+
kind: t.kind,
|
|
412
|
+
status: t.status,
|
|
413
|
+
input: t.input,
|
|
414
|
+
text: visibleText(t),
|
|
415
|
+
modelId: t.modelId,
|
|
416
|
+
endpointId: t.endpointId,
|
|
417
|
+
errorCode: t.errorCode,
|
|
418
|
+
usage: t.usage,
|
|
419
|
+
contextUsage: t.contextUsage,
|
|
420
|
+
reasoningEffort: t.reasoningEffort,
|
|
421
|
+
cost: t.cost,
|
|
422
|
+
reasoningText: reasoningText(t),
|
|
423
|
+
images: imageRefs(document.summary.id, t.id, 'input', t.images),
|
|
424
|
+
generated: imageRefs(document.summary.id, t.id, 'generated', t.generated),
|
|
425
|
+
})),
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isContract,
|
|
3
|
+
type EndStatus,
|
|
4
|
+
type KitError,
|
|
5
|
+
type RunEvent,
|
|
6
|
+
type RunSnapshot,
|
|
7
|
+
} from '@ouraihub/aether-contracts'
|
|
8
|
+
import { KitFailure } from '@ouraihub/aether-core'
|
|
9
|
+
import { immutableActor, isActor, type Actor } from './context.js'
|
|
10
|
+
import type { AcceptedHandle } from './conversations/index.js'
|
|
11
|
+
|
|
12
|
+
export type Phase = 'creating' | 'accepted' | 'running' | EndStatus
|
|
13
|
+
export interface Metadata {
|
|
14
|
+
phase: Phase
|
|
15
|
+
runId: string
|
|
16
|
+
actor: Actor
|
|
17
|
+
handle: AcceptedHandle | null
|
|
18
|
+
snapshot: RunSnapshot
|
|
19
|
+
revision: number
|
|
20
|
+
acceptedUntil: number | null
|
|
21
|
+
pending: { callId: string; revision: number } | null
|
|
22
|
+
}
|
|
23
|
+
export interface Pending {
|
|
24
|
+
callId: string
|
|
25
|
+
revision: number
|
|
26
|
+
decision: boolean | undefined
|
|
27
|
+
resolve: ((decision: boolean) => void) | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const storageKey = 'run'
|
|
31
|
+
export const encoder = new TextEncoder()
|
|
32
|
+
export const conflict = (reason: string): KitFailure =>
|
|
33
|
+
new KitFailure('conflict', 'Run state conflict', { reason })
|
|
34
|
+
export const missing = (): KitFailure => new KitFailure('not_found', 'Run not found')
|
|
35
|
+
export const interrupted = (newId: () => string): KitError => ({
|
|
36
|
+
code: 'internal',
|
|
37
|
+
message: 'Run interrupted',
|
|
38
|
+
details: { reason: 'interrupted', errorId: newId() },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** No arbitrary messages/details cross this boundary, even from host KitFailures. */
|
|
42
|
+
export function publicError(error: unknown, newId: () => string): KitError {
|
|
43
|
+
const code =
|
|
44
|
+
error instanceof KitFailure
|
|
45
|
+
? error.code
|
|
46
|
+
: isContract('KitError', error)
|
|
47
|
+
? error.code
|
|
48
|
+
: 'internal'
|
|
49
|
+
const messages: Record<KitError['code'], string> = {
|
|
50
|
+
bad_request: 'Invalid request',
|
|
51
|
+
not_found: 'Run not found',
|
|
52
|
+
conflict: 'Run state conflict',
|
|
53
|
+
unsupported: 'Unsupported operation',
|
|
54
|
+
cancelled: 'Cancelled',
|
|
55
|
+
unauthorized: 'Authentication required',
|
|
56
|
+
forbidden: 'Access denied',
|
|
57
|
+
rate_limited: 'Rate limit exceeded',
|
|
58
|
+
upstream: 'Upstream execution failed',
|
|
59
|
+
timeout: 'Execution timed out',
|
|
60
|
+
network: 'Network execution failed',
|
|
61
|
+
needs_config: 'Configuration required',
|
|
62
|
+
budget_exceeded: 'Execution budget exceeded',
|
|
63
|
+
incompatible_db: 'Incompatible database',
|
|
64
|
+
internal: 'Internal execution failure',
|
|
65
|
+
}
|
|
66
|
+
// Error IDs are generated here, never copied from a potentially secret-bearing host error.
|
|
67
|
+
const reason = error instanceof KitFailure ? error.details?.reason : undefined
|
|
68
|
+
const safeReason =
|
|
69
|
+
reason === 'event_replay_unavailable' ||
|
|
70
|
+
reason === 'subscriber_already_active' ||
|
|
71
|
+
reason === 'approval_not_pending'
|
|
72
|
+
? reason
|
|
73
|
+
: undefined
|
|
74
|
+
return {
|
|
75
|
+
code,
|
|
76
|
+
message: messages[code],
|
|
77
|
+
...(code === 'internal' || safeReason
|
|
78
|
+
? {
|
|
79
|
+
details: {
|
|
80
|
+
...(safeReason ? { reason: safeReason } : {}),
|
|
81
|
+
...(code === 'internal' ? { errorId: newId() } : {}),
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function statusCode(error: KitError): number {
|
|
88
|
+
switch (error.code) {
|
|
89
|
+
case 'bad_request':
|
|
90
|
+
return 400
|
|
91
|
+
case 'unauthorized':
|
|
92
|
+
return 401
|
|
93
|
+
case 'forbidden':
|
|
94
|
+
return 403
|
|
95
|
+
case 'not_found':
|
|
96
|
+
return 404
|
|
97
|
+
case 'conflict':
|
|
98
|
+
return 409
|
|
99
|
+
case 'rate_limited':
|
|
100
|
+
return 429
|
|
101
|
+
case 'internal':
|
|
102
|
+
case 'incompatible_db':
|
|
103
|
+
return 500
|
|
104
|
+
default:
|
|
105
|
+
return 422
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function actorHeader(request: Request): Actor {
|
|
109
|
+
try {
|
|
110
|
+
const encoded = request.headers.get('x-aether-actor')
|
|
111
|
+
if (!encoded || encoded.length > 131072) throw missing()
|
|
112
|
+
const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0))
|
|
113
|
+
const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
114
|
+
if (!isActor(value)) throw missing()
|
|
115
|
+
return immutableActor(value)
|
|
116
|
+
} catch {
|
|
117
|
+
throw missing()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function terminal(phase: Phase): phase is EndStatus {
|
|
121
|
+
return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
|
|
122
|
+
}
|
|
123
|
+
export function endEvent(snapshot: RunSnapshot): RunEvent {
|
|
124
|
+
if (snapshot.status === 'running') throw conflict('run_not_terminal')
|
|
125
|
+
return {
|
|
126
|
+
kind: 'end',
|
|
127
|
+
status: snapshot.status,
|
|
128
|
+
...(snapshot.error ? { error: snapshot.error } : {}),
|
|
129
|
+
...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
|
|
130
|
+
}
|
|
131
|
+
}
|