@sisu-ai/runtime-desktop 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +51 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +56 -0
- package/dist/index.d.ts +167 -0
- package/dist/index.js +1269 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1269 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
import http from "http";
|
|
4
|
+
import { anthropicAdapter } from "@sisu-ai/adapter-anthropic";
|
|
5
|
+
import { ollamaAdapter } from "@sisu-ai/adapter-ollama";
|
|
6
|
+
import { openAIAdapter } from "@sisu-ai/adapter-openai";
|
|
7
|
+
import { compose, createCtx, InMemoryKV, } from "@sisu-ai/core";
|
|
8
|
+
import { logAndRethrow } from "@sisu-ai/mw-error-boundary";
|
|
9
|
+
import { withGuardrails } from "@sisu-ai/mw-guardrails";
|
|
10
|
+
import { toolCallInvariant } from "@sisu-ai/mw-invariants";
|
|
11
|
+
import { PROTOCOL_VERSION, chatGenerateRequestSchema, defaultModelConfigSchema, parseBranchThreadRequest, parseSetThreadModelOverrideRequest, } from "@sisu-ai/protocol";
|
|
12
|
+
class TypedStreamEmitter {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.emitter = new EventEmitter();
|
|
15
|
+
}
|
|
16
|
+
emit(event, payload) {
|
|
17
|
+
this.emitter.emit(event, payload);
|
|
18
|
+
}
|
|
19
|
+
on(event, handler) {
|
|
20
|
+
this.emitter.on(event, handler);
|
|
21
|
+
return () => this.emitter.off(event, handler);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function nowIso() {
|
|
25
|
+
return new Date().toISOString();
|
|
26
|
+
}
|
|
27
|
+
function fallbackLogger() {
|
|
28
|
+
return {
|
|
29
|
+
debug: (message, attrs) => {
|
|
30
|
+
if (attrs)
|
|
31
|
+
process.stderr.write(`[runtime-desktop] ${message} ${JSON.stringify(attrs)}\n`);
|
|
32
|
+
},
|
|
33
|
+
info: () => { },
|
|
34
|
+
warn: () => { },
|
|
35
|
+
error: () => { },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function normalizeTitleFromPrompt(prompt) {
|
|
39
|
+
const trimmed = prompt.trim();
|
|
40
|
+
if (!trimmed)
|
|
41
|
+
return "New chat";
|
|
42
|
+
return trimmed.length > 48 ? `${trimmed.slice(0, 45)}...` : trimmed;
|
|
43
|
+
}
|
|
44
|
+
function computeState(currentState, dependencies) {
|
|
45
|
+
if (currentState === "stopped")
|
|
46
|
+
return "stopped";
|
|
47
|
+
const hasFailure = dependencies.some((d) => d.status === "failed");
|
|
48
|
+
return hasFailure ? "degraded" : "ready";
|
|
49
|
+
}
|
|
50
|
+
function providerModelOrThrow(providers, providerId, modelId) {
|
|
51
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
52
|
+
if (!provider) {
|
|
53
|
+
throw createRuntimeErrorEnvelope("provider_unavailable", `Provider '${providerId}' is not available`, {
|
|
54
|
+
providerId,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const model = provider.models.find((m) => m.modelId === modelId);
|
|
58
|
+
if (!model) {
|
|
59
|
+
throw createRuntimeErrorEnvelope("model_unavailable", `Model '${modelId}' is not available`, {
|
|
60
|
+
providerId,
|
|
61
|
+
modelId,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { provider, model };
|
|
65
|
+
}
|
|
66
|
+
function createRuntimeErrorEnvelope(code, message, details) {
|
|
67
|
+
return {
|
|
68
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
69
|
+
error: {
|
|
70
|
+
code,
|
|
71
|
+
message,
|
|
72
|
+
details,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function asErrorMessage(err) {
|
|
77
|
+
if (err instanceof Error)
|
|
78
|
+
return err.message;
|
|
79
|
+
return String(err);
|
|
80
|
+
}
|
|
81
|
+
function createAbortError() {
|
|
82
|
+
const err = new Error("The operation was aborted.");
|
|
83
|
+
err.name = "AbortError";
|
|
84
|
+
return err;
|
|
85
|
+
}
|
|
86
|
+
function isAbortError(err) {
|
|
87
|
+
return err instanceof Error && err.name === "AbortError";
|
|
88
|
+
}
|
|
89
|
+
function buildProviderCatalog(providers) {
|
|
90
|
+
return {
|
|
91
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
92
|
+
providers: providers.map((provider) => ({
|
|
93
|
+
providerId: provider.id,
|
|
94
|
+
displayName: provider.displayName,
|
|
95
|
+
models: provider.models,
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function makeMemoryStorage(logger) {
|
|
100
|
+
const record = {
|
|
101
|
+
threads: new Map(),
|
|
102
|
+
messages: new Map(),
|
|
103
|
+
orderedThreadIds: [],
|
|
104
|
+
defaults: null,
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
async listThreads(limit, cursor) {
|
|
108
|
+
const offset = cursor ? Number(cursor) : 0;
|
|
109
|
+
const ids = record.orderedThreadIds.slice(offset, offset + limit);
|
|
110
|
+
const items = ids
|
|
111
|
+
.map((id) => record.threads.get(id)?.summary)
|
|
112
|
+
.filter((v) => Boolean(v));
|
|
113
|
+
const nextOffset = offset + ids.length;
|
|
114
|
+
return {
|
|
115
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
116
|
+
items,
|
|
117
|
+
page: {
|
|
118
|
+
nextCursor: nextOffset < record.orderedThreadIds.length ? String(nextOffset) : undefined,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
async getThread(threadId, limit, cursor) {
|
|
123
|
+
const thread = record.threads.get(threadId);
|
|
124
|
+
if (!thread)
|
|
125
|
+
return null;
|
|
126
|
+
const offset = cursor ? Number(cursor) : 0;
|
|
127
|
+
const messageIds = thread.messageIds.slice(offset, offset + limit);
|
|
128
|
+
const messages = messageIds
|
|
129
|
+
.map((messageId) => record.messages.get(messageId))
|
|
130
|
+
.filter((v) => Boolean(v));
|
|
131
|
+
const nextOffset = offset + messageIds.length;
|
|
132
|
+
return {
|
|
133
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
134
|
+
thread: thread.summary,
|
|
135
|
+
messages,
|
|
136
|
+
page: {
|
|
137
|
+
nextCursor: nextOffset < thread.messageIds.length ? String(nextOffset) : undefined,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
async createThread(input) {
|
|
142
|
+
const ts = nowIso();
|
|
143
|
+
const threadId = randomUUID();
|
|
144
|
+
const summary = {
|
|
145
|
+
threadId,
|
|
146
|
+
title: input.title ?? "New chat",
|
|
147
|
+
createdAt: ts,
|
|
148
|
+
updatedAt: ts,
|
|
149
|
+
messageCount: 0,
|
|
150
|
+
providerId: input.providerId,
|
|
151
|
+
modelId: input.modelId,
|
|
152
|
+
sourceThreadId: input.sourceThreadId,
|
|
153
|
+
sourceMessageId: input.sourceMessageId,
|
|
154
|
+
};
|
|
155
|
+
record.threads.set(threadId, { summary, messageIds: [] });
|
|
156
|
+
record.orderedThreadIds.unshift(threadId);
|
|
157
|
+
return summary;
|
|
158
|
+
},
|
|
159
|
+
async appendMessage(input) {
|
|
160
|
+
const ts = nowIso();
|
|
161
|
+
const thread = record.threads.get(input.threadId);
|
|
162
|
+
if (!thread)
|
|
163
|
+
throw new Error(`Unknown thread: ${input.threadId}`);
|
|
164
|
+
const message = {
|
|
165
|
+
messageId: randomUUID(),
|
|
166
|
+
threadId: input.threadId,
|
|
167
|
+
role: input.role,
|
|
168
|
+
content: input.content,
|
|
169
|
+
status: input.status,
|
|
170
|
+
providerId: input.providerId,
|
|
171
|
+
modelId: input.modelId,
|
|
172
|
+
createdAt: ts,
|
|
173
|
+
updatedAt: ts,
|
|
174
|
+
};
|
|
175
|
+
record.messages.set(message.messageId, message);
|
|
176
|
+
thread.messageIds.push(message.messageId);
|
|
177
|
+
thread.summary.updatedAt = ts;
|
|
178
|
+
thread.summary.messageCount = thread.messageIds.length;
|
|
179
|
+
return message;
|
|
180
|
+
},
|
|
181
|
+
async updateMessageStatus(input) {
|
|
182
|
+
const found = record.messages.get(input.messageId);
|
|
183
|
+
if (!found)
|
|
184
|
+
return null;
|
|
185
|
+
const updatedAt = input.updatedAt ?? nowIso();
|
|
186
|
+
const next = {
|
|
187
|
+
...found,
|
|
188
|
+
status: input.status,
|
|
189
|
+
content: input.content ?? found.content,
|
|
190
|
+
updatedAt,
|
|
191
|
+
};
|
|
192
|
+
record.messages.set(input.messageId, next);
|
|
193
|
+
const thread = record.threads.get(found.threadId);
|
|
194
|
+
if (thread) {
|
|
195
|
+
thread.summary.updatedAt = updatedAt;
|
|
196
|
+
}
|
|
197
|
+
return next;
|
|
198
|
+
},
|
|
199
|
+
async findMessage(messageId) {
|
|
200
|
+
return record.messages.get(messageId) ?? null;
|
|
201
|
+
},
|
|
202
|
+
async branchThread(input) {
|
|
203
|
+
const source = record.messages.get(input.sourceMessageId);
|
|
204
|
+
if (!source)
|
|
205
|
+
return null;
|
|
206
|
+
const sourceThread = record.threads.get(source.threadId);
|
|
207
|
+
if (!sourceThread)
|
|
208
|
+
return null;
|
|
209
|
+
const branchTitle = input.title ?? `${sourceThread.summary.title} (branch)`;
|
|
210
|
+
const branch = await this.createThread({
|
|
211
|
+
title: branchTitle,
|
|
212
|
+
providerId: sourceThread.summary.providerId,
|
|
213
|
+
modelId: sourceThread.summary.modelId,
|
|
214
|
+
sourceThreadId: source.threadId,
|
|
215
|
+
sourceMessageId: source.messageId,
|
|
216
|
+
});
|
|
217
|
+
const sourceIndex = sourceThread.messageIds.findIndex((id) => id === source.messageId);
|
|
218
|
+
const replayIds = sourceIndex >= 0
|
|
219
|
+
? sourceThread.messageIds.slice(0, sourceIndex + 1)
|
|
220
|
+
: sourceThread.messageIds;
|
|
221
|
+
for (const id of replayIds) {
|
|
222
|
+
const message = record.messages.get(id);
|
|
223
|
+
if (!message)
|
|
224
|
+
continue;
|
|
225
|
+
await this.appendMessage({
|
|
226
|
+
threadId: branch.threadId,
|
|
227
|
+
role: message.role,
|
|
228
|
+
content: message.content,
|
|
229
|
+
status: message.status,
|
|
230
|
+
providerId: message.providerId,
|
|
231
|
+
modelId: message.modelId,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const branched = record.threads.get(branch.threadId);
|
|
235
|
+
if (!branched)
|
|
236
|
+
return null;
|
|
237
|
+
return branched.summary;
|
|
238
|
+
},
|
|
239
|
+
async search(query, limit, cursor) {
|
|
240
|
+
const normalized = query.trim().toLowerCase();
|
|
241
|
+
const offset = cursor ? Number(cursor) : 0;
|
|
242
|
+
const all = [...record.messages.values()]
|
|
243
|
+
.filter((m) => m.content.toLowerCase().includes(normalized) ||
|
|
244
|
+
record.threads.get(m.threadId)?.summary.title
|
|
245
|
+
.toLowerCase()
|
|
246
|
+
.includes(normalized))
|
|
247
|
+
.map((m) => ({
|
|
248
|
+
threadId: m.threadId,
|
|
249
|
+
messageId: m.messageId,
|
|
250
|
+
excerpt: m.content.slice(0, 160),
|
|
251
|
+
score: 1,
|
|
252
|
+
}));
|
|
253
|
+
const items = all.slice(offset, offset + limit);
|
|
254
|
+
const nextOffset = offset + items.length;
|
|
255
|
+
return {
|
|
256
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
257
|
+
query,
|
|
258
|
+
items,
|
|
259
|
+
page: {
|
|
260
|
+
nextCursor: nextOffset < all.length ? String(nextOffset) : undefined,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
async getDefaultModel() {
|
|
265
|
+
return record.defaults;
|
|
266
|
+
},
|
|
267
|
+
async setDefaultModel(config) {
|
|
268
|
+
record.defaults = { ...config };
|
|
269
|
+
logger.info("default_model.updated", config);
|
|
270
|
+
return { ...config };
|
|
271
|
+
},
|
|
272
|
+
async setThreadOverride(input) {
|
|
273
|
+
const thread = record.threads.get(input.threadId);
|
|
274
|
+
if (!thread)
|
|
275
|
+
return null;
|
|
276
|
+
const ts = nowIso();
|
|
277
|
+
thread.summary.providerId = input.providerId;
|
|
278
|
+
thread.summary.modelId = input.modelId;
|
|
279
|
+
thread.summary.updatedAt = ts;
|
|
280
|
+
return thread.summary;
|
|
281
|
+
},
|
|
282
|
+
async listMessagesByStatus(statuses) {
|
|
283
|
+
return [...record.messages.values()]
|
|
284
|
+
.filter((message) => statuses.includes(message.status))
|
|
285
|
+
.map((message) => ({
|
|
286
|
+
messageId: message.messageId,
|
|
287
|
+
status: message.status,
|
|
288
|
+
}));
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function findAssistantResult(messages) {
|
|
293
|
+
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
|
294
|
+
return lastAssistant?.content ?? "";
|
|
295
|
+
}
|
|
296
|
+
function isAsyncModelEvents(value) {
|
|
297
|
+
return (Boolean(value) &&
|
|
298
|
+
typeof value[Symbol.asyncIterator] === "function");
|
|
299
|
+
}
|
|
300
|
+
function createStreamingModelBridge(model, emit, streamId, messageId, correlationId, signal) {
|
|
301
|
+
const generate = ((messages, opts) => {
|
|
302
|
+
const nextOpts = { ...(opts ?? {}), stream: true, signal };
|
|
303
|
+
const output = model.generate(messages, nextOpts);
|
|
304
|
+
if (isAsyncModelEvents(output)) {
|
|
305
|
+
const src = output;
|
|
306
|
+
return (async function* () {
|
|
307
|
+
let tokenIndex = 0;
|
|
308
|
+
let completeText = "";
|
|
309
|
+
for await (const ev of src) {
|
|
310
|
+
if (ev.type === "token") {
|
|
311
|
+
completeText += ev.token;
|
|
312
|
+
emit({
|
|
313
|
+
type: "token.delta",
|
|
314
|
+
streamId,
|
|
315
|
+
messageId,
|
|
316
|
+
ts: nowIso(),
|
|
317
|
+
correlationId,
|
|
318
|
+
index: tokenIndex,
|
|
319
|
+
delta: ev.token,
|
|
320
|
+
});
|
|
321
|
+
tokenIndex += 1;
|
|
322
|
+
}
|
|
323
|
+
if (ev.type === "assistant_message") {
|
|
324
|
+
completeText = ev.message.content;
|
|
325
|
+
}
|
|
326
|
+
yield ev;
|
|
327
|
+
}
|
|
328
|
+
if (!completeText) {
|
|
329
|
+
emit({
|
|
330
|
+
type: "message.completed",
|
|
331
|
+
streamId,
|
|
332
|
+
messageId,
|
|
333
|
+
ts: nowIso(),
|
|
334
|
+
correlationId,
|
|
335
|
+
text: "",
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
})();
|
|
339
|
+
}
|
|
340
|
+
return (async () => {
|
|
341
|
+
const resolved = await output;
|
|
342
|
+
const text = resolved.message.content ?? "";
|
|
343
|
+
emit({
|
|
344
|
+
type: "token.delta",
|
|
345
|
+
streamId,
|
|
346
|
+
messageId,
|
|
347
|
+
ts: nowIso(),
|
|
348
|
+
correlationId,
|
|
349
|
+
index: 0,
|
|
350
|
+
delta: text,
|
|
351
|
+
});
|
|
352
|
+
return resolved;
|
|
353
|
+
})();
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
...model,
|
|
357
|
+
generate,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function makeRuntimeCtx(model, input, tools, signal, logger) {
|
|
361
|
+
const coreLogger = {
|
|
362
|
+
debug: (...args) => logger.debug(String(args[0] ?? ""), { args: args.slice(1) }),
|
|
363
|
+
info: (...args) => logger.info(String(args[0] ?? ""), { args: args.slice(1) }),
|
|
364
|
+
warn: (...args) => logger.warn(String(args[0] ?? ""), { args: args.slice(1) }),
|
|
365
|
+
error: (...args) => logger.error(String(args[0] ?? ""), { args: args.slice(1) }),
|
|
366
|
+
span: () => { },
|
|
367
|
+
};
|
|
368
|
+
const ctx = createCtx({
|
|
369
|
+
model,
|
|
370
|
+
input,
|
|
371
|
+
signal,
|
|
372
|
+
});
|
|
373
|
+
for (const tool of tools) {
|
|
374
|
+
ctx.tools.register(tool);
|
|
375
|
+
}
|
|
376
|
+
ctx.memory = new InMemoryKV();
|
|
377
|
+
ctx.log = coreLogger;
|
|
378
|
+
return ctx;
|
|
379
|
+
}
|
|
380
|
+
function parseCursor(raw) {
|
|
381
|
+
if (!raw)
|
|
382
|
+
return undefined;
|
|
383
|
+
return raw.trim() ? raw : undefined;
|
|
384
|
+
}
|
|
385
|
+
export function isLocalAddress(address) {
|
|
386
|
+
if (!address)
|
|
387
|
+
return false;
|
|
388
|
+
return (address === "127.0.0.1" ||
|
|
389
|
+
address === "::1" ||
|
|
390
|
+
address === "::ffff:127.0.0.1");
|
|
391
|
+
}
|
|
392
|
+
function toHttpError(err) {
|
|
393
|
+
if (typeof err === "object" &&
|
|
394
|
+
err !== null &&
|
|
395
|
+
"error" in err &&
|
|
396
|
+
typeof err.error === "object") {
|
|
397
|
+
const envelope = err;
|
|
398
|
+
if (envelope.protocolVersion === PROTOCOL_VERSION &&
|
|
399
|
+
envelope.error &&
|
|
400
|
+
typeof envelope.error.code === "string") {
|
|
401
|
+
return envelope;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return createRuntimeErrorEnvelope("internal_error", asErrorMessage(err));
|
|
405
|
+
}
|
|
406
|
+
function statusCodeFromError(code) {
|
|
407
|
+
if (code === "invalid_request")
|
|
408
|
+
return 400;
|
|
409
|
+
if (code === "not_found")
|
|
410
|
+
return 404;
|
|
411
|
+
if (code === "model_unavailable" || code === "model_incompatible")
|
|
412
|
+
return 422;
|
|
413
|
+
if (code === "provider_unavailable")
|
|
414
|
+
return 503;
|
|
415
|
+
return 500;
|
|
416
|
+
}
|
|
417
|
+
function parseLimit(rawValue, fallback) {
|
|
418
|
+
if (!rawValue)
|
|
419
|
+
return fallback;
|
|
420
|
+
const n = Number(rawValue);
|
|
421
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
422
|
+
return fallback;
|
|
423
|
+
return Math.floor(n);
|
|
424
|
+
}
|
|
425
|
+
async function readJsonBody(req, maxBodyBytes) {
|
|
426
|
+
const chunks = [];
|
|
427
|
+
let size = 0;
|
|
428
|
+
for await (const chunk of req) {
|
|
429
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
430
|
+
size += buf.length;
|
|
431
|
+
if (size > maxBodyBytes) {
|
|
432
|
+
throw createRuntimeErrorEnvelope("invalid_request", `Request body exceeds limit (${maxBodyBytes} bytes)`);
|
|
433
|
+
}
|
|
434
|
+
chunks.push(buf);
|
|
435
|
+
}
|
|
436
|
+
if (chunks.length === 0)
|
|
437
|
+
return {};
|
|
438
|
+
try {
|
|
439
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
throw createRuntimeErrorEnvelope("invalid_request", "Body must be valid JSON");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function writeJson(res, statusCode, body, correlationId) {
|
|
446
|
+
res.statusCode = statusCode;
|
|
447
|
+
res.setHeader("content-type", "application/json");
|
|
448
|
+
res.setHeader("x-correlation-id", correlationId);
|
|
449
|
+
res.end(JSON.stringify(body));
|
|
450
|
+
}
|
|
451
|
+
export function createRuntimeController(options = {}) {
|
|
452
|
+
const logger = options.logger ?? fallbackLogger();
|
|
453
|
+
let state = options.initialState ?? "stopped";
|
|
454
|
+
let dependencies = [...(options.dependencies ?? [])];
|
|
455
|
+
let degradedCapabilities = [...(options.degradedCapabilities ?? [])];
|
|
456
|
+
let providers = [...(options.providers ?? [])];
|
|
457
|
+
const storage = options.storage ?? makeMemoryStorage(logger);
|
|
458
|
+
const streamEvents = new TypedStreamEmitter();
|
|
459
|
+
const streams = new Map();
|
|
460
|
+
const streamControllers = new Map();
|
|
461
|
+
const tools = [...(options.tools ?? [])];
|
|
462
|
+
const status = () => ({
|
|
463
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
464
|
+
state,
|
|
465
|
+
degradedCapabilities: [...degradedCapabilities],
|
|
466
|
+
dependencies: [...dependencies],
|
|
467
|
+
});
|
|
468
|
+
async function resolveModelSelection(input) {
|
|
469
|
+
if (input.providerId && input.modelId) {
|
|
470
|
+
return { providerId: input.providerId, modelId: input.modelId };
|
|
471
|
+
}
|
|
472
|
+
if (input.threadId) {
|
|
473
|
+
const thread = await storage.getThread(input.threadId, 1);
|
|
474
|
+
if (thread) {
|
|
475
|
+
return {
|
|
476
|
+
providerId: thread.thread.providerId,
|
|
477
|
+
modelId: thread.thread.modelId,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const defaults = await storage.getDefaultModel();
|
|
482
|
+
if (defaults)
|
|
483
|
+
return defaults;
|
|
484
|
+
const fallbackProvider = providers[0];
|
|
485
|
+
const fallbackModel = fallbackProvider?.models[0];
|
|
486
|
+
if (!fallbackProvider || !fallbackModel) {
|
|
487
|
+
throw createRuntimeErrorEnvelope("provider_unavailable", "No providers or models are configured");
|
|
488
|
+
}
|
|
489
|
+
return { providerId: fallbackProvider.id, modelId: fallbackModel.modelId };
|
|
490
|
+
}
|
|
491
|
+
async function ensureRuntimeReady() {
|
|
492
|
+
if (state === "stopped") {
|
|
493
|
+
throw createRuntimeErrorEnvelope("internal_error", "Runtime is stopped and cannot process requests");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function pumpGeneration(streamId, request, streamMessage) {
|
|
497
|
+
const stream = streams.get(streamId);
|
|
498
|
+
if (!stream)
|
|
499
|
+
return;
|
|
500
|
+
const controller = streamControllers.get(streamId);
|
|
501
|
+
if (!controller)
|
|
502
|
+
return;
|
|
503
|
+
const correlationId = stream.correlationId;
|
|
504
|
+
const streamingRecord = {
|
|
505
|
+
...stream,
|
|
506
|
+
status: "streaming",
|
|
507
|
+
updatedAt: nowIso(),
|
|
508
|
+
};
|
|
509
|
+
streams.set(streamId, streamingRecord);
|
|
510
|
+
await storage.updateMessageStatus({
|
|
511
|
+
messageId: streamMessage.messageId,
|
|
512
|
+
status: "streaming",
|
|
513
|
+
updatedAt: nowIso(),
|
|
514
|
+
});
|
|
515
|
+
const startEvent = {
|
|
516
|
+
type: "message.started",
|
|
517
|
+
streamId,
|
|
518
|
+
messageId: streamMessage.messageId,
|
|
519
|
+
threadId: stream.threadId,
|
|
520
|
+
ts: nowIso(),
|
|
521
|
+
correlationId,
|
|
522
|
+
};
|
|
523
|
+
streamEvents.emit("event", startEvent);
|
|
524
|
+
try {
|
|
525
|
+
const selection = await resolveModelSelection({
|
|
526
|
+
threadId: stream.threadId,
|
|
527
|
+
providerId: request.providerId,
|
|
528
|
+
modelId: request.modelId,
|
|
529
|
+
});
|
|
530
|
+
const { provider, model } = providerModelOrThrow(providers, selection.providerId, selection.modelId);
|
|
531
|
+
if (request.attachments?.length && !model.capabilities.imageInput) {
|
|
532
|
+
throw createRuntimeErrorEnvelope("model_incompatible", `Model '${model.modelId}' does not support image attachments`, { providerId: provider.id, modelId: model.modelId });
|
|
533
|
+
}
|
|
534
|
+
const baseModel = provider.createModel(model.modelId);
|
|
535
|
+
const bridgedModel = createStreamingModelBridge(baseModel, (ev) => streamEvents.emit("event", ev), streamId, streamMessage.messageId, correlationId, controller.signal);
|
|
536
|
+
const ctx = makeRuntimeCtx(bridgedModel, request.prompt, tools, controller.signal, logger);
|
|
537
|
+
const chain = [];
|
|
538
|
+
chain.push(logAndRethrow());
|
|
539
|
+
if (options.guardrailPolicy) {
|
|
540
|
+
chain.push(withGuardrails(options.guardrailPolicy));
|
|
541
|
+
}
|
|
542
|
+
chain.push(toolCallInvariant());
|
|
543
|
+
if (options.hooks?.beforePipeline)
|
|
544
|
+
chain.push(options.hooks.beforePipeline);
|
|
545
|
+
chain.push(async (runtimeCtx, next) => {
|
|
546
|
+
runtimeCtx.messages.push({
|
|
547
|
+
role: "user",
|
|
548
|
+
content: request.prompt,
|
|
549
|
+
});
|
|
550
|
+
const output = runtimeCtx.model.generate(runtimeCtx.messages, {
|
|
551
|
+
stream: true,
|
|
552
|
+
signal: runtimeCtx.signal,
|
|
553
|
+
tools,
|
|
554
|
+
toolChoice: "auto",
|
|
555
|
+
});
|
|
556
|
+
if (isAsyncModelEvents(output)) {
|
|
557
|
+
for await (const ev of output) {
|
|
558
|
+
if (ev.type === "assistant_message") {
|
|
559
|
+
runtimeCtx.messages.push(ev.message);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
const resolved = await output;
|
|
565
|
+
runtimeCtx.messages.push(resolved.message);
|
|
566
|
+
}
|
|
567
|
+
await next();
|
|
568
|
+
});
|
|
569
|
+
if (options.hooks?.afterPipeline)
|
|
570
|
+
chain.push(options.hooks.afterPipeline);
|
|
571
|
+
await compose(chain)(ctx);
|
|
572
|
+
const assistantText = findAssistantResult(ctx.messages);
|
|
573
|
+
await storage.updateMessageStatus({
|
|
574
|
+
messageId: streamMessage.messageId,
|
|
575
|
+
status: "completed",
|
|
576
|
+
content: assistantText,
|
|
577
|
+
updatedAt: nowIso(),
|
|
578
|
+
});
|
|
579
|
+
const completedEvent = {
|
|
580
|
+
type: "message.completed",
|
|
581
|
+
streamId,
|
|
582
|
+
messageId: streamMessage.messageId,
|
|
583
|
+
ts: nowIso(),
|
|
584
|
+
correlationId,
|
|
585
|
+
text: assistantText,
|
|
586
|
+
};
|
|
587
|
+
streams.set(streamId, {
|
|
588
|
+
...stream,
|
|
589
|
+
status: "completed",
|
|
590
|
+
updatedAt: nowIso(),
|
|
591
|
+
terminalEvent: completedEvent,
|
|
592
|
+
});
|
|
593
|
+
streamEvents.emit("event", completedEvent);
|
|
594
|
+
streamEvents.emit("close", { streamId });
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
if (isAbortError(err) || controller.signal.aborted) {
|
|
598
|
+
const cancelledEvent = {
|
|
599
|
+
type: "message.cancelled",
|
|
600
|
+
streamId,
|
|
601
|
+
messageId: streamMessage.messageId,
|
|
602
|
+
ts: nowIso(),
|
|
603
|
+
correlationId,
|
|
604
|
+
reason: "cancelled_by_client",
|
|
605
|
+
};
|
|
606
|
+
await storage.updateMessageStatus({
|
|
607
|
+
messageId: streamMessage.messageId,
|
|
608
|
+
status: "cancelled",
|
|
609
|
+
updatedAt: nowIso(),
|
|
610
|
+
});
|
|
611
|
+
streams.set(streamId, {
|
|
612
|
+
...stream,
|
|
613
|
+
status: "cancelled",
|
|
614
|
+
updatedAt: nowIso(),
|
|
615
|
+
terminalEvent: cancelledEvent,
|
|
616
|
+
});
|
|
617
|
+
streamEvents.emit("event", cancelledEvent);
|
|
618
|
+
streamEvents.emit("close", { streamId });
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const errorEnvelope = err?.error
|
|
622
|
+
? err
|
|
623
|
+
: createRuntimeErrorEnvelope("internal_error", asErrorMessage(err));
|
|
624
|
+
const failedEvent = {
|
|
625
|
+
type: "message.failed",
|
|
626
|
+
streamId,
|
|
627
|
+
messageId: streamMessage.messageId,
|
|
628
|
+
ts: nowIso(),
|
|
629
|
+
correlationId,
|
|
630
|
+
error: errorEnvelope.error,
|
|
631
|
+
};
|
|
632
|
+
await storage.updateMessageStatus({
|
|
633
|
+
messageId: streamMessage.messageId,
|
|
634
|
+
status: "failed",
|
|
635
|
+
updatedAt: nowIso(),
|
|
636
|
+
});
|
|
637
|
+
streams.set(streamId, {
|
|
638
|
+
...stream,
|
|
639
|
+
status: "failed",
|
|
640
|
+
updatedAt: nowIso(),
|
|
641
|
+
terminalEvent: failedEvent,
|
|
642
|
+
});
|
|
643
|
+
streamEvents.emit("event", failedEvent);
|
|
644
|
+
streamEvents.emit("close", { streamId });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
status,
|
|
650
|
+
async start() {
|
|
651
|
+
state = "starting";
|
|
652
|
+
for (const provider of providers) {
|
|
653
|
+
if (!provider.checkHealth)
|
|
654
|
+
continue;
|
|
655
|
+
const health = await provider.checkHealth();
|
|
656
|
+
const idx = dependencies.findIndex((d) => d.id === provider.id);
|
|
657
|
+
const dependency = {
|
|
658
|
+
id: provider.id,
|
|
659
|
+
status: health.status,
|
|
660
|
+
reason: health.reason,
|
|
661
|
+
};
|
|
662
|
+
if (idx === -1)
|
|
663
|
+
dependencies.push(dependency);
|
|
664
|
+
else
|
|
665
|
+
dependencies[idx] = dependency;
|
|
666
|
+
}
|
|
667
|
+
state = computeState("ready", dependencies);
|
|
668
|
+
if (state === "degraded" && degradedCapabilities.length === 0) {
|
|
669
|
+
degradedCapabilities = ["provider.availability"];
|
|
670
|
+
}
|
|
671
|
+
const recovering = storage.listMessagesByStatus
|
|
672
|
+
? await storage.listMessagesByStatus(["pending", "streaming"])
|
|
673
|
+
: [];
|
|
674
|
+
for (const message of recovering) {
|
|
675
|
+
await storage.updateMessageStatus({
|
|
676
|
+
messageId: message.messageId,
|
|
677
|
+
status: "cancelled",
|
|
678
|
+
updatedAt: nowIso(),
|
|
679
|
+
content: message.status === "pending"
|
|
680
|
+
? "Interrupted before generation started"
|
|
681
|
+
: "Interrupted during runtime restart",
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
if (recovering.length > 0 && !degradedCapabilities.includes("recovery.pending")) {
|
|
685
|
+
degradedCapabilities = [...degradedCapabilities, "recovery.pending"];
|
|
686
|
+
}
|
|
687
|
+
return status();
|
|
688
|
+
},
|
|
689
|
+
async stop() {
|
|
690
|
+
state = "stopped";
|
|
691
|
+
for (const [, controller] of streamControllers)
|
|
692
|
+
controller.abort();
|
|
693
|
+
streamControllers.clear();
|
|
694
|
+
return status();
|
|
695
|
+
},
|
|
696
|
+
setDependencyStatus(dependencyId, dependencyState, reason) {
|
|
697
|
+
const idx = dependencies.findIndex((d) => d.id === dependencyId);
|
|
698
|
+
const next = {
|
|
699
|
+
id: dependencyId,
|
|
700
|
+
status: dependencyState,
|
|
701
|
+
reason,
|
|
702
|
+
};
|
|
703
|
+
if (idx === -1)
|
|
704
|
+
dependencies.push(next);
|
|
705
|
+
else
|
|
706
|
+
dependencies[idx] = next;
|
|
707
|
+
if (state !== "stopped")
|
|
708
|
+
state = computeState("ready", dependencies);
|
|
709
|
+
return status();
|
|
710
|
+
},
|
|
711
|
+
setProviderCatalog(providerCatalog) {
|
|
712
|
+
providers = [...providerCatalog];
|
|
713
|
+
return status();
|
|
714
|
+
},
|
|
715
|
+
listProviders() {
|
|
716
|
+
return buildProviderCatalog(providers);
|
|
717
|
+
},
|
|
718
|
+
async setDefaultModel(config) {
|
|
719
|
+
providerModelOrThrow(providers, config.providerId, config.modelId);
|
|
720
|
+
return storage.setDefaultModel(config);
|
|
721
|
+
},
|
|
722
|
+
async getDefaultModel() {
|
|
723
|
+
return storage.getDefaultModel();
|
|
724
|
+
},
|
|
725
|
+
async createThread(input) {
|
|
726
|
+
const selection = await resolveModelSelection({
|
|
727
|
+
providerId: input.providerId,
|
|
728
|
+
modelId: input.modelId,
|
|
729
|
+
});
|
|
730
|
+
providerModelOrThrow(providers, selection.providerId, selection.modelId);
|
|
731
|
+
return storage.createThread({
|
|
732
|
+
title: input.title,
|
|
733
|
+
providerId: selection.providerId,
|
|
734
|
+
modelId: selection.modelId,
|
|
735
|
+
});
|
|
736
|
+
},
|
|
737
|
+
async listThreads(limit = 20, cursor) {
|
|
738
|
+
return storage.listThreads(limit, parseCursor(cursor));
|
|
739
|
+
},
|
|
740
|
+
async getThread(threadId, limit = 100, cursor) {
|
|
741
|
+
return storage.getThread(threadId, limit, parseCursor(cursor));
|
|
742
|
+
},
|
|
743
|
+
async searchHistory(query, limit = 20, cursor) {
|
|
744
|
+
if (!query.trim()) {
|
|
745
|
+
throw createRuntimeErrorEnvelope("invalid_request", "Query cannot be empty");
|
|
746
|
+
}
|
|
747
|
+
return storage.search(query, limit, parseCursor(cursor));
|
|
748
|
+
},
|
|
749
|
+
async branchThread(input) {
|
|
750
|
+
const parsed = parseBranchThreadRequest(input);
|
|
751
|
+
const branched = await storage.branchThread(parsed);
|
|
752
|
+
if (!branched) {
|
|
753
|
+
throw createRuntimeErrorEnvelope("not_found", `Cannot branch from message '${parsed.sourceMessageId}'`);
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
757
|
+
thread: branched,
|
|
758
|
+
};
|
|
759
|
+
},
|
|
760
|
+
async setThreadModelOverride(input) {
|
|
761
|
+
const parsed = parseSetThreadModelOverrideRequest(input);
|
|
762
|
+
providerModelOrThrow(providers, parsed.providerId, parsed.modelId);
|
|
763
|
+
const updated = await storage.setThreadOverride({
|
|
764
|
+
threadId: input.threadId,
|
|
765
|
+
providerId: parsed.providerId,
|
|
766
|
+
modelId: parsed.modelId,
|
|
767
|
+
});
|
|
768
|
+
if (!updated) {
|
|
769
|
+
throw createRuntimeErrorEnvelope("not_found", `Thread '${input.threadId}' does not exist`);
|
|
770
|
+
}
|
|
771
|
+
return updated;
|
|
772
|
+
},
|
|
773
|
+
async generate(input) {
|
|
774
|
+
await ensureRuntimeReady();
|
|
775
|
+
const request = chatGenerateRequestSchema.parse(input);
|
|
776
|
+
const selection = await resolveModelSelection({
|
|
777
|
+
threadId: request.threadId,
|
|
778
|
+
providerId: request.providerId,
|
|
779
|
+
modelId: request.modelId,
|
|
780
|
+
});
|
|
781
|
+
const { model } = providerModelOrThrow(providers, selection.providerId, selection.modelId);
|
|
782
|
+
if (request.attachments?.length && !model.capabilities.imageInput) {
|
|
783
|
+
throw createRuntimeErrorEnvelope("model_incompatible", `Model '${model.modelId}' does not support image attachments`, {
|
|
784
|
+
providerId: selection.providerId,
|
|
785
|
+
modelId: selection.modelId,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
const threadId = request.threadId
|
|
789
|
+
? request.threadId
|
|
790
|
+
: (await storage.createThread({
|
|
791
|
+
title: normalizeTitleFromPrompt(request.prompt),
|
|
792
|
+
providerId: selection.providerId,
|
|
793
|
+
modelId: selection.modelId,
|
|
794
|
+
})).threadId;
|
|
795
|
+
const userMessage = await storage.appendMessage({
|
|
796
|
+
threadId,
|
|
797
|
+
role: "user",
|
|
798
|
+
content: request.prompt,
|
|
799
|
+
status: "completed",
|
|
800
|
+
providerId: selection.providerId,
|
|
801
|
+
modelId: selection.modelId,
|
|
802
|
+
});
|
|
803
|
+
if (request.retryOfMessageId) {
|
|
804
|
+
const retryMessage = await storage.findMessage(request.retryOfMessageId);
|
|
805
|
+
if (!retryMessage) {
|
|
806
|
+
throw createRuntimeErrorEnvelope("not_found", `Retry source message '${request.retryOfMessageId}' not found`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const assistantMessage = await storage.appendMessage({
|
|
810
|
+
threadId,
|
|
811
|
+
role: "assistant",
|
|
812
|
+
content: "",
|
|
813
|
+
status: "pending",
|
|
814
|
+
providerId: selection.providerId,
|
|
815
|
+
modelId: selection.modelId,
|
|
816
|
+
});
|
|
817
|
+
const streamId = randomUUID();
|
|
818
|
+
const correlationId = randomUUID();
|
|
819
|
+
const streamRecord = {
|
|
820
|
+
streamId,
|
|
821
|
+
messageId: assistantMessage.messageId,
|
|
822
|
+
threadId,
|
|
823
|
+
status: "queued",
|
|
824
|
+
correlationId,
|
|
825
|
+
request: {
|
|
826
|
+
...request,
|
|
827
|
+
providerId: selection.providerId,
|
|
828
|
+
modelId: selection.modelId,
|
|
829
|
+
},
|
|
830
|
+
createdAt: nowIso(),
|
|
831
|
+
updatedAt: nowIso(),
|
|
832
|
+
};
|
|
833
|
+
streams.set(streamId, streamRecord);
|
|
834
|
+
const controller = new AbortController();
|
|
835
|
+
streamControllers.set(streamId, controller);
|
|
836
|
+
void pumpGeneration(streamId, request, assistantMessage).finally(() => {
|
|
837
|
+
streamControllers.delete(streamId);
|
|
838
|
+
});
|
|
839
|
+
logger.info("stream.started", {
|
|
840
|
+
streamId,
|
|
841
|
+
threadId,
|
|
842
|
+
messageId: assistantMessage.messageId,
|
|
843
|
+
correlationId,
|
|
844
|
+
userMessageId: userMessage.messageId,
|
|
845
|
+
});
|
|
846
|
+
return {
|
|
847
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
848
|
+
streamId,
|
|
849
|
+
messageId: assistantMessage.messageId,
|
|
850
|
+
status: "streaming",
|
|
851
|
+
};
|
|
852
|
+
},
|
|
853
|
+
async cancelStream(streamId) {
|
|
854
|
+
const stream = streams.get(streamId);
|
|
855
|
+
if (!stream) {
|
|
856
|
+
throw createRuntimeErrorEnvelope("not_found", `Stream '${streamId}' does not exist`);
|
|
857
|
+
}
|
|
858
|
+
if (stream.status === "completed" ||
|
|
859
|
+
stream.status === "failed" ||
|
|
860
|
+
stream.status === "cancelled") {
|
|
861
|
+
return {
|
|
862
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
863
|
+
streamId,
|
|
864
|
+
status: stream.status === "completed" ? "completed" : "cancelled",
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const controller = streamControllers.get(streamId);
|
|
868
|
+
if (controller)
|
|
869
|
+
controller.abort();
|
|
870
|
+
streams.set(streamId, {
|
|
871
|
+
...stream,
|
|
872
|
+
status: "cancelled",
|
|
873
|
+
updatedAt: nowIso(),
|
|
874
|
+
});
|
|
875
|
+
return {
|
|
876
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
877
|
+
streamId,
|
|
878
|
+
status: "cancelling",
|
|
879
|
+
};
|
|
880
|
+
},
|
|
881
|
+
async getStreamStatus(streamId) {
|
|
882
|
+
const stream = streams.get(streamId);
|
|
883
|
+
if (!stream)
|
|
884
|
+
return null;
|
|
885
|
+
return {
|
|
886
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
887
|
+
streamId: stream.streamId,
|
|
888
|
+
messageId: stream.messageId,
|
|
889
|
+
status: stream.status,
|
|
890
|
+
terminalEvent: stream.terminalEvent,
|
|
891
|
+
};
|
|
892
|
+
},
|
|
893
|
+
async *streamEvents(streamId) {
|
|
894
|
+
const existing = streams.get(streamId);
|
|
895
|
+
if (!existing) {
|
|
896
|
+
throw createRuntimeErrorEnvelope("not_found", `Stream '${streamId}' does not exist`);
|
|
897
|
+
}
|
|
898
|
+
if (existing.terminalEvent) {
|
|
899
|
+
yield {
|
|
900
|
+
type: "message.started",
|
|
901
|
+
streamId: existing.streamId,
|
|
902
|
+
messageId: existing.messageId,
|
|
903
|
+
threadId: existing.threadId,
|
|
904
|
+
ts: existing.createdAt,
|
|
905
|
+
correlationId: existing.correlationId,
|
|
906
|
+
};
|
|
907
|
+
yield existing.terminalEvent;
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const queue = [];
|
|
911
|
+
let done = false;
|
|
912
|
+
queue.push({
|
|
913
|
+
type: "message.started",
|
|
914
|
+
streamId: existing.streamId,
|
|
915
|
+
messageId: existing.messageId,
|
|
916
|
+
threadId: existing.threadId,
|
|
917
|
+
ts: existing.createdAt,
|
|
918
|
+
correlationId: existing.correlationId,
|
|
919
|
+
});
|
|
920
|
+
const offEvent = streamEvents.on("event", (event) => {
|
|
921
|
+
if (event.streamId === streamId) {
|
|
922
|
+
queue.push(event);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
const offClose = streamEvents.on("close", (event) => {
|
|
926
|
+
if (event.streamId === streamId) {
|
|
927
|
+
done = true;
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
try {
|
|
931
|
+
while (!done || queue.length > 0) {
|
|
932
|
+
if (queue.length === 0) {
|
|
933
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const next = queue.shift();
|
|
937
|
+
if (next)
|
|
938
|
+
yield next;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
finally {
|
|
942
|
+
offEvent();
|
|
943
|
+
offClose();
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
health() {
|
|
947
|
+
return {
|
|
948
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
949
|
+
state,
|
|
950
|
+
degradedCapabilities: [...new Set(degradedCapabilities)],
|
|
951
|
+
dependencies: [...dependencies],
|
|
952
|
+
};
|
|
953
|
+
},
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
export function simpleProvider(id, displayName, models, createModel) {
|
|
957
|
+
return {
|
|
958
|
+
id,
|
|
959
|
+
displayName,
|
|
960
|
+
models,
|
|
961
|
+
createModel,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
function modelsOrDefault(models, fallback) {
|
|
965
|
+
if (!models || models.length === 0)
|
|
966
|
+
return fallback;
|
|
967
|
+
return models;
|
|
968
|
+
}
|
|
969
|
+
export function createDefaultProviders(opts = {}) {
|
|
970
|
+
const providers = [];
|
|
971
|
+
const openAiModels = modelsOrDefault(opts.openAI?.models, ["gpt-4o-mini"]);
|
|
972
|
+
providers.push(simpleProvider("openai", "OpenAI", openAiModels.map((modelId) => ({
|
|
973
|
+
providerId: "openai",
|
|
974
|
+
modelId,
|
|
975
|
+
displayName: modelId,
|
|
976
|
+
capabilities: {
|
|
977
|
+
streaming: true,
|
|
978
|
+
imageInput: true,
|
|
979
|
+
toolCalling: true,
|
|
980
|
+
},
|
|
981
|
+
})), (modelId) => openAIAdapter({
|
|
982
|
+
model: modelId,
|
|
983
|
+
apiKey: opts.openAI?.apiKey,
|
|
984
|
+
baseUrl: opts.openAI?.baseUrl,
|
|
985
|
+
})));
|
|
986
|
+
const anthropicModels = modelsOrDefault(opts.anthropic?.models, [
|
|
987
|
+
"claude-sonnet-4-5",
|
|
988
|
+
]);
|
|
989
|
+
providers.push(simpleProvider("anthropic", "Anthropic", anthropicModels.map((modelId) => ({
|
|
990
|
+
providerId: "anthropic",
|
|
991
|
+
modelId,
|
|
992
|
+
displayName: modelId,
|
|
993
|
+
capabilities: {
|
|
994
|
+
streaming: true,
|
|
995
|
+
imageInput: true,
|
|
996
|
+
toolCalling: true,
|
|
997
|
+
},
|
|
998
|
+
})), (modelId) => anthropicAdapter({
|
|
999
|
+
model: modelId,
|
|
1000
|
+
apiKey: opts.anthropic?.apiKey,
|
|
1001
|
+
baseUrl: opts.anthropic?.baseUrl,
|
|
1002
|
+
})));
|
|
1003
|
+
const ollamaModels = modelsOrDefault(opts.ollama?.models, ["llama3.2"]);
|
|
1004
|
+
providers.push(simpleProvider("ollama", "Ollama", ollamaModels.map((modelId) => ({
|
|
1005
|
+
providerId: "ollama",
|
|
1006
|
+
modelId,
|
|
1007
|
+
displayName: modelId,
|
|
1008
|
+
capabilities: {
|
|
1009
|
+
streaming: true,
|
|
1010
|
+
imageInput: true,
|
|
1011
|
+
toolCalling: true,
|
|
1012
|
+
},
|
|
1013
|
+
})), (modelId) => ollamaAdapter({
|
|
1014
|
+
model: modelId,
|
|
1015
|
+
baseUrl: opts.ollama?.baseUrl,
|
|
1016
|
+
})));
|
|
1017
|
+
return providers;
|
|
1018
|
+
}
|
|
1019
|
+
export function staticTextModel(name, text) {
|
|
1020
|
+
const generate = ((messages, opts) => {
|
|
1021
|
+
void messages;
|
|
1022
|
+
const requestedStream = Boolean(opts?.stream);
|
|
1023
|
+
if (!requestedStream) {
|
|
1024
|
+
return Promise.resolve({
|
|
1025
|
+
message: { role: "assistant", content: text },
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
return (async function* () {
|
|
1029
|
+
const tokens = text.split(/(\s+)/).filter(Boolean);
|
|
1030
|
+
for (const token of tokens) {
|
|
1031
|
+
if (opts?.signal?.aborted) {
|
|
1032
|
+
throw createAbortError();
|
|
1033
|
+
}
|
|
1034
|
+
yield { type: "token", token };
|
|
1035
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
1036
|
+
}
|
|
1037
|
+
yield {
|
|
1038
|
+
type: "assistant_message",
|
|
1039
|
+
message: { role: "assistant", content: text },
|
|
1040
|
+
};
|
|
1041
|
+
})();
|
|
1042
|
+
});
|
|
1043
|
+
return {
|
|
1044
|
+
name,
|
|
1045
|
+
capabilities: {
|
|
1046
|
+
functionCall: false,
|
|
1047
|
+
streaming: true,
|
|
1048
|
+
},
|
|
1049
|
+
generate,
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
export function createRuntimeHttpServer(runtime, options = {}) {
|
|
1053
|
+
const host = options.host ?? "127.0.0.1";
|
|
1054
|
+
const port = options.port ?? 0;
|
|
1055
|
+
const maxBodyBytes = options.maxBodyBytes ?? 1_000_000;
|
|
1056
|
+
const logger = options.logger ?? fallbackLogger();
|
|
1057
|
+
const apiKey = options.apiKey;
|
|
1058
|
+
let server;
|
|
1059
|
+
const handler = async (req, res) => {
|
|
1060
|
+
const correlationId = randomUUID();
|
|
1061
|
+
const remoteAddress = req.socket.remoteAddress ?? "";
|
|
1062
|
+
if (!isLocalAddress(remoteAddress)) {
|
|
1063
|
+
writeJson(res, 403, createRuntimeErrorEnvelope("invalid_request", "Only localhost clients are allowed"), correlationId);
|
|
1064
|
+
logger.warn("http.reject.non_local", {
|
|
1065
|
+
correlationId,
|
|
1066
|
+
remoteAddress,
|
|
1067
|
+
method: req.method ?? "GET",
|
|
1068
|
+
url: req.url ?? "/",
|
|
1069
|
+
});
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (apiKey) {
|
|
1073
|
+
const auth = req.headers.authorization;
|
|
1074
|
+
if (auth !== `Bearer ${apiKey}`) {
|
|
1075
|
+
writeJson(res, 401, createRuntimeErrorEnvelope("invalid_request", "Invalid authorization token"), correlationId);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1080
|
+
const path = requestUrl.pathname;
|
|
1081
|
+
const method = req.method ?? "GET";
|
|
1082
|
+
logger.info("http.request", {
|
|
1083
|
+
correlationId,
|
|
1084
|
+
method,
|
|
1085
|
+
path,
|
|
1086
|
+
remoteAddress,
|
|
1087
|
+
});
|
|
1088
|
+
try {
|
|
1089
|
+
if (method === "GET" && path === "/health") {
|
|
1090
|
+
writeJson(res, 200, runtime.health(), correlationId);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (method === "GET" && path === "/providers") {
|
|
1094
|
+
writeJson(res, 200, runtime.listProviders(), correlationId);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (method === "GET" && path === "/settings/default-model") {
|
|
1098
|
+
const config = await runtime.getDefaultModel();
|
|
1099
|
+
writeJson(res, 200, {
|
|
1100
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1101
|
+
config,
|
|
1102
|
+
}, correlationId);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (method === "PUT" && path === "/settings/default-model") {
|
|
1106
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1107
|
+
const parsed = defaultModelConfigSchema.parse(body);
|
|
1108
|
+
const config = await runtime.setDefaultModel(parsed);
|
|
1109
|
+
writeJson(res, 200, {
|
|
1110
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1111
|
+
config,
|
|
1112
|
+
}, correlationId);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (method === "POST" && path === "/threads") {
|
|
1116
|
+
const body = (await readJsonBody(req, maxBodyBytes));
|
|
1117
|
+
const thread = await runtime.createThread(body);
|
|
1118
|
+
writeJson(res, 201, {
|
|
1119
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1120
|
+
thread,
|
|
1121
|
+
}, correlationId);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (method === "GET" && path === "/threads") {
|
|
1125
|
+
const cursor = requestUrl.searchParams.get("cursor") ?? undefined;
|
|
1126
|
+
const limit = parseLimit(requestUrl.searchParams.get("limit"), 20);
|
|
1127
|
+
const result = await runtime.listThreads(limit, cursor);
|
|
1128
|
+
writeJson(res, 200, result, correlationId);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (method === "GET" && path.startsWith("/threads/")) {
|
|
1132
|
+
const threadId = path.slice("/threads/".length);
|
|
1133
|
+
const cursor = requestUrl.searchParams.get("cursor") ?? undefined;
|
|
1134
|
+
const limit = parseLimit(requestUrl.searchParams.get("limit"), 100);
|
|
1135
|
+
const thread = await runtime.getThread(threadId, limit, cursor);
|
|
1136
|
+
if (!thread) {
|
|
1137
|
+
writeJson(res, 404, createRuntimeErrorEnvelope("not_found", `Thread '${threadId}' not found`), correlationId);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
writeJson(res, 200, thread, correlationId);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (method === "POST" && path.endsWith("/override-model") && path.startsWith("/threads/")) {
|
|
1144
|
+
const threadId = path.slice("/threads/".length, -"/override-model".length);
|
|
1145
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1146
|
+
const parsed = parseSetThreadModelOverrideRequest(body);
|
|
1147
|
+
const updated = await runtime.setThreadModelOverride({
|
|
1148
|
+
threadId,
|
|
1149
|
+
providerId: parsed.providerId,
|
|
1150
|
+
modelId: parsed.modelId,
|
|
1151
|
+
});
|
|
1152
|
+
writeJson(res, 200, {
|
|
1153
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1154
|
+
thread: updated,
|
|
1155
|
+
}, correlationId);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (method === "GET" && path === "/search") {
|
|
1159
|
+
const query = requestUrl.searchParams.get("query") ?? "";
|
|
1160
|
+
const cursor = requestUrl.searchParams.get("cursor") ?? undefined;
|
|
1161
|
+
const limit = parseLimit(requestUrl.searchParams.get("limit"), 20);
|
|
1162
|
+
const result = await runtime.searchHistory(query, limit, cursor);
|
|
1163
|
+
writeJson(res, 200, result, correlationId);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (method === "POST" && path === "/threads/branch") {
|
|
1167
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1168
|
+
const result = await runtime.branchThread(parseBranchThreadRequest(body));
|
|
1169
|
+
writeJson(res, 201, result, correlationId);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (method === "POST" && path === "/chat/generate") {
|
|
1173
|
+
const body = await readJsonBody(req, maxBodyBytes);
|
|
1174
|
+
const accepted = await runtime.generate(body);
|
|
1175
|
+
writeJson(res, 202, accepted, correlationId);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (method === "POST" && path.startsWith("/streams/") && path.endsWith("/cancel")) {
|
|
1179
|
+
const streamId = path.slice("/streams/".length, -"/cancel".length);
|
|
1180
|
+
const cancelled = await runtime.cancelStream(streamId);
|
|
1181
|
+
writeJson(res, 202, cancelled, correlationId);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (method === "GET" && path.startsWith("/streams/") && path.endsWith("/status")) {
|
|
1185
|
+
const streamId = path.slice("/streams/".length, -"/status".length);
|
|
1186
|
+
const status = await runtime.getStreamStatus(streamId);
|
|
1187
|
+
if (!status) {
|
|
1188
|
+
writeJson(res, 404, createRuntimeErrorEnvelope("not_found", `Stream '${streamId}' not found`), correlationId);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
writeJson(res, 200, status, correlationId);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (method === "GET" && path.startsWith("/streams/") && path.endsWith("/events")) {
|
|
1195
|
+
const streamId = path.slice("/streams/".length, -"/events".length);
|
|
1196
|
+
res.statusCode = 200;
|
|
1197
|
+
res.setHeader("content-type", "text/event-stream");
|
|
1198
|
+
res.setHeader("cache-control", "no-cache");
|
|
1199
|
+
res.setHeader("connection", "keep-alive");
|
|
1200
|
+
res.setHeader("x-correlation-id", correlationId);
|
|
1201
|
+
for await (const event of runtime.streamEvents(streamId)) {
|
|
1202
|
+
res.write(`event: ${event.type}\n`);
|
|
1203
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
1204
|
+
}
|
|
1205
|
+
res.end();
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
writeJson(res, 404, createRuntimeErrorEnvelope("not_found", `Route '${method} ${path}' is not defined`), correlationId);
|
|
1209
|
+
}
|
|
1210
|
+
catch (err) {
|
|
1211
|
+
const envelope = toHttpError(err);
|
|
1212
|
+
logger.error("http.error", {
|
|
1213
|
+
correlationId,
|
|
1214
|
+
method,
|
|
1215
|
+
path,
|
|
1216
|
+
code: envelope.error.code,
|
|
1217
|
+
message: envelope.error.message,
|
|
1218
|
+
});
|
|
1219
|
+
writeJson(res, statusCodeFromError(envelope.error.code), envelope, correlationId);
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
return {
|
|
1223
|
+
async start() {
|
|
1224
|
+
if (server) {
|
|
1225
|
+
const addr = server.address();
|
|
1226
|
+
if (!addr || typeof addr === "string") {
|
|
1227
|
+
return { host, port };
|
|
1228
|
+
}
|
|
1229
|
+
return { host, port: addr.port };
|
|
1230
|
+
}
|
|
1231
|
+
await runtime.start();
|
|
1232
|
+
server = http.createServer((req, res) => {
|
|
1233
|
+
void handler(req, res);
|
|
1234
|
+
});
|
|
1235
|
+
await new Promise((resolve, reject) => {
|
|
1236
|
+
server?.once("error", reject);
|
|
1237
|
+
server?.listen(port, host, () => resolve());
|
|
1238
|
+
});
|
|
1239
|
+
const addr = server.address();
|
|
1240
|
+
const activePort = addr && typeof addr !== "string" ? addr.port : port;
|
|
1241
|
+
logger.info("http.started", {
|
|
1242
|
+
host,
|
|
1243
|
+
port: activePort,
|
|
1244
|
+
});
|
|
1245
|
+
return { host, port: activePort };
|
|
1246
|
+
},
|
|
1247
|
+
async stop() {
|
|
1248
|
+
if (!server)
|
|
1249
|
+
return;
|
|
1250
|
+
const current = server;
|
|
1251
|
+
server = undefined;
|
|
1252
|
+
await new Promise((resolve, reject) => {
|
|
1253
|
+
current.close((err) => (err ? reject(err) : resolve()));
|
|
1254
|
+
});
|
|
1255
|
+
await runtime.stop();
|
|
1256
|
+
logger.info("http.stopped", {});
|
|
1257
|
+
},
|
|
1258
|
+
address() {
|
|
1259
|
+
if (!server)
|
|
1260
|
+
return null;
|
|
1261
|
+
return server.address();
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
export const runtimeDesktopInternal = {
|
|
1266
|
+
makeMemoryStorage,
|
|
1267
|
+
createRuntimeErrorEnvelope,
|
|
1268
|
+
isLocalAddress,
|
|
1269
|
+
};
|