@witqq/agent-sdk 0.7.0 → 0.8.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/README.md +140 -34
- package/dist/{types-CqvUAYxt.d.cts → agent-CW9XbmG_.d.ts} +137 -102
- package/dist/{types-CqvUAYxt.d.ts → agent-DxY68NZL.d.cts} +137 -102
- package/dist/auth/index.cjs +72 -1
- package/dist/auth/index.cjs.map +1 -1
- package/dist/auth/index.d.cts +21 -154
- package/dist/auth/index.d.ts +21 -154
- package/dist/auth/index.js +72 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/backends/claude.cjs +480 -261
- package/dist/backends/claude.cjs.map +1 -1
- package/dist/backends/claude.d.cts +3 -1
- package/dist/backends/claude.d.ts +3 -1
- package/dist/backends/claude.js +480 -261
- package/dist/backends/claude.js.map +1 -1
- package/dist/backends/copilot.cjs +329 -97
- package/dist/backends/copilot.cjs.map +1 -1
- package/dist/backends/copilot.d.cts +12 -4
- package/dist/backends/copilot.d.ts +12 -4
- package/dist/backends/copilot.js +329 -97
- package/dist/backends/copilot.js.map +1 -1
- package/dist/backends/vercel-ai.cjs +294 -61
- package/dist/backends/vercel-ai.cjs.map +1 -1
- package/dist/backends/vercel-ai.d.cts +3 -1
- package/dist/backends/vercel-ai.d.ts +3 -1
- package/dist/backends/vercel-ai.js +294 -61
- package/dist/backends/vercel-ai.js.map +1 -1
- package/dist/backends-BSrsBYFn.d.cts +39 -0
- package/dist/backends-BSrsBYFn.d.ts +39 -0
- package/dist/chat/accumulator.cjs +1 -1
- package/dist/chat/accumulator.cjs.map +1 -1
- package/dist/chat/accumulator.d.cts +5 -2
- package/dist/chat/accumulator.d.ts +5 -2
- package/dist/chat/accumulator.js +1 -1
- package/dist/chat/accumulator.js.map +1 -1
- package/dist/chat/backends.cjs +736 -746
- package/dist/chat/backends.cjs.map +1 -1
- package/dist/chat/backends.d.cts +10 -6
- package/dist/chat/backends.d.ts +10 -6
- package/dist/chat/backends.js +736 -725
- package/dist/chat/backends.js.map +1 -1
- package/dist/chat/context.cjs +50 -0
- package/dist/chat/context.cjs.map +1 -1
- package/dist/chat/context.d.cts +27 -3
- package/dist/chat/context.d.ts +27 -3
- package/dist/chat/context.js +50 -0
- package/dist/chat/context.js.map +1 -1
- package/dist/chat/core.cjs +25 -2
- package/dist/chat/core.cjs.map +1 -1
- package/dist/chat/core.d.cts +30 -381
- package/dist/chat/core.d.ts +30 -381
- package/dist/chat/core.js +24 -3
- package/dist/chat/core.js.map +1 -1
- package/dist/chat/errors.cjs +48 -26
- package/dist/chat/errors.cjs.map +1 -1
- package/dist/chat/errors.d.cts +6 -31
- package/dist/chat/errors.d.ts +6 -31
- package/dist/chat/errors.js +48 -25
- package/dist/chat/errors.js.map +1 -1
- package/dist/chat/events.cjs.map +1 -1
- package/dist/chat/events.d.cts +6 -2
- package/dist/chat/events.d.ts +6 -2
- package/dist/chat/events.js.map +1 -1
- package/dist/chat/index.cjs +1199 -1008
- package/dist/chat/index.cjs.map +1 -1
- package/dist/chat/index.d.cts +35 -10
- package/dist/chat/index.d.ts +35 -10
- package/dist/chat/index.js +1196 -987
- package/dist/chat/index.js.map +1 -1
- package/dist/chat/react/theme.css +2517 -0
- package/dist/chat/react.cjs +2003 -1153
- package/dist/chat/react.cjs.map +1 -1
- package/dist/chat/react.d.cts +590 -121
- package/dist/chat/react.d.ts +590 -121
- package/dist/chat/react.js +1984 -1151
- package/dist/chat/react.js.map +1 -1
- package/dist/chat/runtime.cjs +401 -186
- package/dist/chat/runtime.cjs.map +1 -1
- package/dist/chat/runtime.d.cts +92 -28
- package/dist/chat/runtime.d.ts +92 -28
- package/dist/chat/runtime.js +401 -186
- package/dist/chat/runtime.js.map +1 -1
- package/dist/chat/server.cjs +2234 -209
- package/dist/chat/server.cjs.map +1 -1
- package/dist/chat/server.d.cts +451 -90
- package/dist/chat/server.d.ts +451 -90
- package/dist/chat/server.js +2221 -210
- package/dist/chat/server.js.map +1 -1
- package/dist/chat/sessions.cjs +25 -43
- package/dist/chat/sessions.cjs.map +1 -1
- package/dist/chat/sessions.d.cts +37 -118
- package/dist/chat/sessions.d.ts +37 -118
- package/dist/chat/sessions.js +25 -43
- package/dist/chat/sessions.js.map +1 -1
- package/dist/chat/sqlite.cjs +441 -0
- package/dist/chat/sqlite.cjs.map +1 -0
- package/dist/chat/sqlite.d.cts +128 -0
- package/dist/chat/sqlite.d.ts +128 -0
- package/dist/chat/sqlite.js +435 -0
- package/dist/chat/sqlite.js.map +1 -0
- package/dist/chat/state.cjs +14 -1
- package/dist/chat/state.cjs.map +1 -1
- package/dist/chat/state.d.cts +5 -2
- package/dist/chat/state.d.ts +5 -2
- package/dist/chat/state.js +14 -1
- package/dist/chat/state.js.map +1 -1
- package/dist/chat/storage.cjs +19 -10
- package/dist/chat/storage.cjs.map +1 -1
- package/dist/chat/storage.d.cts +11 -5
- package/dist/chat/storage.d.ts +11 -5
- package/dist/chat/storage.js +19 -10
- package/dist/chat/storage.js.map +1 -1
- package/dist/errors-C-so0M4t.d.cts +33 -0
- package/dist/errors-C-so0M4t.d.ts +33 -0
- package/dist/errors-CmVvczxZ.d.cts +28 -0
- package/dist/errors-CmVvczxZ.d.ts +28 -0
- package/dist/{in-process-transport-C2oPTYs6.d.ts → in-process-transport-C1JnJGVR.d.ts} +28 -23
- package/dist/{in-process-transport-DG-w5G6k.d.cts → in-process-transport-C7DSqPyX.d.cts} +28 -23
- package/dist/index.cjs +340 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +292 -123
- package/dist/index.d.ts +292 -123
- package/dist/index.js +334 -47
- package/dist/index.js.map +1 -1
- package/dist/provider-types-PTSlRPNB.d.cts +39 -0
- package/dist/provider-types-PTSlRPNB.d.ts +39 -0
- package/dist/refresh-manager-B81PpYBr.d.cts +153 -0
- package/dist/refresh-manager-Dlv_iNZi.d.ts +153 -0
- package/dist/testing.cjs +383 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +132 -0
- package/dist/testing.d.ts +132 -0
- package/dist/testing.js +377 -0
- package/dist/testing.js.map +1 -0
- package/dist/token-store-CSUBgYwn.d.ts +48 -0
- package/dist/token-store-CuC4hB9Z.d.cts +48 -0
- package/dist/{transport-DX1Nhm4N.d.cts → transport-Cdh3M0tS.d.cts} +5 -4
- package/dist/{transport-D1OaUgRk.d.ts → transport-Ciap4PWK.d.ts} +5 -4
- package/dist/{types-CGF7AEX1.d.cts → types-4vbcmPTp.d.cts} +4 -2
- package/dist/{types-Bh5AhqD-.d.ts → types-BxggH0Yh.d.ts} +4 -2
- package/dist/types-DRgd_9R7.d.cts +363 -0
- package/dist/types-ajANVzf7.d.ts +363 -0
- package/package.json +31 -6
- package/dist/errors-BDLbNu9w.d.cts +0 -13
- package/dist/errors-BDLbNu9w.d.ts +0 -13
- package/dist/types-DLZzlJxt.d.ts +0 -39
- package/dist/types-tE0CXwBl.d.cts +0 -39
package/dist/chat/server.js
CHANGED
|
@@ -1,8 +1,104 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
1
2
|
import * as fs from 'fs';
|
|
2
3
|
import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync, readdirSync } from 'fs';
|
|
3
4
|
import * as path from 'path';
|
|
4
5
|
import { join } from 'path';
|
|
5
6
|
|
|
7
|
+
// src/chat/server/utils.ts
|
|
8
|
+
var BodyParseError = class extends Error {
|
|
9
|
+
statusCode;
|
|
10
|
+
constructor(message, statusCode) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "BodyParseError";
|
|
13
|
+
this.statusCode = statusCode;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function readBody(req, maxSize = 1048576) {
|
|
17
|
+
return new Promise((resolve2, reject) => {
|
|
18
|
+
let body = "";
|
|
19
|
+
let size = 0;
|
|
20
|
+
let exceeded = false;
|
|
21
|
+
req.on("data", (chunk) => {
|
|
22
|
+
if (exceeded) return;
|
|
23
|
+
const str = chunk.toString();
|
|
24
|
+
size += Buffer.byteLength(str);
|
|
25
|
+
if (size > maxSize) {
|
|
26
|
+
exceeded = true;
|
|
27
|
+
reject(new BodyParseError("Request body too large", 413));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
body += str;
|
|
31
|
+
});
|
|
32
|
+
req.on("end", () => {
|
|
33
|
+
if (exceeded) return;
|
|
34
|
+
try {
|
|
35
|
+
resolve2(JSON.parse(body || "{}"));
|
|
36
|
+
} catch {
|
|
37
|
+
reject(new BodyParseError("Invalid JSON in request body", 400));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
if ("once" in req && typeof req.once === "function") {
|
|
41
|
+
req.once(
|
|
42
|
+
"error",
|
|
43
|
+
() => reject(new BodyParseError("Request error", 500))
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function json(res, data, status = 200) {
|
|
49
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
50
|
+
res.end(JSON.stringify(data));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/chat/server/routes/sessions.ts
|
|
54
|
+
var sessionRoutes = async (method, path2, req, res, ctx) => {
|
|
55
|
+
const { runtime, maxBodySize } = ctx;
|
|
56
|
+
const sessionMatch = path2.match(/^\/sessions\/([^/]+)$/);
|
|
57
|
+
const contextStatsMatch = path2.match(/^\/sessions\/([^/]+)\/context-stats$/);
|
|
58
|
+
if (method === "POST" && path2 === "/sessions/create") {
|
|
59
|
+
const body = await readBody(req, maxBodySize);
|
|
60
|
+
const session = await runtime.createSession({
|
|
61
|
+
title: body.title || `Chat ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`,
|
|
62
|
+
config: body.config || {
|
|
63
|
+
model: "",
|
|
64
|
+
backend: ""
|
|
65
|
+
},
|
|
66
|
+
...body.tags ? { tags: body.tags } : {},
|
|
67
|
+
...body.custom ? { custom: body.custom } : {}
|
|
68
|
+
});
|
|
69
|
+
json(res, session);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (method === "GET" && contextStatsMatch) {
|
|
73
|
+
const id = decodeURIComponent(contextStatsMatch[1]);
|
|
74
|
+
const stats = await runtime.getContextStats(id);
|
|
75
|
+
json(res, stats ?? null);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (method === "GET" && sessionMatch) {
|
|
79
|
+
const id = decodeURIComponent(sessionMatch[1]);
|
|
80
|
+
const session = await runtime.getSession(id);
|
|
81
|
+
if (!session) {
|
|
82
|
+
json(res, { error: "Not found" }, 404);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
json(res, session);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (method === "DELETE" && sessionMatch) {
|
|
89
|
+
const id = decodeURIComponent(sessionMatch[1]);
|
|
90
|
+
await runtime.deleteSession(id);
|
|
91
|
+
json(res, { ok: true });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (method === "GET" && path2 === "/sessions") {
|
|
95
|
+
const sessions = await runtime.listSessions();
|
|
96
|
+
json(res, sessions);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
};
|
|
101
|
+
|
|
6
102
|
// src/chat/backends/transport.ts
|
|
7
103
|
var SSEChatTransport = class {
|
|
8
104
|
res;
|
|
@@ -95,115 +191,332 @@ async function streamToTransport(events, transport) {
|
|
|
95
191
|
}
|
|
96
192
|
}
|
|
97
193
|
|
|
98
|
-
// src/
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
194
|
+
// src/errors.ts
|
|
195
|
+
var AgentSDKError = class extends Error {
|
|
196
|
+
/** @internal Marker for cross-bundle identity checks */
|
|
197
|
+
_agentSDKError = true;
|
|
198
|
+
/** Machine-readable error code. Prefer values from the ErrorCode enum. */
|
|
199
|
+
code;
|
|
200
|
+
/** Whether this error is safe to retry */
|
|
201
|
+
retryable;
|
|
202
|
+
/** HTTP status code hint for error classification */
|
|
203
|
+
httpStatus;
|
|
204
|
+
constructor(message, options) {
|
|
205
|
+
super(message, options);
|
|
206
|
+
this.name = "AgentSDKError";
|
|
207
|
+
this.code = options?.code;
|
|
208
|
+
this.retryable = options?.retryable ?? false;
|
|
209
|
+
this.httpStatus = options?.httpStatus;
|
|
210
|
+
}
|
|
211
|
+
/** Check if an error is an AgentSDKError (works across bundled copies) */
|
|
212
|
+
static is(error) {
|
|
213
|
+
return error instanceof Error && "_agentSDKError" in error && error._agentSDKError === true;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// src/chat/errors.ts
|
|
218
|
+
var ChatError = class extends AgentSDKError {
|
|
219
|
+
code;
|
|
220
|
+
retryable;
|
|
221
|
+
retryAfter;
|
|
222
|
+
timestamp;
|
|
223
|
+
constructor(message, options) {
|
|
224
|
+
super(message, {
|
|
225
|
+
cause: options.cause,
|
|
226
|
+
code: options.code,
|
|
227
|
+
retryable: options.retryable
|
|
228
|
+
});
|
|
229
|
+
this.name = "ChatError";
|
|
230
|
+
this.code = options.code;
|
|
231
|
+
this.retryable = options.retryable ?? false;
|
|
232
|
+
this.retryAfter = options.retryAfter;
|
|
233
|
+
this.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// src/chat/server/request-context.ts
|
|
238
|
+
async function resolveRequestContext(providerId, deps) {
|
|
239
|
+
const provider = await deps.providerStore.get(providerId);
|
|
240
|
+
if (!provider) {
|
|
241
|
+
throw new ChatError(`Provider "${providerId}" not found`, {
|
|
242
|
+
code: "PROVIDER_NOT_FOUND" /* PROVIDER_NOT_FOUND */
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const credentials = await deps.tokenStore.load(provider.backend);
|
|
246
|
+
if (!credentials) {
|
|
247
|
+
throw new ChatError(
|
|
248
|
+
`Authentication required for backend "${provider.backend}"`,
|
|
249
|
+
{
|
|
250
|
+
code: "AUTH_REQUIRED" /* AUTH_REQUIRED */
|
|
124
251
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
backend: provider.backend,
|
|
256
|
+
credentials,
|
|
257
|
+
model: provider.model,
|
|
258
|
+
provider
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// src/chat/server/routes/messages.ts
|
|
263
|
+
var messageRoutes = async (method, path2, req, res, ctx) => {
|
|
264
|
+
const { runtime, maxBodySize, heartbeatMs, hooks, transportFactory } = ctx;
|
|
265
|
+
if (method === "POST" && path2 === "/send") {
|
|
266
|
+
const body = await readBody(req, maxBodySize);
|
|
267
|
+
const sessionId = body.sessionId;
|
|
268
|
+
const message = body.message || body.content;
|
|
269
|
+
if (!sessionId || !message) {
|
|
270
|
+
json(res, { error: "sessionId and message are required" }, 400);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
let model;
|
|
274
|
+
let reqBackend;
|
|
275
|
+
let reqCredentials;
|
|
276
|
+
const hasProviderInfra = !!(ctx.providerStore && ctx.tokenStore);
|
|
277
|
+
if (hasProviderInfra) {
|
|
278
|
+
const providerId = body.providerId;
|
|
279
|
+
if (!providerId || typeof providerId !== "string") {
|
|
280
|
+
json(res, { error: "providerId is required" }, 400);
|
|
281
|
+
return true;
|
|
130
282
|
}
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
283
|
+
try {
|
|
284
|
+
const reqCtx = await resolveRequestContext(providerId, {
|
|
285
|
+
providerStore: ctx.providerStore,
|
|
286
|
+
tokenStore: ctx.tokenStore
|
|
287
|
+
});
|
|
288
|
+
model = reqCtx.model;
|
|
289
|
+
reqBackend = reqCtx.backend;
|
|
290
|
+
reqCredentials = reqCtx.credentials;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
if (err instanceof ChatError && err.code === "PROVIDER_NOT_FOUND" /* PROVIDER_NOT_FOUND */) {
|
|
293
|
+
json(res, { error: err.message }, 404);
|
|
294
|
+
return true;
|
|
137
295
|
}
|
|
138
|
-
|
|
139
|
-
|
|
296
|
+
if (err instanceof ChatError && err.code === "AUTH_REQUIRED" /* AUTH_REQUIRED */) {
|
|
297
|
+
json(res, { error: err.message }, 401);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
throw err;
|
|
140
301
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
302
|
+
}
|
|
303
|
+
const bodyModel = body.model;
|
|
304
|
+
if (hooks?.onModelSwitch && bodyModel && typeof bodyModel === "string") {
|
|
305
|
+
try {
|
|
306
|
+
await hooks.onModelSwitch(bodyModel);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
json(res, { error: err instanceof Error ? err.message : String(err) }, 403);
|
|
309
|
+
return true;
|
|
146
310
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
311
|
+
}
|
|
312
|
+
if (hooks?.onBeforeSend) {
|
|
313
|
+
try {
|
|
314
|
+
await hooks.onBeforeSend(sessionId, message);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
json(res, { error: err instanceof Error ? err.message : String(err) }, 403);
|
|
317
|
+
return true;
|
|
151
318
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (body.model) opts.model = body.model;
|
|
167
|
-
const stream = runtime.send(
|
|
168
|
-
sessionId,
|
|
169
|
-
message,
|
|
170
|
-
Object.keys(opts).length > 0 ? opts : void 0
|
|
171
|
-
);
|
|
172
|
-
await streamToTransport(stream, transport);
|
|
173
|
-
} catch (err) {
|
|
174
|
-
transport.error(err instanceof Error ? err : new Error(String(err)));
|
|
175
|
-
}
|
|
176
|
-
return;
|
|
319
|
+
}
|
|
320
|
+
model = bodyModel || model;
|
|
321
|
+
if (!model) {
|
|
322
|
+
json(res, { error: "model is required (via body.model or providerId)" }, 400);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
const transport = transportFactory ? transportFactory(req, res) : new SSEChatTransport(res, {
|
|
326
|
+
heartbeatMs,
|
|
327
|
+
request: req
|
|
328
|
+
});
|
|
329
|
+
try {
|
|
330
|
+
if (!reqBackend || !reqCredentials) {
|
|
331
|
+
json(res, { error: "backend and credentials are required (configure providerStore + tokenStore)" }, 400);
|
|
332
|
+
return true;
|
|
177
333
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
334
|
+
const opts = { model, backend: reqBackend, credentials: reqCredentials };
|
|
335
|
+
const stream = runtime.send(sessionId, message, opts);
|
|
336
|
+
await streamToTransport(stream, transport);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
transport.error(err instanceof Error ? err : new Error(String(err)));
|
|
339
|
+
}
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
if (method === "POST" && path2 === "/abort") {
|
|
343
|
+
runtime.abort();
|
|
344
|
+
json(res, { ok: true });
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// src/chat/server/routes/config.ts
|
|
351
|
+
var configRoutes = async (method, path2, req, res, ctx) => {
|
|
352
|
+
const { runtime, maxBodySize, hooks, providerStore } = ctx;
|
|
353
|
+
if (method === "GET" && path2 === "/models") {
|
|
354
|
+
let models = await runtime.listModels();
|
|
355
|
+
if (models.length === 0 && providerStore && ctx.tokenStore) {
|
|
356
|
+
const providers = await providerStore.list();
|
|
357
|
+
for (const p of providers) {
|
|
358
|
+
const token = await ctx.tokenStore.load(p.backend);
|
|
359
|
+
if (token) {
|
|
360
|
+
models = await runtime.listModels({ backend: p.backend, credentials: token });
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
182
363
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
364
|
+
}
|
|
365
|
+
if (hooks?.filterModels) models = hooks.filterModels(models);
|
|
366
|
+
json(res, models);
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
if (method === "GET" && path2 === "/backends") {
|
|
370
|
+
const backends = await runtime.listBackends();
|
|
371
|
+
json(res, backends);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
if (method === "POST" && path2 === "/model/switch") {
|
|
375
|
+
const body = await readBody(req, maxBodySize);
|
|
376
|
+
if (!body.model || typeof body.model !== "string") {
|
|
377
|
+
json(res, { error: "model is required" }, 400);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
if (hooks?.onModelSwitch) {
|
|
381
|
+
try {
|
|
382
|
+
await hooks.onModelSwitch(body.model);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
json(res, { error: err instanceof Error ? err.message : String(err) }, 403);
|
|
385
|
+
return true;
|
|
187
386
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
387
|
+
}
|
|
388
|
+
json(res, { ok: true });
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
if (method === "POST" && path2 === "/provider/switch") {
|
|
392
|
+
const body = await readBody(req, maxBodySize);
|
|
393
|
+
if (!body.providerId || typeof body.providerId !== "string") {
|
|
394
|
+
json(res, { error: "providerId is required" }, 400);
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
if (!providerStore) {
|
|
398
|
+
json(res, { error: "No provider store configured" }, 400);
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
const provider = await providerStore.get(body.providerId);
|
|
402
|
+
if (!provider) {
|
|
403
|
+
json(res, { error: `Provider "${body.providerId}" not found` }, 404);
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
if (hooks?.onProviderSwitch) {
|
|
407
|
+
try {
|
|
408
|
+
await hooks.onProviderSwitch({ providerId: body.providerId, backend: provider.backend });
|
|
409
|
+
} catch (err) {
|
|
410
|
+
json(res, { error: err instanceof Error ? err.message : String(err) }, 400);
|
|
411
|
+
return true;
|
|
197
412
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
413
|
+
}
|
|
414
|
+
json(res, { ok: true });
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
};
|
|
419
|
+
var providerRoutes = async (method, path2, req, res, ctx) => {
|
|
420
|
+
const { providerStore, maxBodySize } = ctx;
|
|
421
|
+
if (!providerStore) return false;
|
|
422
|
+
const idMatch = path2.match(/^\/providers\/([^/]+)$/);
|
|
423
|
+
if (method === "GET" && path2 === "/providers") {
|
|
424
|
+
const providers = await providerStore.list();
|
|
425
|
+
json(res, providers);
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
if (method === "GET" && idMatch) {
|
|
429
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
430
|
+
const provider = await providerStore.get(id);
|
|
431
|
+
if (!provider) {
|
|
432
|
+
json(res, { error: "Provider not found" }, 404);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
json(res, provider);
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
if (method === "POST" && path2 === "/providers") {
|
|
439
|
+
const body = await readBody(req, maxBodySize);
|
|
440
|
+
const backend = body.backend;
|
|
441
|
+
const model = body.model;
|
|
442
|
+
const label = body.label;
|
|
443
|
+
if (!backend || typeof backend !== "string") {
|
|
444
|
+
json(res, { error: "backend is required" }, 400);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
if (!model || typeof model !== "string") {
|
|
448
|
+
json(res, { error: "model is required" }, 400);
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
if (!label || typeof label !== "string") {
|
|
452
|
+
json(res, { error: "label is required" }, 400);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
const config = {
|
|
456
|
+
id: randomUUID(),
|
|
457
|
+
backend,
|
|
458
|
+
model,
|
|
459
|
+
label,
|
|
460
|
+
createdAt: Date.now()
|
|
461
|
+
};
|
|
462
|
+
await providerStore.create(config);
|
|
463
|
+
json(res, config, 201);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
if (method === "PUT" && idMatch) {
|
|
467
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
468
|
+
const existing = await providerStore.get(id);
|
|
469
|
+
if (!existing) {
|
|
470
|
+
json(res, { error: "Provider not found" }, 404);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
const body = await readBody(req, maxBodySize);
|
|
474
|
+
const changes = {};
|
|
475
|
+
if (body.backend && typeof body.backend === "string") changes.backend = body.backend;
|
|
476
|
+
if (body.model && typeof body.model === "string") changes.model = body.model;
|
|
477
|
+
if (body.label && typeof body.label === "string") changes.label = body.label;
|
|
478
|
+
await providerStore.update(id, changes);
|
|
479
|
+
const updated = await providerStore.get(id);
|
|
480
|
+
json(res, updated);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
if (method === "DELETE" && idMatch) {
|
|
484
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
485
|
+
await providerStore.delete(id);
|
|
486
|
+
json(res, { ok: true });
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
return false;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// src/chat/server/handler.ts
|
|
493
|
+
var ROUTE_PIPELINE = [
|
|
494
|
+
sessionRoutes,
|
|
495
|
+
messageRoutes,
|
|
496
|
+
configRoutes,
|
|
497
|
+
providerRoutes
|
|
498
|
+
];
|
|
499
|
+
function createChatHandler(runtime, options) {
|
|
500
|
+
const prefix = options?.prefix ?? "";
|
|
501
|
+
const state = {};
|
|
502
|
+
const ctx = {
|
|
503
|
+
runtime,
|
|
504
|
+
maxBodySize: options?.maxBodySize ?? 1048576,
|
|
505
|
+
heartbeatMs: options?.heartbeatMs,
|
|
506
|
+
hooks: options?.hooks,
|
|
507
|
+
providerStore: options?.providerStore,
|
|
508
|
+
tokenStore: options?.tokenStore,
|
|
509
|
+
transportFactory: options?.transportFactory,
|
|
510
|
+
state
|
|
511
|
+
};
|
|
512
|
+
return async (req, res) => {
|
|
513
|
+
const url = req.url || "";
|
|
514
|
+
const method = req.method || "GET";
|
|
515
|
+
const rawPath = prefix ? url.slice(prefix.length) : url;
|
|
516
|
+
const path2 = rawPath.split("?")[0];
|
|
517
|
+
try {
|
|
518
|
+
for (const route of ROUTE_PIPELINE) {
|
|
519
|
+
if (await route(method, path2, req, res, ctx)) return;
|
|
207
520
|
}
|
|
208
521
|
json(res, { error: "Not found" }, 404);
|
|
209
522
|
} catch (err) {
|
|
@@ -211,55 +524,14 @@ function createChatHandler(runtime, options) {
|
|
|
211
524
|
json(res, { error: err.message }, err.statusCode);
|
|
212
525
|
} else {
|
|
213
526
|
const message = err instanceof Error ? err.message : String(err);
|
|
527
|
+
if (ctx.hooks?.onError) {
|
|
528
|
+
ctx.hooks.onError(err instanceof Error ? err : new Error(message), { route: path2, method });
|
|
529
|
+
}
|
|
214
530
|
json(res, { error: message }, 500);
|
|
215
531
|
}
|
|
216
532
|
}
|
|
217
533
|
};
|
|
218
534
|
}
|
|
219
|
-
var BodyParseError = class extends Error {
|
|
220
|
-
statusCode;
|
|
221
|
-
constructor(message, statusCode) {
|
|
222
|
-
super(message);
|
|
223
|
-
this.name = "BodyParseError";
|
|
224
|
-
this.statusCode = statusCode;
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
function readBody(req, maxSize) {
|
|
228
|
-
return new Promise((resolve2, reject) => {
|
|
229
|
-
let body = "";
|
|
230
|
-
let size = 0;
|
|
231
|
-
let exceeded = false;
|
|
232
|
-
req.on("data", (chunk) => {
|
|
233
|
-
if (exceeded) return;
|
|
234
|
-
const str = chunk.toString();
|
|
235
|
-
size += Buffer.byteLength(str);
|
|
236
|
-
if (size > maxSize) {
|
|
237
|
-
exceeded = true;
|
|
238
|
-
reject(new BodyParseError("Request body too large", 413));
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
body += str;
|
|
242
|
-
});
|
|
243
|
-
req.on("end", () => {
|
|
244
|
-
if (exceeded) return;
|
|
245
|
-
try {
|
|
246
|
-
resolve2(JSON.parse(body || "{}"));
|
|
247
|
-
} catch {
|
|
248
|
-
reject(new BodyParseError("Invalid JSON in request body", 400));
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
if ("once" in req && typeof req.once === "function") {
|
|
252
|
-
req.once(
|
|
253
|
-
"error",
|
|
254
|
-
() => reject(new BodyParseError("Request error", 500))
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
function json(res, data, status = 200) {
|
|
260
|
-
res.writeHead(status, { "Content-Type": "application/json" });
|
|
261
|
-
res.end(JSON.stringify(data));
|
|
262
|
-
}
|
|
263
535
|
|
|
264
536
|
// src/chat/server/auth-handler.ts
|
|
265
537
|
function createAuthHandler(options) {
|
|
@@ -275,74 +547,74 @@ function createAuthHandler(options) {
|
|
|
275
547
|
const path2 = rawPath.split("?")[0];
|
|
276
548
|
try {
|
|
277
549
|
if (method === "POST" && path2 === "/auth/start") {
|
|
278
|
-
const body = await
|
|
550
|
+
const body = await readBody(req, maxBodySize);
|
|
279
551
|
const provider = body.provider;
|
|
280
552
|
if (!provider || !isValidProvider(provider)) {
|
|
281
|
-
|
|
553
|
+
json(res, { error: "provider is required (copilot, claude, vercel-ai)" }, 400);
|
|
282
554
|
return;
|
|
283
555
|
}
|
|
284
556
|
pendingCopilot = null;
|
|
285
557
|
pendingClaude = null;
|
|
286
558
|
if (provider === "copilot") {
|
|
287
559
|
if (!options.createCopilotAuth) {
|
|
288
|
-
|
|
560
|
+
json(res, { error: "Copilot auth not configured" }, 400);
|
|
289
561
|
return;
|
|
290
562
|
}
|
|
291
563
|
const auth = options.createCopilotAuth();
|
|
292
564
|
const flow = await auth.startDeviceFlow();
|
|
293
565
|
pendingCopilot = { waitForToken: flow.waitForToken };
|
|
294
|
-
|
|
566
|
+
json(res, { userCode: flow.userCode, verificationUrl: flow.verificationUrl });
|
|
295
567
|
return;
|
|
296
568
|
}
|
|
297
569
|
if (provider === "claude") {
|
|
298
570
|
if (!options.createClaudeAuth) {
|
|
299
|
-
|
|
571
|
+
json(res, { error: "Claude auth not configured" }, 400);
|
|
300
572
|
return;
|
|
301
573
|
}
|
|
302
574
|
const auth = options.createClaudeAuth();
|
|
303
575
|
const flow = auth.startOAuthFlow();
|
|
304
576
|
pendingClaude = { completeAuth: flow.completeAuth };
|
|
305
|
-
|
|
577
|
+
json(res, { authorizeUrl: flow.authorizeUrl });
|
|
306
578
|
return;
|
|
307
579
|
}
|
|
308
|
-
|
|
580
|
+
json(res, { ready: true });
|
|
309
581
|
return;
|
|
310
582
|
}
|
|
311
583
|
if (method === "POST" && path2 === "/auth/copilot/poll") {
|
|
312
584
|
if (!pendingCopilot) {
|
|
313
|
-
|
|
585
|
+
json(res, { error: "No active Copilot flow" }, 400);
|
|
314
586
|
return;
|
|
315
587
|
}
|
|
316
588
|
const token = await pendingCopilot.waitForToken();
|
|
317
589
|
pendingCopilot = null;
|
|
318
590
|
await tokenStore.save("copilot", token);
|
|
319
591
|
if (onAuth) await onAuth("copilot", token);
|
|
320
|
-
|
|
592
|
+
json(res, { ok: true, login: token.login });
|
|
321
593
|
return;
|
|
322
594
|
}
|
|
323
595
|
if (method === "POST" && path2 === "/auth/claude/complete") {
|
|
324
596
|
if (!pendingClaude) {
|
|
325
|
-
|
|
597
|
+
json(res, { error: "No active Claude flow" }, 400);
|
|
326
598
|
return;
|
|
327
599
|
}
|
|
328
|
-
const body = await
|
|
600
|
+
const body = await readBody(req, maxBodySize);
|
|
329
601
|
const code = body.code;
|
|
330
602
|
if (!code || typeof code !== "string") {
|
|
331
|
-
|
|
603
|
+
json(res, { error: "code is required" }, 400);
|
|
332
604
|
return;
|
|
333
605
|
}
|
|
334
606
|
const token = await pendingClaude.completeAuth(code);
|
|
335
607
|
pendingClaude = null;
|
|
336
608
|
await tokenStore.save("claude", token);
|
|
337
609
|
if (onAuth) await onAuth("claude", token);
|
|
338
|
-
|
|
610
|
+
json(res, { ok: true });
|
|
339
611
|
return;
|
|
340
612
|
}
|
|
341
613
|
if (method === "POST" && path2 === "/auth/vercel/complete") {
|
|
342
|
-
const body = await
|
|
614
|
+
const body = await readBody(req, maxBodySize);
|
|
343
615
|
const apiKey = body.apiKey;
|
|
344
616
|
if (!apiKey || typeof apiKey !== "string") {
|
|
345
|
-
|
|
617
|
+
json(res, { error: "apiKey is required" }, 400);
|
|
346
618
|
return;
|
|
347
619
|
}
|
|
348
620
|
const token = {
|
|
@@ -353,86 +625,57 @@ function createAuthHandler(options) {
|
|
|
353
625
|
const storeToken = body.baseUrl ? { ...token, baseUrl: body.baseUrl } : token;
|
|
354
626
|
await tokenStore.save("vercel-ai", storeToken);
|
|
355
627
|
if (onAuth) await onAuth("vercel-ai", storeToken);
|
|
356
|
-
|
|
628
|
+
json(res, { ok: true });
|
|
357
629
|
return;
|
|
358
630
|
}
|
|
359
631
|
if (method === "GET" && path2 === "/tokens/saved") {
|
|
360
632
|
const saved = await tokenStore.list();
|
|
361
|
-
|
|
633
|
+
json(res, { saved });
|
|
362
634
|
return;
|
|
363
635
|
}
|
|
364
636
|
if (method === "POST" && path2 === "/tokens/use") {
|
|
365
|
-
const body = await
|
|
637
|
+
const body = await readBody(req, maxBodySize);
|
|
366
638
|
const provider = body.provider;
|
|
367
639
|
if (!provider || !isValidProvider(provider)) {
|
|
368
|
-
|
|
640
|
+
json(res, { error: "provider is required (copilot, claude, vercel-ai)" }, 400);
|
|
369
641
|
return;
|
|
370
642
|
}
|
|
371
643
|
const token = await tokenStore.load(provider);
|
|
372
644
|
if (!token) {
|
|
373
|
-
|
|
645
|
+
json(res, { error: `No saved token for ${provider}` }, 404);
|
|
374
646
|
return;
|
|
375
647
|
}
|
|
376
648
|
if (onAuth) await onAuth(provider, token);
|
|
377
|
-
|
|
649
|
+
json(res, { ok: true, provider });
|
|
378
650
|
return;
|
|
379
651
|
}
|
|
380
652
|
if (method === "POST" && path2 === "/tokens/clear") {
|
|
381
653
|
await tokenStore.clearAll();
|
|
382
654
|
if (options.onLogout) await options.onLogout();
|
|
383
|
-
|
|
655
|
+
json(res, { ok: true });
|
|
384
656
|
return;
|
|
385
657
|
}
|
|
386
658
|
if (method === "POST" && path2 === "/auth/dispose") {
|
|
387
659
|
pendingCopilot = null;
|
|
388
660
|
pendingClaude = null;
|
|
389
661
|
if (options.onLogout) await options.onLogout();
|
|
390
|
-
|
|
662
|
+
json(res, { ok: true });
|
|
391
663
|
return;
|
|
392
664
|
}
|
|
393
|
-
|
|
665
|
+
json(res, { error: "Not found" }, 404);
|
|
394
666
|
} catch (err) {
|
|
395
|
-
|
|
396
|
-
|
|
667
|
+
if (err instanceof BodyParseError) {
|
|
668
|
+
json(res, { error: err.message }, err.statusCode);
|
|
669
|
+
} else {
|
|
670
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
671
|
+
json(res, { error: message }, 500);
|
|
672
|
+
}
|
|
397
673
|
}
|
|
398
674
|
};
|
|
399
675
|
}
|
|
400
676
|
function isValidProvider(p) {
|
|
401
677
|
return p === "copilot" || p === "claude" || p === "vercel-ai";
|
|
402
678
|
}
|
|
403
|
-
function readBody2(req, maxSize) {
|
|
404
|
-
return new Promise((resolve2) => {
|
|
405
|
-
let body = "";
|
|
406
|
-
let size = 0;
|
|
407
|
-
let exceeded = false;
|
|
408
|
-
req.on("data", (chunk) => {
|
|
409
|
-
if (exceeded) return;
|
|
410
|
-
const str = chunk.toString();
|
|
411
|
-
size += Buffer.byteLength(str);
|
|
412
|
-
if (size > maxSize) {
|
|
413
|
-
exceeded = true;
|
|
414
|
-
resolve2({});
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
body += str;
|
|
418
|
-
});
|
|
419
|
-
req.on("end", () => {
|
|
420
|
-
if (exceeded) return;
|
|
421
|
-
try {
|
|
422
|
-
resolve2(JSON.parse(body || "{}"));
|
|
423
|
-
} catch {
|
|
424
|
-
resolve2({});
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
if ("once" in req && typeof req.once === "function") {
|
|
428
|
-
req.once("error", () => resolve2({}));
|
|
429
|
-
}
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
function json2(res, data, status = 200) {
|
|
433
|
-
res.writeHead(status, { "Content-Type": "application/json" });
|
|
434
|
-
res.end(JSON.stringify(data));
|
|
435
|
-
}
|
|
436
679
|
|
|
437
680
|
// src/chat/server/cors.ts
|
|
438
681
|
function corsMiddleware(options) {
|
|
@@ -463,6 +706,1208 @@ function corsMiddleware(options) {
|
|
|
463
706
|
return false;
|
|
464
707
|
};
|
|
465
708
|
}
|
|
709
|
+
|
|
710
|
+
// src/chat/types.ts
|
|
711
|
+
function createChatId() {
|
|
712
|
+
return crypto.randomUUID();
|
|
713
|
+
}
|
|
714
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
715
|
+
function toChatId(value) {
|
|
716
|
+
if (!UUID_RE.test(value)) {
|
|
717
|
+
throw new TypeError(`Invalid ChatId: "${value}" is not a valid UUID`);
|
|
718
|
+
}
|
|
719
|
+
return value;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/chat/bridge.ts
|
|
723
|
+
function chatEventToAgentEvent(event) {
|
|
724
|
+
switch (event.type) {
|
|
725
|
+
case "message:delta":
|
|
726
|
+
return { type: "text_delta", text: event.text };
|
|
727
|
+
case "thinking:start":
|
|
728
|
+
return { type: "thinking_start" };
|
|
729
|
+
case "thinking:delta":
|
|
730
|
+
return { type: "thinking_delta", text: event.text };
|
|
731
|
+
case "thinking:end":
|
|
732
|
+
return { type: "thinking_end" };
|
|
733
|
+
case "tool:start":
|
|
734
|
+
return {
|
|
735
|
+
type: "tool_call_start",
|
|
736
|
+
toolCallId: event.toolCallId,
|
|
737
|
+
toolName: event.toolName,
|
|
738
|
+
args: event.args
|
|
739
|
+
};
|
|
740
|
+
case "tool:complete":
|
|
741
|
+
return {
|
|
742
|
+
type: "tool_call_end",
|
|
743
|
+
toolCallId: event.toolCallId,
|
|
744
|
+
toolName: event.toolName,
|
|
745
|
+
result: event.result
|
|
746
|
+
};
|
|
747
|
+
case "error":
|
|
748
|
+
return { type: "error", error: event.error, recoverable: event.recoverable, code: event.code };
|
|
749
|
+
default:
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/chat/context.ts
|
|
755
|
+
function estimateTokens(message, options) {
|
|
756
|
+
const ratio = options?.charsPerToken ?? 4;
|
|
757
|
+
let charCount = 0;
|
|
758
|
+
charCount += message.role.length + 4;
|
|
759
|
+
for (const part of message.parts) {
|
|
760
|
+
charCount += estimatePartChars(part);
|
|
761
|
+
}
|
|
762
|
+
return Math.ceil(charCount / ratio);
|
|
763
|
+
}
|
|
764
|
+
function estimatePartChars(part) {
|
|
765
|
+
switch (part.type) {
|
|
766
|
+
case "text":
|
|
767
|
+
return part.text.length;
|
|
768
|
+
case "reasoning":
|
|
769
|
+
return part.text.length;
|
|
770
|
+
case "tool_call":
|
|
771
|
+
return JSON.stringify(part.args).length + part.name.length + 20 + (part.result !== void 0 ? JSON.stringify(part.result).length : 0);
|
|
772
|
+
case "source":
|
|
773
|
+
return (part.title?.length ?? 0) + part.url.length + 10;
|
|
774
|
+
case "file":
|
|
775
|
+
return part.name.length + part.data.length + 20;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
var ContextWindowManager = class {
|
|
779
|
+
config;
|
|
780
|
+
constructor(config) {
|
|
781
|
+
this.config = {
|
|
782
|
+
maxTokens: config.maxTokens,
|
|
783
|
+
reservedTokens: config.reservedTokens ?? 0,
|
|
784
|
+
strategy: config.strategy ?? "truncate-oldest",
|
|
785
|
+
estimation: config.estimation,
|
|
786
|
+
summarizer: config.summarizer
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/** Available token budget after reserving tokens */
|
|
790
|
+
get availableBudget() {
|
|
791
|
+
return Math.max(0, this.config.maxTokens - this.config.reservedTokens);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Estimate tokens for a single message.
|
|
795
|
+
* @param message - Message to estimate
|
|
796
|
+
* @returns Estimated token count
|
|
797
|
+
*/
|
|
798
|
+
estimateMessageTokens(message) {
|
|
799
|
+
return estimateTokens(message, this.config.estimation);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Fit messages within the token budget using the configured strategy.
|
|
803
|
+
* @param messages - All messages to consider
|
|
804
|
+
* @returns Result with fitted messages and metadata
|
|
805
|
+
*/
|
|
806
|
+
fitMessages(messages) {
|
|
807
|
+
if (messages.length === 0) {
|
|
808
|
+
return { messages: [], totalTokens: 0, removedCount: 0, wasTruncated: false };
|
|
809
|
+
}
|
|
810
|
+
const budget = this.availableBudget;
|
|
811
|
+
const tokenCounts = messages.map((m) => this.estimateMessageTokens(m));
|
|
812
|
+
const totalTokens = tokenCounts.reduce((a, b) => a + b, 0);
|
|
813
|
+
if (totalTokens <= budget) {
|
|
814
|
+
return {
|
|
815
|
+
messages: [...messages],
|
|
816
|
+
totalTokens,
|
|
817
|
+
removedCount: 0,
|
|
818
|
+
wasTruncated: false
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
switch (this.config.strategy) {
|
|
822
|
+
case "truncate-oldest":
|
|
823
|
+
return this.truncateOldest(messages, tokenCounts, budget);
|
|
824
|
+
case "sliding-window":
|
|
825
|
+
return this.slidingWindow(messages, tokenCounts, budget);
|
|
826
|
+
case "summarize-placeholder":
|
|
827
|
+
return this.summarizePlaceholder(messages, tokenCounts, budget);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Async variant of fitMessages that supports async summarization.
|
|
832
|
+
* When strategy is "summarize-placeholder" and a summarizer is configured,
|
|
833
|
+
* calls the summarizer with removed messages and replaces the placeholder text.
|
|
834
|
+
* Falls back to static placeholder if summarizer throws.
|
|
835
|
+
* For other strategies, behaves identically to fitMessages().
|
|
836
|
+
*/
|
|
837
|
+
async fitMessagesAsync(messages) {
|
|
838
|
+
const result = this.fitMessages(messages);
|
|
839
|
+
if (this.config.strategy !== "summarize-placeholder" || !result.wasTruncated || !this.config.summarizer) {
|
|
840
|
+
return result;
|
|
841
|
+
}
|
|
842
|
+
const keptIds = new Set(result.messages.map((m) => m.id));
|
|
843
|
+
const removed = messages.filter((m) => !keptIds.has(m.id));
|
|
844
|
+
if (removed.length === 0) return result;
|
|
845
|
+
let summaryText;
|
|
846
|
+
try {
|
|
847
|
+
summaryText = await this.config.summarizer(removed);
|
|
848
|
+
} catch {
|
|
849
|
+
return result;
|
|
850
|
+
}
|
|
851
|
+
const updatedMessages = result.messages.map((m) => {
|
|
852
|
+
if (m.metadata?.isSummary === true) {
|
|
853
|
+
return {
|
|
854
|
+
...m,
|
|
855
|
+
parts: [{ type: "text", text: summaryText, status: "complete" }]
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
return m;
|
|
859
|
+
});
|
|
860
|
+
return { ...result, messages: updatedMessages };
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Trim messages using real token usage data from the previous API call.
|
|
864
|
+
* Uses average-based algorithm: `avgTokensPerMessage = lastPromptTokens / messageCount`.
|
|
865
|
+
* Removes oldest non-system messages until freed budget brings usage under modelContextWindow.
|
|
866
|
+
*
|
|
867
|
+
* @param messages - All messages in the session
|
|
868
|
+
* @param lastPromptTokens - Real prompt tokens from the last API response
|
|
869
|
+
* @param modelContextWindow - Model's total context window size in tokens
|
|
870
|
+
* @returns Result with fitted messages and metadata
|
|
871
|
+
*/
|
|
872
|
+
fitMessagesWithUsage(messages, lastPromptTokens, modelContextWindow) {
|
|
873
|
+
if (messages.length === 0) {
|
|
874
|
+
return { messages: [], totalTokens: 0, removedCount: 0, wasTruncated: false };
|
|
875
|
+
}
|
|
876
|
+
const budget = modelContextWindow - this.config.reservedTokens;
|
|
877
|
+
if (budget <= 0 || lastPromptTokens <= budget) {
|
|
878
|
+
return {
|
|
879
|
+
messages: [...messages],
|
|
880
|
+
totalTokens: lastPromptTokens,
|
|
881
|
+
removedCount: 0,
|
|
882
|
+
wasTruncated: false
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
const avgTokensPerMessage = lastPromptTokens / messages.length;
|
|
886
|
+
const tokensToFree = lastPromptTokens - budget;
|
|
887
|
+
const messagesToRemove = Math.ceil(tokensToFree / avgTokensPerMessage);
|
|
888
|
+
const nonSystemIndices = [];
|
|
889
|
+
for (let i = 0; i < messages.length; i++) {
|
|
890
|
+
if (messages[i].role === "system") ; else {
|
|
891
|
+
nonSystemIndices.push(i);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const removableCount = Math.min(messagesToRemove, nonSystemIndices.length);
|
|
895
|
+
const removedIndices = new Set(nonSystemIndices.slice(0, removableCount));
|
|
896
|
+
const result = [];
|
|
897
|
+
for (let i = 0; i < messages.length; i++) {
|
|
898
|
+
if (!removedIndices.has(i)) {
|
|
899
|
+
result.push(messages[i]);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const estimatedTokens = Math.round(
|
|
903
|
+
lastPromptTokens * (result.length / messages.length)
|
|
904
|
+
);
|
|
905
|
+
return {
|
|
906
|
+
messages: result,
|
|
907
|
+
totalTokens: estimatedTokens,
|
|
908
|
+
removedCount: removableCount,
|
|
909
|
+
wasTruncated: removableCount > 0
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Truncate oldest: keeps system messages, removes oldest non-system messages first.
|
|
914
|
+
* Always keeps the most recent user message.
|
|
915
|
+
*/
|
|
916
|
+
truncateOldest(messages, tokenCounts, budget) {
|
|
917
|
+
const systemIndices = [];
|
|
918
|
+
const nonSystemIndices = [];
|
|
919
|
+
for (let i = 0; i < messages.length; i++) {
|
|
920
|
+
if (messages[i].role === "system") {
|
|
921
|
+
systemIndices.push(i);
|
|
922
|
+
} else {
|
|
923
|
+
nonSystemIndices.push(i);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
let usedTokens = systemIndices.reduce(
|
|
927
|
+
(sum, i) => sum + tokenCounts[i],
|
|
928
|
+
0
|
|
929
|
+
);
|
|
930
|
+
const includedNonSystem = [];
|
|
931
|
+
for (let i = nonSystemIndices.length - 1; i >= 0; i--) {
|
|
932
|
+
const idx = nonSystemIndices[i];
|
|
933
|
+
if (usedTokens + tokenCounts[idx] <= budget) {
|
|
934
|
+
includedNonSystem.unshift(idx);
|
|
935
|
+
usedTokens += tokenCounts[idx];
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const includedSet = /* @__PURE__ */ new Set([...systemIndices, ...includedNonSystem]);
|
|
939
|
+
const result = [];
|
|
940
|
+
let resultTokens = 0;
|
|
941
|
+
for (let i = 0; i < messages.length; i++) {
|
|
942
|
+
if (includedSet.has(i)) {
|
|
943
|
+
result.push(messages[i]);
|
|
944
|
+
resultTokens += tokenCounts[i];
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return {
|
|
948
|
+
messages: result,
|
|
949
|
+
totalTokens: resultTokens,
|
|
950
|
+
removedCount: messages.length - result.length,
|
|
951
|
+
wasTruncated: true
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Sliding window: keeps the most recent messages that fit within budget.
|
|
956
|
+
*/
|
|
957
|
+
slidingWindow(messages, tokenCounts, budget) {
|
|
958
|
+
const result = [];
|
|
959
|
+
let usedTokens = 0;
|
|
960
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
961
|
+
if (usedTokens + tokenCounts[i] <= budget) {
|
|
962
|
+
result.unshift(messages[i]);
|
|
963
|
+
usedTokens += tokenCounts[i];
|
|
964
|
+
} else {
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
messages: result,
|
|
970
|
+
totalTokens: usedTokens,
|
|
971
|
+
removedCount: messages.length - result.length,
|
|
972
|
+
wasTruncated: true
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Summarize placeholder: replaces truncated messages with a placeholder,
|
|
977
|
+
* preserving system messages and recent context.
|
|
978
|
+
*/
|
|
979
|
+
summarizePlaceholder(messages, tokenCounts, budget) {
|
|
980
|
+
const systemMessages = [];
|
|
981
|
+
const nonSystem = [];
|
|
982
|
+
for (let i = 0; i < messages.length; i++) {
|
|
983
|
+
if (messages[i].role === "system") {
|
|
984
|
+
systemMessages.push({ msg: messages[i], tokens: tokenCounts[i] });
|
|
985
|
+
} else {
|
|
986
|
+
nonSystem.push({ msg: messages[i], tokens: tokenCounts[i], idx: i });
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
let usedTokens = systemMessages.reduce((s, m) => s + m.tokens, 0);
|
|
990
|
+
const placeholderTokens = 20;
|
|
991
|
+
usedTokens += placeholderTokens;
|
|
992
|
+
const recentKept = [];
|
|
993
|
+
for (let i = nonSystem.length - 1; i >= 0; i--) {
|
|
994
|
+
if (usedTokens + nonSystem[i].tokens <= budget) {
|
|
995
|
+
recentKept.unshift(nonSystem[i]);
|
|
996
|
+
usedTokens += nonSystem[i].tokens;
|
|
997
|
+
} else {
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const removedCount = messages.length - systemMessages.length - recentKept.length;
|
|
1002
|
+
const result = [];
|
|
1003
|
+
for (const sm of systemMessages) {
|
|
1004
|
+
result.push(sm.msg);
|
|
1005
|
+
}
|
|
1006
|
+
if (removedCount > 0) {
|
|
1007
|
+
result.push({
|
|
1008
|
+
id: "context-placeholder",
|
|
1009
|
+
role: "system",
|
|
1010
|
+
parts: [{ type: "text", text: `[${removedCount} earlier message${removedCount === 1 ? "" : "s"} omitted for context window]`, status: "complete" }],
|
|
1011
|
+
metadata: { isSummary: true },
|
|
1012
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1013
|
+
status: "complete"
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
for (const m of recentKept) {
|
|
1017
|
+
result.push(m.msg);
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
messages: result,
|
|
1021
|
+
totalTokens: usedTokens,
|
|
1022
|
+
removedCount,
|
|
1023
|
+
wasTruncated: true
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
// src/chat/state.ts
|
|
1029
|
+
var StateMachine = class {
|
|
1030
|
+
constructor(initial, transitions) {
|
|
1031
|
+
this.initial = initial;
|
|
1032
|
+
this.transitions = transitions;
|
|
1033
|
+
this._current = initial;
|
|
1034
|
+
}
|
|
1035
|
+
_current;
|
|
1036
|
+
/** Current state */
|
|
1037
|
+
get current() {
|
|
1038
|
+
return this._current;
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Check whether transitioning to `next` is allowed from current state
|
|
1042
|
+
* @param next - Target state to check
|
|
1043
|
+
* @returns True if transition is allowed
|
|
1044
|
+
*/
|
|
1045
|
+
canTransition(next) {
|
|
1046
|
+
const allowed = this.transitions[this._current];
|
|
1047
|
+
return allowed !== void 0 && allowed.includes(next);
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Transition to `next` state.
|
|
1051
|
+
* @throws ChatError(INVALID_TRANSITION) if the transition is not allowed
|
|
1052
|
+
*/
|
|
1053
|
+
transition(next) {
|
|
1054
|
+
if (!this.canTransition(next)) {
|
|
1055
|
+
throw new ChatError(
|
|
1056
|
+
`Invalid transition: ${this._current} \u2192 ${next}`,
|
|
1057
|
+
{ code: "INVALID_TRANSITION" /* INVALID_TRANSITION */ }
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
this._current = next;
|
|
1061
|
+
}
|
|
1062
|
+
/** Reset to initial state */
|
|
1063
|
+
reset() {
|
|
1064
|
+
this._current = this.initial;
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
var RUNTIME_TRANSITIONS = {
|
|
1068
|
+
idle: ["streaming", "disposed"],
|
|
1069
|
+
streaming: ["idle", "error", "disposed"],
|
|
1070
|
+
error: ["idle", "disposed"],
|
|
1071
|
+
disposed: []
|
|
1072
|
+
};
|
|
1073
|
+
var ChatReentrancyGuard = class {
|
|
1074
|
+
_acquired = false;
|
|
1075
|
+
/** Whether the guard is currently held */
|
|
1076
|
+
get isAcquired() {
|
|
1077
|
+
return this._acquired;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Acquire the guard. Throws if already acquired.
|
|
1081
|
+
* @throws ChatError with code REENTRANCY
|
|
1082
|
+
*/
|
|
1083
|
+
acquire() {
|
|
1084
|
+
if (this._acquired) {
|
|
1085
|
+
throw new ChatError(
|
|
1086
|
+
"Concurrent operation detected: a send is already in progress",
|
|
1087
|
+
{ code: "REENTRANCY" /* REENTRANCY */ }
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
this._acquired = true;
|
|
1091
|
+
}
|
|
1092
|
+
/** Release the guard. Safe to call even if not acquired. */
|
|
1093
|
+
release() {
|
|
1094
|
+
this._acquired = false;
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
var ChatAbortController = class {
|
|
1098
|
+
_controller;
|
|
1099
|
+
_onExternalAbort;
|
|
1100
|
+
_externalSignal;
|
|
1101
|
+
constructor(externalSignal) {
|
|
1102
|
+
this._controller = new AbortController();
|
|
1103
|
+
this._externalSignal = externalSignal;
|
|
1104
|
+
if (externalSignal) {
|
|
1105
|
+
if (externalSignal.aborted) {
|
|
1106
|
+
this._controller.abort(externalSignal.reason);
|
|
1107
|
+
} else {
|
|
1108
|
+
this._onExternalAbort = () => {
|
|
1109
|
+
this._controller.abort(externalSignal.reason);
|
|
1110
|
+
};
|
|
1111
|
+
externalSignal.addEventListener("abort", this._onExternalAbort, { once: true });
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
/** The AbortSignal for this controller */
|
|
1116
|
+
get signal() {
|
|
1117
|
+
return this._controller.signal;
|
|
1118
|
+
}
|
|
1119
|
+
/** Whether the operation has been aborted */
|
|
1120
|
+
get isAborted() {
|
|
1121
|
+
return this._controller.signal.aborted;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Abort the operation.
|
|
1125
|
+
* @param reason - Optional abort reason
|
|
1126
|
+
*/
|
|
1127
|
+
abort(reason) {
|
|
1128
|
+
this._controller.abort(reason);
|
|
1129
|
+
}
|
|
1130
|
+
/** Clean up external signal listener to prevent memory leaks */
|
|
1131
|
+
dispose() {
|
|
1132
|
+
if (this._onExternalAbort && this._externalSignal) {
|
|
1133
|
+
this._externalSignal.removeEventListener("abort", this._onExternalAbort);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
// src/chat/accumulator.ts
|
|
1139
|
+
var MessageAccumulator = class {
|
|
1140
|
+
messageId;
|
|
1141
|
+
parts = [];
|
|
1142
|
+
status = "pending";
|
|
1143
|
+
currentTextPart = null;
|
|
1144
|
+
currentReasoningPart = null;
|
|
1145
|
+
toolCallParts = /* @__PURE__ */ new Map();
|
|
1146
|
+
_finalized = false;
|
|
1147
|
+
constructor(messageId) {
|
|
1148
|
+
this.messageId = messageId ?? createChatId();
|
|
1149
|
+
}
|
|
1150
|
+
/** Get current message ID */
|
|
1151
|
+
get id() {
|
|
1152
|
+
return this.messageId;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Apply an AgentEvent to accumulate into the message
|
|
1156
|
+
* @param event - AgentEvent to process
|
|
1157
|
+
* @throws Error if accumulator is already finalized
|
|
1158
|
+
*/
|
|
1159
|
+
apply(event) {
|
|
1160
|
+
if (this._finalized) throw new Error("Cannot apply events to finalized accumulator");
|
|
1161
|
+
if (this.status === "pending") {
|
|
1162
|
+
this.status = "streaming";
|
|
1163
|
+
}
|
|
1164
|
+
switch (event.type) {
|
|
1165
|
+
case "text_delta":
|
|
1166
|
+
this.handleTextDelta(event.text);
|
|
1167
|
+
break;
|
|
1168
|
+
case "thinking_start":
|
|
1169
|
+
this.finalizeCurrentText();
|
|
1170
|
+
this.currentReasoningPart = { type: "reasoning", text: "", status: "streaming" };
|
|
1171
|
+
this.parts.push(this.currentReasoningPart);
|
|
1172
|
+
break;
|
|
1173
|
+
case "thinking_delta":
|
|
1174
|
+
if (this.currentReasoningPart) {
|
|
1175
|
+
this.currentReasoningPart.text += event.text;
|
|
1176
|
+
}
|
|
1177
|
+
break;
|
|
1178
|
+
case "thinking_end":
|
|
1179
|
+
if (this.currentReasoningPart) {
|
|
1180
|
+
this.currentReasoningPart.status = "complete";
|
|
1181
|
+
this.currentReasoningPart = null;
|
|
1182
|
+
}
|
|
1183
|
+
break;
|
|
1184
|
+
case "tool_call_start": {
|
|
1185
|
+
this.finalizeCurrentText();
|
|
1186
|
+
const toolPart = {
|
|
1187
|
+
type: "tool_call",
|
|
1188
|
+
toolCallId: event.toolCallId,
|
|
1189
|
+
name: event.toolName,
|
|
1190
|
+
args: event.args,
|
|
1191
|
+
status: "running"
|
|
1192
|
+
};
|
|
1193
|
+
this.toolCallParts.set(event.toolCallId, toolPart);
|
|
1194
|
+
this.parts.push(toolPart);
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
case "tool_call_end": {
|
|
1198
|
+
const existing = this.toolCallParts.get(event.toolCallId);
|
|
1199
|
+
if (existing) {
|
|
1200
|
+
existing.result = event.result;
|
|
1201
|
+
existing.status = "complete";
|
|
1202
|
+
}
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
case "error":
|
|
1206
|
+
this.status = "error";
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
handleTextDelta(text) {
|
|
1211
|
+
if (!this.currentTextPart) {
|
|
1212
|
+
this.currentTextPart = { type: "text", text: "", status: "streaming" };
|
|
1213
|
+
this.parts.push(this.currentTextPart);
|
|
1214
|
+
}
|
|
1215
|
+
this.currentTextPart.text += text;
|
|
1216
|
+
}
|
|
1217
|
+
finalizeCurrentText() {
|
|
1218
|
+
if (this.currentTextPart) {
|
|
1219
|
+
this.currentTextPart.status = "complete";
|
|
1220
|
+
this.currentTextPart = null;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Get a snapshot of the current accumulated message (for streaming UI)
|
|
1225
|
+
* @returns ChatMessage with current parts and "streaming" status
|
|
1226
|
+
*/
|
|
1227
|
+
snapshot() {
|
|
1228
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1229
|
+
return {
|
|
1230
|
+
id: this.messageId,
|
|
1231
|
+
role: "assistant",
|
|
1232
|
+
parts: this.parts.map((p) => ({ ...p })),
|
|
1233
|
+
status: this.status === "pending" ? "pending" : "streaming",
|
|
1234
|
+
createdAt: now,
|
|
1235
|
+
updatedAt: now
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Finalize the accumulator and return the complete ChatMessage
|
|
1240
|
+
* @returns Completed ChatMessage with all parts finalized
|
|
1241
|
+
* @throws Error if accumulator is already finalized
|
|
1242
|
+
*/
|
|
1243
|
+
finalize() {
|
|
1244
|
+
if (this._finalized) throw new Error("Accumulator already finalized");
|
|
1245
|
+
this._finalized = true;
|
|
1246
|
+
this.finalizeCurrentText();
|
|
1247
|
+
if (this.currentReasoningPart) {
|
|
1248
|
+
this.currentReasoningPart.status = "complete";
|
|
1249
|
+
this.currentReasoningPart = null;
|
|
1250
|
+
}
|
|
1251
|
+
for (const [, toolPart] of this.toolCallParts) {
|
|
1252
|
+
if (toolPart.status === "running" || toolPart.status === "pending") {
|
|
1253
|
+
toolPart.status = "error";
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
if (this.status !== "error" && this.status !== "cancelled") {
|
|
1257
|
+
this.status = "complete";
|
|
1258
|
+
}
|
|
1259
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1260
|
+
return {
|
|
1261
|
+
id: this.messageId,
|
|
1262
|
+
role: "assistant",
|
|
1263
|
+
parts: this.parts,
|
|
1264
|
+
status: this.status,
|
|
1265
|
+
createdAt: now,
|
|
1266
|
+
updatedAt: now
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
/** Check if the accumulator has been finalized */
|
|
1270
|
+
get finalized() {
|
|
1271
|
+
return this._finalized;
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
// src/chat/watchdog.ts
|
|
1276
|
+
async function* withStreamWatchdog(source, config) {
|
|
1277
|
+
const { timeoutMs, signal } = config;
|
|
1278
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
1279
|
+
let aborted = false;
|
|
1280
|
+
if (signal?.aborted) {
|
|
1281
|
+
iterator.return?.();
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
const onAbort = () => {
|
|
1285
|
+
aborted = true;
|
|
1286
|
+
iterator.return?.();
|
|
1287
|
+
};
|
|
1288
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1289
|
+
try {
|
|
1290
|
+
while (true) {
|
|
1291
|
+
if (aborted) break;
|
|
1292
|
+
const timeout = new CancellableTimeout(timeoutMs);
|
|
1293
|
+
try {
|
|
1294
|
+
const result = await Promise.race([
|
|
1295
|
+
iterator.next(),
|
|
1296
|
+
timeout.promise
|
|
1297
|
+
]);
|
|
1298
|
+
timeout.cancel();
|
|
1299
|
+
if (result.done) break;
|
|
1300
|
+
yield result.value;
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
timeout.cancel();
|
|
1303
|
+
throw err;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
} finally {
|
|
1307
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1308
|
+
iterator.return?.();
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
var CancellableTimeout = class {
|
|
1312
|
+
promise;
|
|
1313
|
+
_timer;
|
|
1314
|
+
_cancelled = false;
|
|
1315
|
+
constructor(ms) {
|
|
1316
|
+
this.promise = new Promise((_, reject) => {
|
|
1317
|
+
this._timer = setTimeout(() => {
|
|
1318
|
+
if (!this._cancelled) {
|
|
1319
|
+
reject(
|
|
1320
|
+
new ChatError(
|
|
1321
|
+
`Stream timed out after ${ms}ms of inactivity`,
|
|
1322
|
+
{ code: "TIMEOUT" /* TIMEOUT */ }
|
|
1323
|
+
)
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
}, ms);
|
|
1327
|
+
});
|
|
1328
|
+
this.promise.catch(() => {
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
cancel() {
|
|
1332
|
+
this._cancelled = true;
|
|
1333
|
+
if (this._timer !== void 0) {
|
|
1334
|
+
clearTimeout(this._timer);
|
|
1335
|
+
this._timer = void 0;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
// src/chat/listener-set.ts
|
|
1341
|
+
var ListenerSet = class {
|
|
1342
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
1343
|
+
/** Add a listener. Returns an unsubscribe function. */
|
|
1344
|
+
add(callback) {
|
|
1345
|
+
this._listeners.add(callback);
|
|
1346
|
+
return () => {
|
|
1347
|
+
this._listeners.delete(callback);
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
/** Notify all listeners with the given arguments. Errors are isolated per listener. */
|
|
1351
|
+
notify(...args) {
|
|
1352
|
+
for (const cb of this._listeners) {
|
|
1353
|
+
try {
|
|
1354
|
+
cb(...args);
|
|
1355
|
+
} catch {
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
/** Remove all listeners. */
|
|
1360
|
+
clear() {
|
|
1361
|
+
this._listeners.clear();
|
|
1362
|
+
}
|
|
1363
|
+
/** Current number of listeners. */
|
|
1364
|
+
get size() {
|
|
1365
|
+
return this._listeners.size;
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
// src/chat/runtime.ts
|
|
1370
|
+
var ChatRuntime = class {
|
|
1371
|
+
_state;
|
|
1372
|
+
_guard;
|
|
1373
|
+
_backends;
|
|
1374
|
+
_sessionStore;
|
|
1375
|
+
_contextConfig;
|
|
1376
|
+
_middleware;
|
|
1377
|
+
_tools = /* @__PURE__ */ new Map();
|
|
1378
|
+
_retryConfig;
|
|
1379
|
+
_contextStats = /* @__PURE__ */ new Map();
|
|
1380
|
+
_sessionUsage = /* @__PURE__ */ new Map();
|
|
1381
|
+
_modelContextWindows = /* @__PURE__ */ new Map();
|
|
1382
|
+
_onContextTrimmed;
|
|
1383
|
+
_streamTimeoutMs;
|
|
1384
|
+
_sessionListeners = new ListenerSet();
|
|
1385
|
+
_adapterPool = /* @__PURE__ */ new Map();
|
|
1386
|
+
_defaultBackend;
|
|
1387
|
+
_abortController = null;
|
|
1388
|
+
constructor(options) {
|
|
1389
|
+
this._state = new StateMachine("idle", RUNTIME_TRANSITIONS);
|
|
1390
|
+
this._guard = new ChatReentrancyGuard();
|
|
1391
|
+
this._backends = options.backends;
|
|
1392
|
+
this._defaultBackend = options.defaultBackend;
|
|
1393
|
+
this._sessionStore = options.sessionStore;
|
|
1394
|
+
this._contextConfig = options.context;
|
|
1395
|
+
this._middleware = [...options.middleware ?? []];
|
|
1396
|
+
this._retryConfig = options.retryConfig;
|
|
1397
|
+
this._onContextTrimmed = options.onContextTrimmed;
|
|
1398
|
+
this._streamTimeoutMs = options.streamTimeoutMs;
|
|
1399
|
+
if (!options.backends[options.defaultBackend]) {
|
|
1400
|
+
throw new ChatError(
|
|
1401
|
+
`Default backend "${options.defaultBackend}" not found in backends map`,
|
|
1402
|
+
{ code: "INVALID_INPUT" /* INVALID_INPUT */ }
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
if (options.tools) {
|
|
1406
|
+
for (const tool of options.tools) {
|
|
1407
|
+
this._tools.set(tool.name, tool);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
// ── Lifecycle ──────────────────────────────────────────────
|
|
1412
|
+
get status() {
|
|
1413
|
+
return this._state.current;
|
|
1414
|
+
}
|
|
1415
|
+
async dispose() {
|
|
1416
|
+
if (this._state.current === "disposed") return;
|
|
1417
|
+
this._abortController?.abort("Runtime disposed");
|
|
1418
|
+
this._abortController?.dispose();
|
|
1419
|
+
this._abortController = null;
|
|
1420
|
+
this._state.transition("disposed");
|
|
1421
|
+
for (const adapter of this._adapterPool.values()) {
|
|
1422
|
+
try {
|
|
1423
|
+
await adapter.dispose();
|
|
1424
|
+
} catch {
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
this._adapterPool.clear();
|
|
1428
|
+
}
|
|
1429
|
+
// ── Sessions ───────────────────────────────────────────────
|
|
1430
|
+
async createSession(options) {
|
|
1431
|
+
this.assertNotDisposed();
|
|
1432
|
+
const config = {
|
|
1433
|
+
model: options.config?.model ?? "",
|
|
1434
|
+
backend: options.config?.backend ?? this._defaultBackend,
|
|
1435
|
+
...options.config
|
|
1436
|
+
};
|
|
1437
|
+
const session = await this._sessionStore.createSession({ ...options, config });
|
|
1438
|
+
this._notifySessionChange();
|
|
1439
|
+
return session;
|
|
1440
|
+
}
|
|
1441
|
+
async getSession(id) {
|
|
1442
|
+
this.assertNotDisposed();
|
|
1443
|
+
const cid = toChatId(id);
|
|
1444
|
+
return this._sessionStore.getSession(cid);
|
|
1445
|
+
}
|
|
1446
|
+
async listSessions(options) {
|
|
1447
|
+
this.assertNotDisposed();
|
|
1448
|
+
return this._sessionStore.listSessions(options);
|
|
1449
|
+
}
|
|
1450
|
+
async deleteSession(id) {
|
|
1451
|
+
this.assertNotDisposed();
|
|
1452
|
+
const cid = toChatId(id);
|
|
1453
|
+
const session = await this._sessionStore.getSession(cid);
|
|
1454
|
+
if (!session) return;
|
|
1455
|
+
await this._sessionStore.deleteSession(cid);
|
|
1456
|
+
this._contextStats.delete(cid);
|
|
1457
|
+
this._sessionUsage.delete(cid);
|
|
1458
|
+
this._notifySessionChange();
|
|
1459
|
+
}
|
|
1460
|
+
// ── Messaging ──────────────────────────────────────────────
|
|
1461
|
+
async *send(sessionId, message, options) {
|
|
1462
|
+
this.validateSendInput(message, options);
|
|
1463
|
+
this._guard.acquire();
|
|
1464
|
+
const cid = toChatId(sessionId);
|
|
1465
|
+
this._abortController = new ChatAbortController(options?.signal);
|
|
1466
|
+
try {
|
|
1467
|
+
if (this._state.current === "error") {
|
|
1468
|
+
this._state.transition("idle");
|
|
1469
|
+
}
|
|
1470
|
+
this._state.transition("streaming");
|
|
1471
|
+
await this.loadSession(cid);
|
|
1472
|
+
const mwCtx = {
|
|
1473
|
+
sessionId: cid,
|
|
1474
|
+
signal: this._abortController.signal
|
|
1475
|
+
};
|
|
1476
|
+
const userMessage = await this.applyBeforeSendMiddleware(
|
|
1477
|
+
this.createUserMessage(message),
|
|
1478
|
+
mwCtx
|
|
1479
|
+
);
|
|
1480
|
+
if (userMessage === null) {
|
|
1481
|
+
this._state.transition("idle");
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const updatedSession = await this.persistAndReload(cid, userMessage);
|
|
1485
|
+
const sessionForAdapter = await this.trimSessionContext(cid, updatedSession, options.model);
|
|
1486
|
+
const stream = await this.prepareEventStream(
|
|
1487
|
+
cid,
|
|
1488
|
+
sessionForAdapter,
|
|
1489
|
+
updatedSession,
|
|
1490
|
+
message,
|
|
1491
|
+
options
|
|
1492
|
+
);
|
|
1493
|
+
const accumulator = new MessageAccumulator();
|
|
1494
|
+
const eventSource = this._streamTimeoutMs ? withStreamWatchdog(stream, { timeoutMs: this._streamTimeoutMs, signal: this._abortController.signal }) : stream;
|
|
1495
|
+
for await (const event of eventSource) {
|
|
1496
|
+
if (this._abortController.isAborted) break;
|
|
1497
|
+
this.feedAccumulator(accumulator, event);
|
|
1498
|
+
if (event.type === "usage") {
|
|
1499
|
+
this._sessionUsage.set(cid, {
|
|
1500
|
+
promptTokens: event.promptTokens,
|
|
1501
|
+
completionTokens: event.completionTokens
|
|
1502
|
+
});
|
|
1503
|
+
this.updateContextStatsWithUsage(cid, event.promptTokens, event.completionTokens, options);
|
|
1504
|
+
}
|
|
1505
|
+
const processed = await this.applyOnEventMiddleware(event, mwCtx);
|
|
1506
|
+
if (processed) yield processed;
|
|
1507
|
+
}
|
|
1508
|
+
if (this._state.current === "disposed") return;
|
|
1509
|
+
await this.finalizeAssistantMessage(cid, accumulator, mwCtx);
|
|
1510
|
+
this._state.transition("idle");
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
const result = await this.handleSendError(error, cid);
|
|
1513
|
+
if (result !== null) throw result;
|
|
1514
|
+
} finally {
|
|
1515
|
+
this._guard.release();
|
|
1516
|
+
this._abortController?.dispose();
|
|
1517
|
+
this._abortController = null;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
// ── Send Pipeline Stages ──────────────────────────────────────
|
|
1521
|
+
/** Stage 1: Validate send inputs (message content + required fields). */
|
|
1522
|
+
validateSendInput(message, options) {
|
|
1523
|
+
this.assertNotDisposed();
|
|
1524
|
+
if (!message || message.trim().length === 0) {
|
|
1525
|
+
throw new ChatError("Message cannot be empty", { code: "INVALID_INPUT" /* INVALID_INPUT */ });
|
|
1526
|
+
}
|
|
1527
|
+
if (!options.model) {
|
|
1528
|
+
throw new ChatError(
|
|
1529
|
+
"options.model is required \u2014 caller must specify which model to use",
|
|
1530
|
+
{ code: "INVALID_INPUT" /* INVALID_INPUT */ }
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
if (!options.backend) {
|
|
1534
|
+
throw new ChatError(
|
|
1535
|
+
"options.backend is required \u2014 caller must specify which backend to use",
|
|
1536
|
+
{ code: "INVALID_INPUT" /* INVALID_INPUT */ }
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
if (!options.credentials) {
|
|
1540
|
+
throw new ChatError(
|
|
1541
|
+
"options.credentials is required \u2014 caller must provide authentication credentials",
|
|
1542
|
+
{ code: "INVALID_INPUT" /* INVALID_INPUT */ }
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
/** Stage 2: Load session from store. */
|
|
1547
|
+
async loadSession(cid) {
|
|
1548
|
+
const session = await this._sessionStore.getSession(cid);
|
|
1549
|
+
if (!session) {
|
|
1550
|
+
throw new ChatError(
|
|
1551
|
+
`Session "${cid}" not found`,
|
|
1552
|
+
{ code: "SESSION_NOT_FOUND" /* SESSION_NOT_FOUND */ }
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
return session;
|
|
1556
|
+
}
|
|
1557
|
+
/** Stage 3: Apply onBeforeSend middleware pipeline. Returns null if middleware rejected the send. */
|
|
1558
|
+
async applyBeforeSendMiddleware(userMessage, ctx) {
|
|
1559
|
+
let msg = userMessage;
|
|
1560
|
+
for (const mw of this._middleware) {
|
|
1561
|
+
if (mw.onBeforeSend && msg) {
|
|
1562
|
+
msg = await mw.onBeforeSend(msg, ctx);
|
|
1563
|
+
if (msg === null) return null;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
return msg;
|
|
1567
|
+
}
|
|
1568
|
+
/** Stage 4: Persist user message and reload session with full history. */
|
|
1569
|
+
async persistAndReload(cid, userMessage) {
|
|
1570
|
+
await this._sessionStore.appendMessage(cid, userMessage);
|
|
1571
|
+
return await this._sessionStore.getSession(cid);
|
|
1572
|
+
}
|
|
1573
|
+
/** Stage 5: Auto-trim context window if configured. Returns session snapshot for adapter. */
|
|
1574
|
+
async trimSessionContext(cid, session, model) {
|
|
1575
|
+
if (!this._contextConfig) return session;
|
|
1576
|
+
const ctxManager = new ContextWindowManager(this._contextConfig);
|
|
1577
|
+
const lastUsage = this._sessionUsage.get(cid);
|
|
1578
|
+
const modelContextWindow = model ? this._modelContextWindows.get(model) : void 0;
|
|
1579
|
+
if (lastUsage && modelContextWindow) {
|
|
1580
|
+
const result2 = ctxManager.fitMessagesWithUsage(
|
|
1581
|
+
session.messages,
|
|
1582
|
+
lastUsage.promptTokens,
|
|
1583
|
+
modelContextWindow
|
|
1584
|
+
);
|
|
1585
|
+
this._contextStats.set(cid, {
|
|
1586
|
+
totalTokens: result2.totalTokens,
|
|
1587
|
+
removedCount: result2.removedCount,
|
|
1588
|
+
wasTruncated: result2.wasTruncated,
|
|
1589
|
+
availableBudget: Math.max(0, modelContextWindow - result2.totalTokens),
|
|
1590
|
+
realPromptTokens: lastUsage.promptTokens,
|
|
1591
|
+
realCompletionTokens: lastUsage.completionTokens,
|
|
1592
|
+
modelContextWindow
|
|
1593
|
+
});
|
|
1594
|
+
if (result2.wasTruncated && this._onContextTrimmed) {
|
|
1595
|
+
const keptIds = new Set(result2.messages.map((m) => m.id));
|
|
1596
|
+
const removed = session.messages.filter((m) => !keptIds.has(m.id));
|
|
1597
|
+
if (removed.length > 0) {
|
|
1598
|
+
try {
|
|
1599
|
+
this._onContextTrimmed(cid, removed);
|
|
1600
|
+
} catch {
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return { ...session, messages: result2.messages };
|
|
1605
|
+
}
|
|
1606
|
+
const result = await ctxManager.fitMessagesAsync(session.messages);
|
|
1607
|
+
this._contextStats.set(cid, {
|
|
1608
|
+
totalTokens: result.totalTokens,
|
|
1609
|
+
removedCount: result.removedCount,
|
|
1610
|
+
wasTruncated: result.wasTruncated,
|
|
1611
|
+
availableBudget: ctxManager.availableBudget,
|
|
1612
|
+
modelContextWindow
|
|
1613
|
+
});
|
|
1614
|
+
if (result.wasTruncated && this._onContextTrimmed) {
|
|
1615
|
+
const keptIds = new Set(result.messages.map((m) => m.id));
|
|
1616
|
+
const removed = session.messages.filter((m) => !keptIds.has(m.id));
|
|
1617
|
+
if (removed.length > 0) {
|
|
1618
|
+
try {
|
|
1619
|
+
this._onContextTrimmed(cid, removed);
|
|
1620
|
+
} catch {
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
return { ...session, messages: result.messages };
|
|
1625
|
+
}
|
|
1626
|
+
/** Update context stats with real usage data from a usage event. */
|
|
1627
|
+
updateContextStatsWithUsage(cid, promptTokens, completionTokens, options) {
|
|
1628
|
+
const modelContextWindow = options.model ? this._modelContextWindows.get(options.model) : void 0;
|
|
1629
|
+
const existing = this._contextStats.get(cid);
|
|
1630
|
+
this._contextStats.set(cid, {
|
|
1631
|
+
totalTokens: promptTokens,
|
|
1632
|
+
removedCount: existing?.removedCount ?? 0,
|
|
1633
|
+
wasTruncated: existing?.wasTruncated ?? false,
|
|
1634
|
+
availableBudget: modelContextWindow ? Math.max(0, modelContextWindow - promptTokens) : existing?.availableBudget ?? 0,
|
|
1635
|
+
realPromptTokens: promptTokens,
|
|
1636
|
+
realCompletionTokens: completionTokens,
|
|
1637
|
+
modelContextWindow
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
/** Stage 6: Prepare event stream — adapter with retry, tool injection. */
|
|
1641
|
+
async prepareEventStream(cid, sessionForAdapter, fullSession, message, options) {
|
|
1642
|
+
const adapter = await this.getOrCreateAdapterWithRetry(options.backend, options.credentials);
|
|
1643
|
+
const runtimeTools = this._tools.size > 0 ? this.injectToolContext([...this._tools.values()], {
|
|
1644
|
+
sessionId: cid,
|
|
1645
|
+
custom: fullSession.metadata?.custom
|
|
1646
|
+
}) : void 0;
|
|
1647
|
+
const streamOptions = {
|
|
1648
|
+
signal: this._abortController.signal,
|
|
1649
|
+
model: options.model,
|
|
1650
|
+
systemPrompt: options.systemPrompt,
|
|
1651
|
+
tools: runtimeTools
|
|
1652
|
+
};
|
|
1653
|
+
return this.createStreamWithRetry(
|
|
1654
|
+
adapter,
|
|
1655
|
+
sessionForAdapter,
|
|
1656
|
+
message,
|
|
1657
|
+
streamOptions,
|
|
1658
|
+
options.backend,
|
|
1659
|
+
options.credentials
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
/** Stage 7: Apply onEvent middleware pipeline (sequential transform/suppress). */
|
|
1663
|
+
async applyOnEventMiddleware(event, ctx) {
|
|
1664
|
+
let processed = event;
|
|
1665
|
+
for (const mw of this._middleware) {
|
|
1666
|
+
if (mw.onEvent && processed) {
|
|
1667
|
+
processed = await mw.onEvent(processed, ctx);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
return processed;
|
|
1671
|
+
}
|
|
1672
|
+
/** Stage 8: Finalize accumulator, apply afterReceive middleware, persist assistant message. */
|
|
1673
|
+
async finalizeAssistantMessage(cid, accumulator, ctx) {
|
|
1674
|
+
let assistantMessage = accumulator.finalize();
|
|
1675
|
+
for (const mw of this._middleware) {
|
|
1676
|
+
if (mw.onAfterReceive) {
|
|
1677
|
+
assistantMessage = await mw.onAfterReceive(assistantMessage, ctx);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
await this._sessionStore.appendMessage(cid, assistantMessage);
|
|
1681
|
+
this._notifySessionChange();
|
|
1682
|
+
}
|
|
1683
|
+
/** Stage 9: Error handling — apply onError middleware, transition state. Returns null if suppressed. */
|
|
1684
|
+
async handleSendError(error, cid) {
|
|
1685
|
+
let processedError = error instanceof Error ? error : new Error(String(error));
|
|
1686
|
+
const ctx = {
|
|
1687
|
+
sessionId: cid,
|
|
1688
|
+
signal: this._abortController?.signal ?? new AbortController().signal
|
|
1689
|
+
};
|
|
1690
|
+
for (const mw of this._middleware) {
|
|
1691
|
+
if (mw.onError) {
|
|
1692
|
+
const result = await mw.onError(processedError, ctx);
|
|
1693
|
+
if (result === null) {
|
|
1694
|
+
if (this._state.canTransition("idle")) {
|
|
1695
|
+
this._state.transition("idle");
|
|
1696
|
+
}
|
|
1697
|
+
return null;
|
|
1698
|
+
}
|
|
1699
|
+
processedError = result;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
if (this._state.canTransition("error")) {
|
|
1703
|
+
this._state.transition("error");
|
|
1704
|
+
}
|
|
1705
|
+
return processedError;
|
|
1706
|
+
}
|
|
1707
|
+
abort() {
|
|
1708
|
+
this._abortController?.abort("User abort");
|
|
1709
|
+
}
|
|
1710
|
+
// ── Backend / Model ────────────────────────────────────────
|
|
1711
|
+
async listModels(options) {
|
|
1712
|
+
this.assertNotDisposed();
|
|
1713
|
+
let models = [];
|
|
1714
|
+
const firstAdapter = [...this._adapterPool.values()][0];
|
|
1715
|
+
if (firstAdapter) {
|
|
1716
|
+
try {
|
|
1717
|
+
models = await firstAdapter.listModels();
|
|
1718
|
+
} catch {
|
|
1719
|
+
return [];
|
|
1720
|
+
}
|
|
1721
|
+
} else if (options?.backend && options?.credentials) {
|
|
1722
|
+
try {
|
|
1723
|
+
const adapter = await this.getOrCreateAdapter(options.backend, options.credentials);
|
|
1724
|
+
models = await adapter.listModels();
|
|
1725
|
+
} catch {
|
|
1726
|
+
return [];
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
for (const model of models) {
|
|
1730
|
+
if (model.contextWindow != null) {
|
|
1731
|
+
this._modelContextWindows.set(model.id, model.contextWindow);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
return models;
|
|
1735
|
+
}
|
|
1736
|
+
async listBackends() {
|
|
1737
|
+
this.assertNotDisposed();
|
|
1738
|
+
return Object.keys(this._backends).map((name) => ({ name }));
|
|
1739
|
+
}
|
|
1740
|
+
// ── Tools ──────────────────────────────────────────────────
|
|
1741
|
+
get registeredTools() {
|
|
1742
|
+
return this._tools;
|
|
1743
|
+
}
|
|
1744
|
+
registerTool(tool) {
|
|
1745
|
+
this.assertNotDisposed();
|
|
1746
|
+
this._tools.set(tool.name, tool);
|
|
1747
|
+
}
|
|
1748
|
+
removeTool(name) {
|
|
1749
|
+
this.assertNotDisposed();
|
|
1750
|
+
this._tools.delete(name);
|
|
1751
|
+
}
|
|
1752
|
+
// ── Middleware ──────────────────────────────────────────────
|
|
1753
|
+
use(middleware) {
|
|
1754
|
+
this.assertNotDisposed();
|
|
1755
|
+
this._middleware.push(middleware);
|
|
1756
|
+
}
|
|
1757
|
+
removeMiddleware(middleware) {
|
|
1758
|
+
this.assertNotDisposed();
|
|
1759
|
+
const idx = this._middleware.indexOf(middleware);
|
|
1760
|
+
if (idx >= 0) this._middleware.splice(idx, 1);
|
|
1761
|
+
}
|
|
1762
|
+
// ── Context Stats ─────────────────────────────────────────
|
|
1763
|
+
async getContextStats(sessionId) {
|
|
1764
|
+
const cid = toChatId(sessionId);
|
|
1765
|
+
return this._contextStats.get(cid) ?? null;
|
|
1766
|
+
}
|
|
1767
|
+
// ── Session Subscription ──────────────────────────────────
|
|
1768
|
+
onSessionChange(callback) {
|
|
1769
|
+
return this._sessionListeners.add(callback);
|
|
1770
|
+
}
|
|
1771
|
+
_notifySessionChange() {
|
|
1772
|
+
this._sessionListeners.notify();
|
|
1773
|
+
}
|
|
1774
|
+
// ── Private Helpers ────────────────────────────────────────
|
|
1775
|
+
async getOrCreateAdapter(backend, credentials) {
|
|
1776
|
+
const key = this.getPoolKey(backend, credentials);
|
|
1777
|
+
const existing = this._adapterPool.get(key);
|
|
1778
|
+
if (existing) return existing;
|
|
1779
|
+
for (const [oldKey, oldAdapter] of this._adapterPool) {
|
|
1780
|
+
if (oldKey.startsWith(backend + ":")) {
|
|
1781
|
+
try {
|
|
1782
|
+
await oldAdapter.dispose();
|
|
1783
|
+
} catch {
|
|
1784
|
+
}
|
|
1785
|
+
this._adapterPool.delete(oldKey);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
const factory = this._backends[backend];
|
|
1789
|
+
if (!factory) {
|
|
1790
|
+
throw new ChatError(
|
|
1791
|
+
`Backend "${backend}" not found`,
|
|
1792
|
+
{ code: "INVALID_INPUT" /* INVALID_INPUT */ }
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
const adapter = await factory(credentials);
|
|
1796
|
+
this._adapterPool.set(key, adapter);
|
|
1797
|
+
return adapter;
|
|
1798
|
+
}
|
|
1799
|
+
getPoolKey(backend, credentials) {
|
|
1800
|
+
const token = credentials.accessToken;
|
|
1801
|
+
const hash = token.length > 16 ? token.slice(0, 8) + token.slice(-8) : token;
|
|
1802
|
+
return `${backend}:${hash}`;
|
|
1803
|
+
}
|
|
1804
|
+
/** Wrap each tool's execute to inject ToolContext as 2nd argument */
|
|
1805
|
+
injectToolContext(tools, context) {
|
|
1806
|
+
return tools.map((tool) => ({
|
|
1807
|
+
...tool,
|
|
1808
|
+
execute: (params) => tool.execute(params, context)
|
|
1809
|
+
}));
|
|
1810
|
+
}
|
|
1811
|
+
/** Map ChatEvent to AgentEvent for MessageAccumulator */
|
|
1812
|
+
feedAccumulator(acc, event) {
|
|
1813
|
+
const agentEvent = chatEventToAgentEvent(event);
|
|
1814
|
+
if (agentEvent) acc.apply(agentEvent);
|
|
1815
|
+
}
|
|
1816
|
+
createUserMessage(text) {
|
|
1817
|
+
return {
|
|
1818
|
+
id: createChatId(),
|
|
1819
|
+
role: "user",
|
|
1820
|
+
parts: [{ type: "text", text, status: "complete" }],
|
|
1821
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1822
|
+
status: "complete"
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
assertNotDisposed() {
|
|
1826
|
+
if (this._state.current === "disposed") {
|
|
1827
|
+
throw new ChatError(
|
|
1828
|
+
"Runtime is disposed",
|
|
1829
|
+
{ code: "DISPOSED" /* DISPOSED */ }
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
/** Get or create adapter with retry on connection errors */
|
|
1834
|
+
async getOrCreateAdapterWithRetry(backend, credentials) {
|
|
1835
|
+
const maxAttempts = this._retryConfig?.maxAttempts ?? 1;
|
|
1836
|
+
const delayMs = this._retryConfig?.delayMs ?? 0;
|
|
1837
|
+
let lastError;
|
|
1838
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1839
|
+
try {
|
|
1840
|
+
return await this.getOrCreateAdapter(backend, credentials);
|
|
1841
|
+
} catch (err) {
|
|
1842
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1843
|
+
if (attempt < maxAttempts) {
|
|
1844
|
+
const key = this.getPoolKey(backend, credentials);
|
|
1845
|
+
const old = this._adapterPool.get(key);
|
|
1846
|
+
if (old) {
|
|
1847
|
+
try {
|
|
1848
|
+
await old.dispose();
|
|
1849
|
+
} catch {
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
this._adapterPool.delete(key);
|
|
1853
|
+
await delay(delayMs);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
throw lastError;
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Create stream with retry for pre-stream connection errors.
|
|
1861
|
+
* Tries to get the first event from the stream; if that fails,
|
|
1862
|
+
* retries with a fresh adapter. Once first event is received,
|
|
1863
|
+
* the stream is committed (no more retries).
|
|
1864
|
+
*/
|
|
1865
|
+
async createStreamWithRetry(adapter, session, message, options, backend, credentials) {
|
|
1866
|
+
const maxAttempts = this._retryConfig?.maxAttempts ?? 1;
|
|
1867
|
+
const delayMs = this._retryConfig?.delayMs ?? 0;
|
|
1868
|
+
let lastError;
|
|
1869
|
+
let currentAdapter = adapter;
|
|
1870
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1871
|
+
try {
|
|
1872
|
+
const stream = currentAdapter.streamMessage(session, message, options);
|
|
1873
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
1874
|
+
const first = await iterator.next();
|
|
1875
|
+
return (async function* () {
|
|
1876
|
+
if (!first.done) yield first.value;
|
|
1877
|
+
while (true) {
|
|
1878
|
+
const next = await iterator.next();
|
|
1879
|
+
if (next.done) break;
|
|
1880
|
+
yield next.value;
|
|
1881
|
+
}
|
|
1882
|
+
})();
|
|
1883
|
+
} catch (err) {
|
|
1884
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1885
|
+
if (attempt < maxAttempts) {
|
|
1886
|
+
try {
|
|
1887
|
+
await currentAdapter.dispose();
|
|
1888
|
+
} catch {
|
|
1889
|
+
}
|
|
1890
|
+
const key = this.getPoolKey(backend, credentials);
|
|
1891
|
+
this._adapterPool.delete(key);
|
|
1892
|
+
await delay(delayMs);
|
|
1893
|
+
currentAdapter = await this.getOrCreateAdapter(backend, credentials);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
throw lastError;
|
|
1898
|
+
}
|
|
1899
|
+
};
|
|
1900
|
+
function delay(ms) {
|
|
1901
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1902
|
+
}
|
|
1903
|
+
function createChatRuntime(options) {
|
|
1904
|
+
return new ChatRuntime(options);
|
|
1905
|
+
}
|
|
1906
|
+
var DEFAULT_PROVIDER_MODELS = {
|
|
1907
|
+
copilot: "gpt-5-mini",
|
|
1908
|
+
claude: "claude-sonnet-4-5-20250514",
|
|
1909
|
+
"vercel-ai": "gpt-4.1-mini"
|
|
1910
|
+
};
|
|
466
1911
|
var MIME_TYPES = {
|
|
467
1912
|
".html": "text/html",
|
|
468
1913
|
".css": "text/css",
|
|
@@ -482,15 +1927,25 @@ var MIME_TYPES = {
|
|
|
482
1927
|
".map": "application/json"
|
|
483
1928
|
};
|
|
484
1929
|
function createChatServer(options) {
|
|
1930
|
+
const runtime = options.runtime ?? (options.runtimeConfig ? createChatRuntime(options.runtimeConfig) : (() => {
|
|
1931
|
+
throw new Error("Either `runtime` or `runtimeConfig` must be provided to createChatServer");
|
|
1932
|
+
})());
|
|
485
1933
|
const chatPrefix = options.chatPrefix ?? "/api/chat";
|
|
486
1934
|
const authPrefix = options.authPrefix ?? "/api/auth";
|
|
487
1935
|
const staticPrefix = options.staticPrefix ?? "/";
|
|
488
1936
|
const staticDir = options.staticDir ? path.resolve(options.staticDir) : void 0;
|
|
489
|
-
const
|
|
1937
|
+
const healthPath = options.healthPath !== false ? options.healthPath ?? "/api/health" : void 0;
|
|
1938
|
+
const authOptions = wrapAuthWithServiceManager(
|
|
1939
|
+
wrapAuthWithAutoProviders(options),
|
|
1940
|
+
options.serviceManager
|
|
1941
|
+
);
|
|
1942
|
+
const chatHandler = createChatHandler(runtime, {
|
|
490
1943
|
prefix: chatPrefix,
|
|
1944
|
+
providerStore: options.providers?.providerStore,
|
|
1945
|
+
hooks: options.hooks,
|
|
491
1946
|
...options.chatHandlerOptions
|
|
492
1947
|
});
|
|
493
|
-
const authHandler =
|
|
1948
|
+
const authHandler = authOptions ? createAuthHandler(authOptions) : void 0;
|
|
494
1949
|
const cors = options.cors !== false ? corsMiddleware(options.cors) : void 0;
|
|
495
1950
|
return async (req, res) => {
|
|
496
1951
|
const url = req.url || "/";
|
|
@@ -506,6 +1961,10 @@ function createChatServer(options) {
|
|
|
506
1961
|
return;
|
|
507
1962
|
}
|
|
508
1963
|
}
|
|
1964
|
+
if (healthPath && urlPath === healthPath) {
|
|
1965
|
+
json(res, { ok: true }, 200);
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
509
1968
|
if (urlPath.startsWith(chatPrefix + "/") || urlPath === chatPrefix) {
|
|
510
1969
|
await chatHandler(req, res);
|
|
511
1970
|
return;
|
|
@@ -542,13 +2001,51 @@ function createChatServer(options) {
|
|
|
542
2001
|
} catch {
|
|
543
2002
|
}
|
|
544
2003
|
}
|
|
545
|
-
|
|
2004
|
+
json(res, { error: "Not found" }, 404);
|
|
546
2005
|
};
|
|
547
2006
|
}
|
|
548
|
-
function
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
2007
|
+
function wrapAuthWithAutoProviders(options) {
|
|
2008
|
+
if (!options.auth) return void 0;
|
|
2009
|
+
if (!options.autoCreateProviders || !options.providers) return options.auth;
|
|
2010
|
+
const providerStore = options.providers.providerStore;
|
|
2011
|
+
const modelMap = typeof options.autoCreateProviders === "object" ? options.autoCreateProviders : DEFAULT_PROVIDER_MODELS;
|
|
2012
|
+
const userOnAuth = options.auth.onAuth;
|
|
2013
|
+
const wrappedOnAuth = async (backend, token) => {
|
|
2014
|
+
if (userOnAuth) await userOnAuth(backend, token);
|
|
2015
|
+
try {
|
|
2016
|
+
const existing = await providerStore.list();
|
|
2017
|
+
const hasBackend = existing.some((p) => p.backend === backend);
|
|
2018
|
+
if (!hasBackend) {
|
|
2019
|
+
const model = modelMap[backend] ?? "default";
|
|
2020
|
+
const label = `${backend.charAt(0).toUpperCase() + backend.slice(1)} ${model}`;
|
|
2021
|
+
await providerStore.create({
|
|
2022
|
+
id: crypto.randomUUID(),
|
|
2023
|
+
backend,
|
|
2024
|
+
model,
|
|
2025
|
+
label,
|
|
2026
|
+
createdAt: Date.now()
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
} catch {
|
|
2030
|
+
}
|
|
2031
|
+
};
|
|
2032
|
+
return { ...options.auth, onAuth: wrappedOnAuth };
|
|
2033
|
+
}
|
|
2034
|
+
function wrapAuthWithServiceManager(authOptions, serviceManager) {
|
|
2035
|
+
if (!authOptions || !serviceManager) return authOptions;
|
|
2036
|
+
const userOnAuth = authOptions.onAuth;
|
|
2037
|
+
const userOnLogout = authOptions.onLogout;
|
|
2038
|
+
return {
|
|
2039
|
+
...authOptions,
|
|
2040
|
+
onAuth: async (backend, token) => {
|
|
2041
|
+
if (userOnAuth) await userOnAuth(backend, token);
|
|
2042
|
+
await serviceManager.handleAuth(backend, token);
|
|
2043
|
+
},
|
|
2044
|
+
onLogout: async () => {
|
|
2045
|
+
if (userOnLogout) await userOnLogout();
|
|
2046
|
+
await serviceManager.handleLogout();
|
|
2047
|
+
}
|
|
2048
|
+
};
|
|
552
2049
|
}
|
|
553
2050
|
var InMemoryTokenStore = class {
|
|
554
2051
|
tokens = /* @__PURE__ */ new Map();
|
|
@@ -611,7 +2108,521 @@ var FileTokenStore = class {
|
|
|
611
2108
|
return join(this.dir, `${provider}-token.json`);
|
|
612
2109
|
}
|
|
613
2110
|
};
|
|
2111
|
+
function createProviderHandler(options) {
|
|
2112
|
+
const { providerStore } = options;
|
|
2113
|
+
return async (req, res) => {
|
|
2114
|
+
const url = req.url || "";
|
|
2115
|
+
const method = req.method || "GET";
|
|
2116
|
+
const path2 = url.split("?")[0];
|
|
2117
|
+
const idMatch = path2.match(/^\/providers\/([^/]+)$/);
|
|
2118
|
+
try {
|
|
2119
|
+
if (method === "GET" && path2 === "/providers") {
|
|
2120
|
+
const providers = await providerStore.list();
|
|
2121
|
+
json(res, providers);
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (method === "GET" && idMatch) {
|
|
2125
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
2126
|
+
const provider = await providerStore.get(id);
|
|
2127
|
+
if (!provider) {
|
|
2128
|
+
json(res, { error: "Provider not found" }, 404);
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
json(res, provider);
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
if (method === "POST" && path2 === "/providers") {
|
|
2135
|
+
const body = await readBody(req);
|
|
2136
|
+
const backend = body.backend;
|
|
2137
|
+
const model = body.model;
|
|
2138
|
+
const label = body.label;
|
|
2139
|
+
if (!backend || typeof backend !== "string") {
|
|
2140
|
+
json(res, { error: "backend is required" }, 400);
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
if (!model || typeof model !== "string") {
|
|
2144
|
+
json(res, { error: "model is required" }, 400);
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
if (!label || typeof label !== "string") {
|
|
2148
|
+
json(res, { error: "label is required" }, 400);
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
const config = {
|
|
2152
|
+
id: randomUUID(),
|
|
2153
|
+
backend,
|
|
2154
|
+
model,
|
|
2155
|
+
label,
|
|
2156
|
+
createdAt: Date.now()
|
|
2157
|
+
};
|
|
2158
|
+
await providerStore.create(config);
|
|
2159
|
+
json(res, config, 201);
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
if (method === "PUT" && idMatch) {
|
|
2163
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
2164
|
+
const existing = await providerStore.get(id);
|
|
2165
|
+
if (!existing) {
|
|
2166
|
+
json(res, { error: "Provider not found" }, 404);
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
const body = await readBody(req);
|
|
2170
|
+
const changes = {};
|
|
2171
|
+
if (body.backend && typeof body.backend === "string") changes.backend = body.backend;
|
|
2172
|
+
if (body.model && typeof body.model === "string") changes.model = body.model;
|
|
2173
|
+
if (body.label && typeof body.label === "string") changes.label = body.label;
|
|
2174
|
+
await providerStore.update(id, changes);
|
|
2175
|
+
const updated = await providerStore.get(id);
|
|
2176
|
+
json(res, updated);
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
if (method === "DELETE" && idMatch) {
|
|
2180
|
+
const id = decodeURIComponent(idMatch[1]);
|
|
2181
|
+
await providerStore.delete(id);
|
|
2182
|
+
json(res, { ok: true });
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
json(res, { error: "Not found" }, 404);
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2188
|
+
json(res, { error: message }, 500);
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
var InMemoryProviderStore = class {
|
|
2193
|
+
providers = /* @__PURE__ */ new Map();
|
|
2194
|
+
async create(config) {
|
|
2195
|
+
const id = config.id || randomUUID();
|
|
2196
|
+
this.providers.set(id, { ...config, id });
|
|
2197
|
+
}
|
|
2198
|
+
async get(id) {
|
|
2199
|
+
const p = this.providers.get(id);
|
|
2200
|
+
return p ? { ...p } : null;
|
|
2201
|
+
}
|
|
2202
|
+
async update(id, changes) {
|
|
2203
|
+
const existing = this.providers.get(id);
|
|
2204
|
+
if (!existing) {
|
|
2205
|
+
throw new Error(`Provider "${id}" not found`);
|
|
2206
|
+
}
|
|
2207
|
+
this.providers.set(id, { ...existing, ...changes, id: existing.id, createdAt: existing.createdAt });
|
|
2208
|
+
}
|
|
2209
|
+
async delete(id) {
|
|
2210
|
+
this.providers.delete(id);
|
|
2211
|
+
}
|
|
2212
|
+
async list() {
|
|
2213
|
+
return [...this.providers.values()].map((p) => ({ ...p }));
|
|
2214
|
+
}
|
|
2215
|
+
};
|
|
2216
|
+
var FileProviderStore = class {
|
|
2217
|
+
dir;
|
|
2218
|
+
constructor(options) {
|
|
2219
|
+
this.dir = options.directory;
|
|
2220
|
+
}
|
|
2221
|
+
async create(config) {
|
|
2222
|
+
const id = config.id || randomUUID();
|
|
2223
|
+
const data = { ...config, id };
|
|
2224
|
+
mkdirSync(this.dir, { recursive: true });
|
|
2225
|
+
writeFileSync(this.filePath(id), JSON.stringify(data));
|
|
2226
|
+
}
|
|
2227
|
+
async get(id) {
|
|
2228
|
+
try {
|
|
2229
|
+
const data = readFileSync(this.filePath(id), "utf-8");
|
|
2230
|
+
return JSON.parse(data);
|
|
2231
|
+
} catch {
|
|
2232
|
+
return null;
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
async update(id, changes) {
|
|
2236
|
+
const existing = await this.get(id);
|
|
2237
|
+
if (!existing) {
|
|
2238
|
+
throw new Error(`Provider "${id}" not found`);
|
|
2239
|
+
}
|
|
2240
|
+
const updated = { ...existing, ...changes, id: existing.id, createdAt: existing.createdAt };
|
|
2241
|
+
writeFileSync(this.filePath(id), JSON.stringify(updated));
|
|
2242
|
+
}
|
|
2243
|
+
async delete(id) {
|
|
2244
|
+
try {
|
|
2245
|
+
unlinkSync(this.filePath(id));
|
|
2246
|
+
} catch {
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
async list() {
|
|
2250
|
+
if (!existsSync(this.dir)) return [];
|
|
2251
|
+
return readdirSync(this.dir).filter((f) => f.endsWith("-provider.json")).map((f) => {
|
|
2252
|
+
try {
|
|
2253
|
+
const data = readFileSync(join(this.dir, f), "utf-8");
|
|
2254
|
+
return JSON.parse(data);
|
|
2255
|
+
} catch {
|
|
2256
|
+
return null;
|
|
2257
|
+
}
|
|
2258
|
+
}).filter((p) => p !== null);
|
|
2259
|
+
}
|
|
2260
|
+
filePath(id) {
|
|
2261
|
+
return join(this.dir, `${id}-provider.json`);
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
|
|
2265
|
+
// src/auth/refresh-manager.ts
|
|
2266
|
+
var TokenRefreshManager = class {
|
|
2267
|
+
currentToken;
|
|
2268
|
+
refreshFn;
|
|
2269
|
+
threshold;
|
|
2270
|
+
maxRetries;
|
|
2271
|
+
retryDelayMs;
|
|
2272
|
+
minDelayMs;
|
|
2273
|
+
timerId = null;
|
|
2274
|
+
running = false;
|
|
2275
|
+
disposed = false;
|
|
2276
|
+
listeners = {
|
|
2277
|
+
refreshed: /* @__PURE__ */ new Set(),
|
|
2278
|
+
error: /* @__PURE__ */ new Set(),
|
|
2279
|
+
expired: /* @__PURE__ */ new Set(),
|
|
2280
|
+
disposed: /* @__PURE__ */ new Set()
|
|
2281
|
+
};
|
|
2282
|
+
constructor(options) {
|
|
2283
|
+
this.currentToken = { ...options.token };
|
|
2284
|
+
this.refreshFn = options.refresh;
|
|
2285
|
+
this.threshold = options.refreshThreshold ?? 0.8;
|
|
2286
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
2287
|
+
this.retryDelayMs = options.retryDelayMs ?? 1e3;
|
|
2288
|
+
this.minDelayMs = options.minDelayMs ?? 1e3;
|
|
2289
|
+
}
|
|
2290
|
+
/** Register an event listener */
|
|
2291
|
+
on(event, listener) {
|
|
2292
|
+
this.listeners[event].add(listener);
|
|
2293
|
+
return this;
|
|
2294
|
+
}
|
|
2295
|
+
/** Remove an event listener */
|
|
2296
|
+
off(event, listener) {
|
|
2297
|
+
this.listeners[event].delete(listener);
|
|
2298
|
+
return this;
|
|
2299
|
+
}
|
|
2300
|
+
/** Current token managed by this instance */
|
|
2301
|
+
get token() {
|
|
2302
|
+
return { ...this.currentToken };
|
|
2303
|
+
}
|
|
2304
|
+
/** Whether the manager is currently running */
|
|
2305
|
+
get isRunning() {
|
|
2306
|
+
return this.running;
|
|
2307
|
+
}
|
|
2308
|
+
/** Whether the manager has been disposed */
|
|
2309
|
+
get isDisposed() {
|
|
2310
|
+
return this.disposed;
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Start automatic refresh scheduling.
|
|
2314
|
+
* If the token is already expired, emits "expired" immediately.
|
|
2315
|
+
* If the token has no expiresIn, does nothing (long-lived token).
|
|
2316
|
+
*/
|
|
2317
|
+
start() {
|
|
2318
|
+
if (this.disposed) return;
|
|
2319
|
+
if (this.running) return;
|
|
2320
|
+
this.running = true;
|
|
2321
|
+
this.schedule();
|
|
2322
|
+
}
|
|
2323
|
+
/** Stop automatic refresh (can be restarted with start()) */
|
|
2324
|
+
stop() {
|
|
2325
|
+
this.running = false;
|
|
2326
|
+
this.clearTimer();
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Update the managed token (e.g. after manual refresh).
|
|
2330
|
+
* Reschedules automatic refresh if running.
|
|
2331
|
+
*/
|
|
2332
|
+
updateToken(token) {
|
|
2333
|
+
if (this.disposed) return;
|
|
2334
|
+
this.currentToken = { ...token };
|
|
2335
|
+
if (this.running) {
|
|
2336
|
+
this.clearTimer();
|
|
2337
|
+
this.schedule();
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
/** Stop and clean up all resources */
|
|
2341
|
+
dispose() {
|
|
2342
|
+
if (this.disposed) return;
|
|
2343
|
+
this.stop();
|
|
2344
|
+
this.disposed = true;
|
|
2345
|
+
this.emit("disposed");
|
|
2346
|
+
for (const set of Object.values(this.listeners)) {
|
|
2347
|
+
set.clear();
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
// ─── Private ──────────────────────────────────────────────────
|
|
2351
|
+
schedule() {
|
|
2352
|
+
if (!this.running || this.disposed) return;
|
|
2353
|
+
const delayMs = this.computeRefreshDelay();
|
|
2354
|
+
if (delayMs === null) {
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
if (delayMs <= 0) {
|
|
2358
|
+
this.timerId = setTimeout(() => {
|
|
2359
|
+
this.timerId = null;
|
|
2360
|
+
if (!this.running || this.disposed) return;
|
|
2361
|
+
void this.performRefresh();
|
|
2362
|
+
}, 0);
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
this.timerId = setTimeout(() => {
|
|
2366
|
+
this.timerId = null;
|
|
2367
|
+
if (!this.running || this.disposed) return;
|
|
2368
|
+
void this.performRefresh();
|
|
2369
|
+
}, Math.max(delayMs, this.minDelayMs));
|
|
2370
|
+
}
|
|
2371
|
+
async performRefresh(attempt = 1) {
|
|
2372
|
+
if (!this.running || this.disposed) return;
|
|
2373
|
+
try {
|
|
2374
|
+
const newToken = await this.refreshFn(this.currentToken);
|
|
2375
|
+
if (!this.running || this.disposed) return;
|
|
2376
|
+
this.currentToken = { ...newToken };
|
|
2377
|
+
this.emit("refreshed", newToken);
|
|
2378
|
+
this.schedule();
|
|
2379
|
+
} catch (err) {
|
|
2380
|
+
if (!this.running || this.disposed) return;
|
|
2381
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2382
|
+
this.emit("error", error, attempt);
|
|
2383
|
+
if (attempt < this.maxRetries) {
|
|
2384
|
+
const delay2 = this.retryDelayMs * Math.pow(2, attempt - 1);
|
|
2385
|
+
this.timerId = setTimeout(() => {
|
|
2386
|
+
this.timerId = null;
|
|
2387
|
+
if (!this.running || this.disposed) return;
|
|
2388
|
+
void this.performRefresh(attempt + 1);
|
|
2389
|
+
}, delay2);
|
|
2390
|
+
} else {
|
|
2391
|
+
if (this.isTokenExpired()) {
|
|
2392
|
+
this.running = false;
|
|
2393
|
+
this.emit("expired");
|
|
2394
|
+
} else {
|
|
2395
|
+
const expiresIn = this.currentToken.expiresIn;
|
|
2396
|
+
if (expiresIn == null) return;
|
|
2397
|
+
const expiresAt = this.currentToken.obtainedAt + expiresIn * 1e3;
|
|
2398
|
+
const waitMs = Math.max(expiresAt - Date.now(), this.minDelayMs);
|
|
2399
|
+
this.timerId = setTimeout(() => {
|
|
2400
|
+
this.timerId = null;
|
|
2401
|
+
if (!this.running || this.disposed) return;
|
|
2402
|
+
void this.performRefresh();
|
|
2403
|
+
}, waitMs);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
computeRefreshDelay() {
|
|
2409
|
+
if (this.currentToken.expiresIn == null) return null;
|
|
2410
|
+
const lifetimeMs = this.currentToken.expiresIn * 1e3;
|
|
2411
|
+
const refreshAtMs = this.currentToken.obtainedAt + lifetimeMs * this.threshold;
|
|
2412
|
+
const now = Date.now();
|
|
2413
|
+
const delay2 = refreshAtMs - now;
|
|
2414
|
+
return delay2;
|
|
2415
|
+
}
|
|
2416
|
+
isTokenExpired() {
|
|
2417
|
+
if (this.currentToken.expiresIn == null) return false;
|
|
2418
|
+
const expiresAt = this.currentToken.obtainedAt + this.currentToken.expiresIn * 1e3;
|
|
2419
|
+
return Date.now() >= expiresAt;
|
|
2420
|
+
}
|
|
2421
|
+
clearTimer() {
|
|
2422
|
+
if (this.timerId !== null) {
|
|
2423
|
+
clearTimeout(this.timerId);
|
|
2424
|
+
this.timerId = null;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
emit(event, ...args) {
|
|
2428
|
+
for (const listener of this.listeners[event]) {
|
|
2429
|
+
try {
|
|
2430
|
+
listener(...args);
|
|
2431
|
+
} catch {
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
};
|
|
2436
|
+
|
|
2437
|
+
// src/chat/server/service-manager.ts
|
|
2438
|
+
var ServiceManager = class {
|
|
2439
|
+
_services = /* @__PURE__ */ new Map();
|
|
2440
|
+
_refreshManagers = /* @__PURE__ */ new Map();
|
|
2441
|
+
_options;
|
|
2442
|
+
constructor(options) {
|
|
2443
|
+
this._options = options;
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Handle auth event: dispose old service (if any) and create new one.
|
|
2447
|
+
* If the token is refreshable and refreshFactory is configured, starts a
|
|
2448
|
+
* TokenRefreshManager that auto-refreshes and recreates the service.
|
|
2449
|
+
*/
|
|
2450
|
+
async handleAuth(backend, token) {
|
|
2451
|
+
this._stopRefreshManager(backend);
|
|
2452
|
+
const old = this._services.get(backend);
|
|
2453
|
+
if (old) {
|
|
2454
|
+
try {
|
|
2455
|
+
await old.dispose();
|
|
2456
|
+
} catch {
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
const service = await this._options.createService(backend, token);
|
|
2460
|
+
this._services.set(backend, service);
|
|
2461
|
+
this._startRefreshManager(backend, token);
|
|
2462
|
+
return service;
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Handle logout: dispose all services, stop all refresh managers, clear cache.
|
|
2466
|
+
*/
|
|
2467
|
+
async handleLogout() {
|
|
2468
|
+
for (const backend of [...this._refreshManagers.keys()]) {
|
|
2469
|
+
this._stopRefreshManager(backend);
|
|
2470
|
+
}
|
|
2471
|
+
for (const [, service] of this._services) {
|
|
2472
|
+
try {
|
|
2473
|
+
await service.dispose();
|
|
2474
|
+
} catch {
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
this._services.clear();
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Dispose the ServiceManager — stops all refresh managers and disposes all services.
|
|
2481
|
+
*/
|
|
2482
|
+
async dispose() {
|
|
2483
|
+
await this.handleLogout();
|
|
2484
|
+
}
|
|
2485
|
+
/** Get cached service for a backend (undefined if not authenticated). */
|
|
2486
|
+
getService(backend) {
|
|
2487
|
+
return this._services.get(backend);
|
|
2488
|
+
}
|
|
2489
|
+
/** Check if a service exists for the given backend. */
|
|
2490
|
+
hasService(backend) {
|
|
2491
|
+
return this._services.has(backend);
|
|
2492
|
+
}
|
|
2493
|
+
/** Get all backend names with active services. */
|
|
2494
|
+
get activeBackends() {
|
|
2495
|
+
return [...this._services.keys()];
|
|
2496
|
+
}
|
|
2497
|
+
/** Get active refresh manager for a backend (for testing/introspection). */
|
|
2498
|
+
getRefreshManager(backend) {
|
|
2499
|
+
return this._refreshManagers.get(backend);
|
|
2500
|
+
}
|
|
2501
|
+
// ── Private ─────────────────────────────────────────────────
|
|
2502
|
+
_startRefreshManager(backend, token) {
|
|
2503
|
+
if (!this._options.refreshFactory) return;
|
|
2504
|
+
if (token.expiresIn == null) return;
|
|
2505
|
+
const refreshFn = this._options.refreshFactory(backend);
|
|
2506
|
+
if (!refreshFn) return;
|
|
2507
|
+
const manager = new TokenRefreshManager({
|
|
2508
|
+
token,
|
|
2509
|
+
refresh: refreshFn,
|
|
2510
|
+
refreshThreshold: this._options.refreshOptions?.refreshThreshold,
|
|
2511
|
+
maxRetries: this._options.refreshOptions?.maxRetries,
|
|
2512
|
+
retryDelayMs: this._options.refreshOptions?.retryDelayMs
|
|
2513
|
+
});
|
|
2514
|
+
manager.on("refreshed", (newToken) => {
|
|
2515
|
+
void this._recreateService(backend, newToken);
|
|
2516
|
+
});
|
|
2517
|
+
manager.on("expired", () => {
|
|
2518
|
+
this._options.onTokenExpired?.(backend);
|
|
2519
|
+
void this._logoutBackend(backend);
|
|
2520
|
+
});
|
|
2521
|
+
this._refreshManagers.set(backend, manager);
|
|
2522
|
+
manager.start();
|
|
2523
|
+
}
|
|
2524
|
+
_stopRefreshManager(backend) {
|
|
2525
|
+
const manager = this._refreshManagers.get(backend);
|
|
2526
|
+
if (manager) {
|
|
2527
|
+
manager.dispose();
|
|
2528
|
+
this._refreshManagers.delete(backend);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
async _recreateService(backend, token) {
|
|
2532
|
+
const old = this._services.get(backend);
|
|
2533
|
+
if (old) {
|
|
2534
|
+
try {
|
|
2535
|
+
await old.dispose();
|
|
2536
|
+
} catch {
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
try {
|
|
2540
|
+
const service = await this._options.createService(backend, token);
|
|
2541
|
+
this._services.set(backend, service);
|
|
2542
|
+
} catch {
|
|
2543
|
+
this._services.delete(backend);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
async _logoutBackend(backend) {
|
|
2547
|
+
this._stopRefreshManager(backend);
|
|
2548
|
+
const service = this._services.get(backend);
|
|
2549
|
+
if (service) {
|
|
2550
|
+
try {
|
|
2551
|
+
await service.dispose();
|
|
2552
|
+
} catch {
|
|
2553
|
+
}
|
|
2554
|
+
this._services.delete(backend);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
};
|
|
2558
|
+
|
|
2559
|
+
// src/chat/server/adapter-pool.ts
|
|
2560
|
+
var AdapterPool = class {
|
|
2561
|
+
_cached = /* @__PURE__ */ new Map();
|
|
2562
|
+
_pending = /* @__PURE__ */ new Map();
|
|
2563
|
+
_factory;
|
|
2564
|
+
_disposed = false;
|
|
2565
|
+
constructor(options) {
|
|
2566
|
+
this._factory = options.factory;
|
|
2567
|
+
}
|
|
2568
|
+
/**
|
|
2569
|
+
* Get or create an adapter for the given backend.
|
|
2570
|
+
* Concurrent calls for the same backend share one creation promise.
|
|
2571
|
+
* Failed creations are NOT cached — next call retries.
|
|
2572
|
+
*/
|
|
2573
|
+
async getAdapter(backend) {
|
|
2574
|
+
if (this._disposed) {
|
|
2575
|
+
throw new Error("AdapterPool is disposed");
|
|
2576
|
+
}
|
|
2577
|
+
const cached = this._cached.get(backend);
|
|
2578
|
+
if (cached) return cached;
|
|
2579
|
+
const pending = this._pending.get(backend);
|
|
2580
|
+
if (pending) return pending;
|
|
2581
|
+
const promise = this._create(backend);
|
|
2582
|
+
this._pending.set(backend, promise);
|
|
2583
|
+
try {
|
|
2584
|
+
const adapter = await promise;
|
|
2585
|
+
this._cached.set(backend, adapter);
|
|
2586
|
+
return adapter;
|
|
2587
|
+
} finally {
|
|
2588
|
+
this._pending.delete(backend);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Evict (dispose and remove) the cached adapter for a backend.
|
|
2593
|
+
* Use after token rotation to force re-creation on next getAdapter().
|
|
2594
|
+
*/
|
|
2595
|
+
async evict(backend) {
|
|
2596
|
+
const cached = this._cached.get(backend);
|
|
2597
|
+
if (cached) {
|
|
2598
|
+
this._cached.delete(backend);
|
|
2599
|
+
try {
|
|
2600
|
+
await cached.dispose();
|
|
2601
|
+
} catch {
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
/** Check if a backend has a cached adapter. */
|
|
2606
|
+
has(backend) {
|
|
2607
|
+
return this._cached.has(backend);
|
|
2608
|
+
}
|
|
2609
|
+
/** Get all backend names with cached adapters. */
|
|
2610
|
+
get activeBackends() {
|
|
2611
|
+
return [...this._cached.keys()];
|
|
2612
|
+
}
|
|
2613
|
+
/** Dispose all cached adapters and mark pool as unusable. */
|
|
2614
|
+
async dispose() {
|
|
2615
|
+
this._disposed = true;
|
|
2616
|
+
const backends = [...this._cached.keys()];
|
|
2617
|
+
for (const backend of backends) {
|
|
2618
|
+
await this.evict(backend);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
async _create(backend) {
|
|
2622
|
+
return this._factory(backend);
|
|
2623
|
+
}
|
|
2624
|
+
};
|
|
614
2625
|
|
|
615
|
-
export { FileTokenStore, InMemoryTokenStore, corsMiddleware, createAuthHandler, createChatHandler, createChatServer };
|
|
2626
|
+
export { AdapterPool, BodyParseError, DEFAULT_PROVIDER_MODELS, FileProviderStore, FileTokenStore, InMemoryProviderStore, InMemoryTokenStore, ServiceManager, configRoutes, corsMiddleware, createAuthHandler, createChatHandler, createChatServer, createProviderHandler, json, messageRoutes, providerRoutes, readBody, resolveRequestContext, sessionRoutes };
|
|
616
2627
|
//# sourceMappingURL=server.js.map
|
|
617
2628
|
//# sourceMappingURL=server.js.map
|