@intelligo-dev/chat 1.0.0-beta.13

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.
Files changed (94) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.md +116 -0
  4. package/dist/artifact-writer.d.ts +45 -0
  5. package/dist/artifact-writer.d.ts.map +1 -0
  6. package/dist/artifact-writer.js +77 -0
  7. package/dist/artifact-writer.js.map +1 -0
  8. package/dist/attachments.d.ts +56 -0
  9. package/dist/attachments.d.ts.map +1 -0
  10. package/dist/attachments.js +204 -0
  11. package/dist/attachments.js.map +1 -0
  12. package/dist/body.d.ts +72 -0
  13. package/dist/body.d.ts.map +1 -0
  14. package/dist/body.js +174 -0
  15. package/dist/body.js.map +1 -0
  16. package/dist/client.d.ts +65 -0
  17. package/dist/client.d.ts.map +1 -0
  18. package/dist/client.js +61 -0
  19. package/dist/client.js.map +1 -0
  20. package/dist/config.d.ts +322 -0
  21. package/dist/config.d.ts.map +1 -0
  22. package/dist/config.js +11 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/errors.d.ts +23 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +41 -0
  27. package/dist/errors.js.map +1 -0
  28. package/dist/feedback.d.ts +22 -0
  29. package/dist/feedback.d.ts.map +1 -0
  30. package/dist/feedback.js +46 -0
  31. package/dist/feedback.js.map +1 -0
  32. package/dist/generation.d.ts +104 -0
  33. package/dist/generation.d.ts.map +1 -0
  34. package/dist/generation.js +85 -0
  35. package/dist/generation.js.map +1 -0
  36. package/dist/handler.d.ts +30 -0
  37. package/dist/handler.d.ts.map +1 -0
  38. package/dist/handler.js +913 -0
  39. package/dist/handler.js.map +1 -0
  40. package/dist/index.d.ts +31 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +20 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/messages.d.ts +19 -0
  45. package/dist/messages.d.ts.map +1 -0
  46. package/dist/messages.js +34 -0
  47. package/dist/messages.js.map +1 -0
  48. package/dist/parts.d.ts +117 -0
  49. package/dist/parts.d.ts.map +1 -0
  50. package/dist/parts.js +13 -0
  51. package/dist/parts.js.map +1 -0
  52. package/dist/quota.d.ts +32 -0
  53. package/dist/quota.d.ts.map +1 -0
  54. package/dist/quota.js +81 -0
  55. package/dist/quota.js.map +1 -0
  56. package/dist/share.d.ts +24 -0
  57. package/dist/share.d.ts.map +1 -0
  58. package/dist/share.js +78 -0
  59. package/dist/share.js.map +1 -0
  60. package/dist/testing.d.ts +36 -0
  61. package/dist/testing.d.ts.map +1 -0
  62. package/dist/testing.js +82 -0
  63. package/dist/testing.js.map +1 -0
  64. package/dist/title.d.ts +7 -0
  65. package/dist/title.d.ts.map +1 -0
  66. package/dist/title.js +15 -0
  67. package/dist/title.js.map +1 -0
  68. package/dist/usage.d.ts +21 -0
  69. package/dist/usage.d.ts.map +1 -0
  70. package/dist/usage.js +35 -0
  71. package/dist/usage.js.map +1 -0
  72. package/dist/windowing.d.ts +38 -0
  73. package/dist/windowing.d.ts.map +1 -0
  74. package/dist/windowing.js +82 -0
  75. package/dist/windowing.js.map +1 -0
  76. package/package.json +78 -0
  77. package/src/artifact-writer.ts +114 -0
  78. package/src/attachments.ts +262 -0
  79. package/src/body.ts +236 -0
  80. package/src/client.ts +133 -0
  81. package/src/config.ts +376 -0
  82. package/src/errors.ts +80 -0
  83. package/src/feedback.ts +62 -0
  84. package/src/generation.ts +150 -0
  85. package/src/handler.ts +1164 -0
  86. package/src/index.ts +93 -0
  87. package/src/messages.ts +39 -0
  88. package/src/parts.ts +143 -0
  89. package/src/quota.ts +105 -0
  90. package/src/share.ts +103 -0
  91. package/src/testing.ts +150 -0
  92. package/src/title.ts +15 -0
  93. package/src/usage.ts +46 -0
  94. package/src/windowing.ts +110 -0
@@ -0,0 +1,913 @@
1
+ /**
2
+ * The chat transport: one turn, from request to settled execution.
3
+ *
4
+ * onRequest → parse → authenticate → rate limit → load the row →
5
+ * resolveAgent → feature gate → model gate → create the row →
6
+ * prepareMessages → executions.begin() → streamText | streamTurn →
7
+ * settle → persist.
8
+ *
9
+ * Web `Request` in, `Response` out; request headers reach
10
+ * `requireWorkspace()` through `core/request-context`, never `next/*`.
11
+ *
12
+ * Entitlement is decided at `executions.begin()`, after the agent and
13
+ * model are resolved, so the hold matches what will actually run.
14
+ * Everything before it is cheaper and answers without opening an execution.
15
+ *
16
+ * Every terminal path settles exactly once: `complete()` is
17
+ * compare-and-swap in the boundary, so whichever of finish, abort or
18
+ * error gets there first wins and the others are no-ops.
19
+ */
20
+ import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, generateId, stepCountIs, streamText, } from "ai";
21
+ import { requireWorkspace } from "@intelligo-dev/auth";
22
+ import { checkRateLimit, getWorkspaceBilling, hasFeature, } from "@intelligo-dev/billing";
23
+ import { attachToConversation, getAttachments, } from "@intelligo-dev/core/attachments";
24
+ import { createConversation, deleteConversation, deleteTrailingMessages, getConversation, getMessages, isConversationServiceError, renameConversation, updateConversationMetadata, upsertMessages, } from "@intelligo-dev/core/conversations";
25
+ import { createLogger } from "@intelligo-dev/core/logger";
26
+ import { getStorageAdapter } from "@intelligo-dev/core/storage";
27
+ import { getModelPricing } from "@intelligo-dev/executions/pricing";
28
+ import { attachmentIdFromUrl, parseChatBody } from "./body.js";
29
+ import { CHAT_ERROR_STATUS, DEFAULT_CHAT_MESSAGES, refuse } from "./errors.js";
30
+ import { pickGenerationOptions } from "./generation.js";
31
+ import { lastUserMessage, toUIMessages } from "./messages.js";
32
+ import { truncateTitle } from "./title.js";
33
+ import { pickUsage, sumStepUsage, sumUsage } from "./usage.js";
34
+ import { applyConversationWindow, extractText } from "./windowing.js";
35
+ const log = createLogger("Chat");
36
+ function errorMessage(error) {
37
+ return error instanceof Error ? error.message : String(error);
38
+ }
39
+ const DEFAULT_FEATURE_KEY = "chat";
40
+ const DEFAULT_CAPABILITY = "chat.message";
41
+ const DEFAULT_AGENT_ID = "assistant";
42
+ const DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant embedded in a SaaS product. Be concise and direct.";
43
+ const DEFAULT_MAX_MESSAGE_LENGTH = 8000;
44
+ const DEFAULT_MAX_STEPS = 5;
45
+ /**
46
+ * Admission holds the price of a 16K-token input; the history is kept
47
+ * under 12K of it, leaving the rest to the system prompt and tools.
48
+ */
49
+ const DEFAULT_WINDOW = { maxMessages: 40, maxTokens: 12000 };
50
+ const MODEL_URL_SECONDS = 900;
51
+ async function defaultAuthenticate() {
52
+ const { workspace, user } = await requireWorkspace();
53
+ return { workspaceId: workspace.id, userId: user.id };
54
+ }
55
+ async function defaultRateLimit(actor) {
56
+ const billing = await getWorkspaceBilling(actor.workspaceId);
57
+ return checkRateLimit(actor.workspaceId, billing.plan?.slug ?? "free");
58
+ }
59
+ function rateLimitHeaders(decision) {
60
+ const headers = {};
61
+ if (decision.limit !== undefined) {
62
+ headers["X-RateLimit-Limit"] = String(decision.limit);
63
+ }
64
+ if (decision.remaining !== undefined) {
65
+ headers["X-RateLimit-Remaining"] = String(decision.remaining);
66
+ }
67
+ if (decision.resetAt !== undefined) {
68
+ headers["X-RateLimit-Reset"] = String(Math.ceil(decision.resetAt.getTime() / 1000));
69
+ }
70
+ if (decision.retryAfterSeconds !== undefined) {
71
+ headers["Retry-After"] = String(decision.retryAfterSeconds);
72
+ }
73
+ return headers;
74
+ }
75
+ /**
76
+ * The `start` and `finish` frames are the transport's: it writes them
77
+ * around whatever a `streamTurn` produces, so the response id and the
78
+ * message metadata are its own. A runtime whose adapter frames the
79
+ * message itself is not asked to strip anything.
80
+ */
81
+ function withoutFrames(stream) {
82
+ return stream.pipeThrough(new TransformStream({
83
+ transform(chunk, controller) {
84
+ if (chunk.type !== "start" && chunk.type !== "finish") {
85
+ controller.enqueue(chunk);
86
+ }
87
+ },
88
+ }));
89
+ }
90
+ /** The approval answers a continuation carries, from the last assistant message. */
91
+ function approvalAnswers(messages) {
92
+ const last = messages[messages.length - 1];
93
+ if (last?.role !== "assistant")
94
+ return [];
95
+ const answers = [];
96
+ for (const raw of last.parts) {
97
+ const part = raw;
98
+ if (part.state !== "approval-responded")
99
+ continue;
100
+ const approval = part.approval;
101
+ if (!approval ||
102
+ typeof approval.id !== "string" ||
103
+ typeof approval.approved !== "boolean") {
104
+ continue;
105
+ }
106
+ const toolName = part.type === "dynamic-tool" && typeof part.toolName === "string"
107
+ ? part.toolName
108
+ : typeof part.type === "string" && part.type.startsWith("tool-")
109
+ ? part.type.slice("tool-".length)
110
+ : "";
111
+ answers.push({
112
+ toolName,
113
+ toolCallId: typeof part.toolCallId === "string" ? part.toolCallId : "",
114
+ approvalId: approval.id,
115
+ approved: approval.approved,
116
+ ...(typeof approval.reason === "string"
117
+ ? { reason: approval.reason }
118
+ : {}),
119
+ });
120
+ }
121
+ return answers;
122
+ }
123
+ /** The stored attachment ids a transcript's file parts name. */
124
+ function storedAttachmentIds(messages, policy) {
125
+ const ids = new Set();
126
+ for (const message of messages) {
127
+ for (const part of message.parts) {
128
+ if (part.type !== "file")
129
+ continue;
130
+ const id = attachmentIdFromUrl(policy, part.url);
131
+ if (id)
132
+ ids.add(id);
133
+ }
134
+ }
135
+ return [...ids];
136
+ }
137
+ export function createChatHandler(config) {
138
+ if (!config.model.resolve && !config.streamTurn) {
139
+ throw new Error("createChatHandler: set model.resolve (a model for streamText) or streamTurn (a runtime binding); the transport cannot guess the model.");
140
+ }
141
+ const maxMessageLength = config.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
142
+ const attachments = config.attachments ?? false;
143
+ const authenticate = config.authenticate ?? defaultAuthenticate;
144
+ const rateLimit = config.rateLimit === undefined ? defaultRateLimit : config.rateLimit;
145
+ const deriveTitle = config.deriveTitle ?? truncateTitle;
146
+ const events = config.onTurn ?? {};
147
+ const withMetadata = config.messageMetadata ?? true;
148
+ const allowedOrigins = new Set(config.cors?.origins ?? []);
149
+ /** Telemetry must never fail a turn. */
150
+ async function emit(run) {
151
+ if (!run)
152
+ return;
153
+ try {
154
+ await run();
155
+ }
156
+ catch (error) {
157
+ log.warn("onTurn hook threw", { error: errorMessage(error) });
158
+ }
159
+ }
160
+ async function messagesFor(request) {
161
+ return config.messages ? config.messages(request) : DEFAULT_CHAT_MESSAGES;
162
+ }
163
+ /**
164
+ * Headers that let an embedded widget on another origin call this
165
+ * route with its cookies. Only for an origin the deployment listed;
166
+ * everyone else gets no header and the browser refuses the response.
167
+ */
168
+ function corsHeaders(request) {
169
+ if (allowedOrigins.size === 0)
170
+ return {};
171
+ const origin = request.headers.get("origin");
172
+ if (!origin || !allowedOrigins.has(origin))
173
+ return {};
174
+ return {
175
+ "Access-Control-Allow-Origin": origin,
176
+ "Access-Control-Allow-Credentials": "true",
177
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
178
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
179
+ "Access-Control-Expose-Headers": "X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After",
180
+ Vary: "Origin",
181
+ };
182
+ }
183
+ function refusal(code, error, where, extra = {}, headers = {}) {
184
+ void emit(() => events.refuse?.({
185
+ ...where,
186
+ code,
187
+ status: CHAT_ERROR_STATUS[code],
188
+ ...(extra.reasonCode ? { reasonCode: extra.reasonCode } : {}),
189
+ }));
190
+ return refuse(code, error, extra, headers);
191
+ }
192
+ async function resolveAgent(turn) {
193
+ if (config.resolveAgent)
194
+ return config.resolveAgent(turn);
195
+ const shorthand = config.agent ?? {};
196
+ const tools = typeof shorthand.tools === "function"
197
+ ? await shorthand.tools(turn)
198
+ : shorthand.tools;
199
+ // Everything the shorthand carries beyond the three fields the
200
+ // transport decides for itself. Spread rather than enumerated:
201
+ // `ChatAgentConfig` is `Omit<ResolvedAgent, …>`, so a field added
202
+ // to the agent reaches the model without another line here, and a
203
+ // key the caller never set is not an own property and cannot
204
+ // overwrite a default with `undefined`.
205
+ const { id: _id, systemPrompt: _systemPrompt, tools: _tools, ...rest } = shorthand;
206
+ return {
207
+ ...rest,
208
+ // A conversation keeps the agent it was created with.
209
+ id: turn.conversation?.agentId ?? shorthand.id ?? DEFAULT_AGENT_ID,
210
+ systemPrompt: shorthand.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
211
+ ...(tools ? { tools } : {}),
212
+ };
213
+ }
214
+ /**
215
+ * The model the request asked for, if it may have it. Null when it
216
+ * asked for nothing (or `models` is unset, in which case a request
217
+ * cannot choose); `false` when it asked for one it may not have.
218
+ */
219
+ async function requestedModel(actor, body) {
220
+ if (!config.models)
221
+ return null;
222
+ const wanted = body.modelId;
223
+ if (typeof wanted !== "string" || !wanted)
224
+ return null;
225
+ const options = typeof config.models.options === "function"
226
+ ? await config.models.options(actor)
227
+ : config.models.options;
228
+ const option = options.find((candidate) => candidate.id === wanted);
229
+ if (!option)
230
+ return false;
231
+ if (option.featureKey) {
232
+ const allowed = await hasFeature(actor.workspaceId, option.featureKey);
233
+ if (!allowed)
234
+ return false;
235
+ }
236
+ return option;
237
+ }
238
+ async function prepare(turn, incoming) {
239
+ if (config.prepareMessages)
240
+ return config.prepareMessages(turn, incoming);
241
+ const windowing = config.windowing === undefined ? DEFAULT_WINDOW : config.windowing;
242
+ if (windowing === false)
243
+ return { messages: incoming };
244
+ return { messages: applyConversationWindow(incoming, windowing).windowed };
245
+ }
246
+ /**
247
+ * What the model sees of stored attachments: a signed URL in place
248
+ * of the app URL, and the extracted text of a document appended as
249
+ * text. The persisted transcript keeps the app URL — a signed URL
250
+ * expires, and would leak on a shared page.
251
+ */
252
+ async function resolveStoredFiles(actor, messages) {
253
+ if (attachments === false || attachments.mode !== "stored") {
254
+ return messages;
255
+ }
256
+ const ids = storedAttachmentIds(messages, attachments);
257
+ if (ids.length === 0)
258
+ return messages;
259
+ const rows = await getAttachments(actor, ids);
260
+ const byId = new Map(rows.map((row) => [row.id, row]));
261
+ const storage = getStorageAdapter();
262
+ const signed = new Map();
263
+ for (const row of rows) {
264
+ signed.set(row.id, await storage.getSignedUrl(row.storageKey, {
265
+ expiresInSeconds: MODEL_URL_SECONDS,
266
+ }));
267
+ }
268
+ return messages.map((message) => {
269
+ const parts = [];
270
+ const extracted = [];
271
+ for (const part of message.parts) {
272
+ if (part.type !== "file") {
273
+ parts.push(part);
274
+ continue;
275
+ }
276
+ const id = attachmentIdFromUrl(attachments, part.url);
277
+ const row = id ? byId.get(id) : undefined;
278
+ if (!id || !row) {
279
+ // Not this tenant's upload, or already gone: the model does
280
+ // not get a URL it could not have fetched anyway.
281
+ continue;
282
+ }
283
+ parts.push({
284
+ ...part,
285
+ mediaType: row.mediaType,
286
+ filename: row.filename,
287
+ url: signed.get(id),
288
+ });
289
+ if (row.extractedText && !row.mediaType.startsWith("image/")) {
290
+ extracted.push(`[Attachment: ${row.filename}]\n${row.extractedText}`);
291
+ }
292
+ }
293
+ if (extracted.length > 0) {
294
+ parts.push({ type: "text", text: extracted.join("\n\n") });
295
+ }
296
+ return { ...message, parts };
297
+ });
298
+ }
299
+ async function loadRow(actor, id) {
300
+ try {
301
+ return { ok: true, row: await getConversation(actor, id) };
302
+ }
303
+ catch (error) {
304
+ if (isConversationServiceError(error) && error.code === "not_found") {
305
+ return { ok: true, row: null };
306
+ }
307
+ return { ok: false, error };
308
+ }
309
+ }
310
+ /**
311
+ * An approval answer runs the tool it approves with the input it
312
+ * carries, so that input must be what the model proposed. With the
313
+ * transport's own persistence the stored assistant message is that
314
+ * proposal: a continuation whose approved call is missing from it, or
315
+ * carries other input, is refused. A deployment that persists
316
+ * elsewhere (`persist`) makes this check in its own `prepareMessages`.
317
+ */
318
+ async function approvalsMatchStored(actor, body) {
319
+ if (config.persist !== undefined)
320
+ return true;
321
+ const last = body.messages[body.messages.length - 1];
322
+ let stored;
323
+ try {
324
+ stored = toUIMessages(await getMessages(actor, body.id)).find((message) => message.id === last.id);
325
+ }
326
+ catch {
327
+ return false;
328
+ }
329
+ if (!stored)
330
+ return false;
331
+ const proposed = new Map();
332
+ for (const raw of stored.parts) {
333
+ const part = raw;
334
+ if (typeof part.toolCallId === "string") {
335
+ proposed.set(part.toolCallId, part);
336
+ }
337
+ }
338
+ return last.parts.every((raw) => {
339
+ const part = raw;
340
+ if (part.state !== "approval-responded")
341
+ return true;
342
+ const original = proposed.get(String(part.toolCallId));
343
+ return (original !== undefined &&
344
+ original.type === part.type &&
345
+ JSON.stringify(original.input) === JSON.stringify(part.input));
346
+ });
347
+ }
348
+ // POST: stream a reply.
349
+ async function POST(request) {
350
+ await config.onRequest?.();
351
+ const t = await messagesFor(request);
352
+ const cors = corsHeaders(request);
353
+ const nowhere = { actor: null, conversationId: null };
354
+ let json;
355
+ try {
356
+ json = await request.json();
357
+ }
358
+ catch {
359
+ return refusal("BAD_REQUEST", t("invalidBody"), nowhere, {}, cors);
360
+ }
361
+ const parsed = parseChatBody(json, { maxMessageLength, attachments });
362
+ if (!parsed.ok) {
363
+ const { key, params } = parsed.rejection;
364
+ return refusal("BAD_REQUEST", t(key, params), nowhere, {}, cors);
365
+ }
366
+ const body = parsed.body;
367
+ const where = { actor: null, conversationId: body.id };
368
+ let actor;
369
+ try {
370
+ actor = await authenticate(request);
371
+ }
372
+ catch {
373
+ return refusal("UNAUTHORIZED", t("unauthorized"), where, {}, cors);
374
+ }
375
+ where.actor = actor;
376
+ let limitHeaders = { ...cors };
377
+ if (rateLimit !== false) {
378
+ const decision = await rateLimit(actor);
379
+ limitHeaders = { ...cors, ...rateLimitHeaders(decision) };
380
+ if (!decision.allowed) {
381
+ const seconds = decision.retryAfterSeconds ?? 60;
382
+ return refusal("RATE_LIMITED", t("rateLimited", { seconds }), where, { retryAfterSeconds: seconds }, limitHeaders);
383
+ }
384
+ }
385
+ const loaded = await loadRow(actor, body.id);
386
+ if (!loaded.ok) {
387
+ // `forbidden` is another tenant's id. Answer as if it did not
388
+ // exist rather than confirm that it does.
389
+ if (isConversationServiceError(loaded.error) &&
390
+ loaded.error.code === "forbidden") {
391
+ return refusal("NOT_FOUND", t("notFound"), where, {}, cors);
392
+ }
393
+ log.error("Failed to load conversation", {
394
+ conversationId: body.id,
395
+ error: errorMessage(loaded.error),
396
+ });
397
+ return refusal("INTERNAL", t("internalError"), where, {}, cors);
398
+ }
399
+ // The writer exists only while the stream is open; a tool that
400
+ // writes outside that window is dropped rather than crashed.
401
+ let writerSlot = null;
402
+ // Tokens tools spent on their own model calls, settled with the run's.
403
+ let nestedUsage = null;
404
+ const context = {
405
+ ...actor,
406
+ request,
407
+ conversationId: body.id,
408
+ body: body.extra,
409
+ conversation: loaded.row,
410
+ trigger: body.trigger,
411
+ write: (chunk) => {
412
+ writerSlot?.write(chunk);
413
+ },
414
+ updateMetadata: async (patch) => {
415
+ await updateConversationMetadata(actor, body.id, patch);
416
+ },
417
+ addUsage: (usage) => {
418
+ nestedUsage = sumUsage(nestedUsage ?? {}, pickUsage(usage));
419
+ },
420
+ };
421
+ let agent;
422
+ try {
423
+ agent = await resolveAgent(context);
424
+ }
425
+ catch (error) {
426
+ log.error("resolveAgent failed", {
427
+ conversationId: body.id,
428
+ error: errorMessage(error),
429
+ });
430
+ await emit(() => events.fail?.({ turn: context, error, phase: "unhandled" }));
431
+ return refusal("INTERNAL", t("internalError"), where, {}, cors);
432
+ }
433
+ const featureKey = agent.featureKey === undefined
434
+ ? config.featureKey === undefined
435
+ ? DEFAULT_FEATURE_KEY
436
+ : config.featureKey
437
+ : agent.featureKey;
438
+ if (featureKey !== null) {
439
+ const allowed = await hasFeature(actor.workspaceId, featureKey);
440
+ if (!allowed) {
441
+ return refusal("FEATURE_GATED", t("featureGated"), where, {}, cors);
442
+ }
443
+ }
444
+ // The agent's model wins; then the request's, if it may have it;
445
+ // then the deployment's default.
446
+ const picked = await requestedModel(actor, body.extra);
447
+ if (picked === false) {
448
+ return refusal("FEATURE_GATED", t("featureGated"), where, { reasonCode: "model_not_allowed" }, cors);
449
+ }
450
+ const modelId = agent.modelId ?? picked?.id ?? config.model.defaultId;
451
+ const capability = agent.capability ?? config.capability ?? DEFAULT_CAPABILITY;
452
+ const maxSteps = agent.maxSteps ?? config.maxSteps ?? DEFAULT_MAX_STEPS;
453
+ // A title that resolves later is applied after the stream — a
454
+ // model-written title must not delay the first token.
455
+ let pendingTitle = null;
456
+ if (!loaded.row) {
457
+ const opening = body.messages.find((message) => message.role === "user");
458
+ const titled = deriveTitle(opening ? extractText(opening.parts) : "", context);
459
+ let title = null;
460
+ if (titled instanceof Promise)
461
+ pendingTitle = titled;
462
+ else
463
+ title = titled;
464
+ try {
465
+ context.conversation = await createConversation(actor, {
466
+ id: body.id,
467
+ agentId: agent.id,
468
+ modelId,
469
+ title,
470
+ });
471
+ }
472
+ catch (error) {
473
+ // Another actor's id: answer as if it did not exist.
474
+ if (isConversationServiceError(error) && error.code === "forbidden") {
475
+ return refusal("NOT_FOUND", t("notFound"), where, {}, cors);
476
+ }
477
+ log.error("Failed to create conversation", {
478
+ conversationId: body.id,
479
+ error: errorMessage(error),
480
+ });
481
+ return refusal("INTERNAL", t("internalError"), where, {}, cors);
482
+ }
483
+ }
484
+ // What the stored transcript loses to this turn — the reply being
485
+ // regenerated, the path an edit replaces. Run only once the turn is
486
+ // admitted: a refused regenerate must leave the old answer in place.
487
+ let trimAfter = null;
488
+ if (!loaded.row) {
489
+ // A new conversation has nothing to trim.
490
+ }
491
+ else if (body.trigger === "regenerate-message") {
492
+ // The client dropped the reply it is regenerating; drop what the
493
+ // row holds after the user message that gets a second answer.
494
+ trimAfter = lastUserMessage(body.messages)?.id ?? null;
495
+ }
496
+ else if (lastUserMessage(body.messages) && body.messages.length > 1) {
497
+ // An edit: the client cut the transcript and re-sent a message
498
+ // with a new id. Whatever the row holds after the message before
499
+ // it is the path being replaced. On an ordinary send the message
500
+ // before is the latest reply and nothing follows it, so this
501
+ // deletes nothing.
502
+ trimAfter = body.messages[body.messages.length - 2].id;
503
+ }
504
+ const answers = approvalAnswers(body.messages);
505
+ if (answers.length > 0 && !(await approvalsMatchStored(actor, body))) {
506
+ return refusal("BAD_REQUEST", t("invalidBody"), where, {}, cors);
507
+ }
508
+ const turn = {
509
+ ...context,
510
+ agent,
511
+ history: async () => toUIMessages(await getMessages(actor, body.id)),
512
+ };
513
+ // Approval answers ride on a continuation; the audit hook sees
514
+ // each once, before the tool they gate runs.
515
+ for (const answer of answers) {
516
+ await emit(() => events.approval?.({ turn: context, ...answer }));
517
+ }
518
+ let prepared;
519
+ try {
520
+ prepared = await prepare(turn, body.messages);
521
+ }
522
+ catch (error) {
523
+ log.error("prepareMessages failed", {
524
+ conversationId: body.id,
525
+ error: errorMessage(error),
526
+ });
527
+ await emit(() => events.fail?.({ turn, error, phase: "unhandled" }));
528
+ return refusal("INTERNAL", t("internalError"), where, {}, cors);
529
+ }
530
+ const metadata = {
531
+ conversationId: body.id,
532
+ agentId: agent.id,
533
+ ...(config.metadata ? config.metadata(turn) : {}),
534
+ };
535
+ // Entitlement, decided against the model that is about to run.
536
+ const run = await config.executions.begin({
537
+ workspaceId: actor.workspaceId,
538
+ userId: actor.userId,
539
+ capability,
540
+ model: modelId,
541
+ metadata,
542
+ });
543
+ if (!run.allowed) {
544
+ // 402 for anything the workspace can fix by paying; 503 for what
545
+ // only the deployment can fix — no billing configured, or a model
546
+ // id with no registered price.
547
+ if (run.code === "unknown_model") {
548
+ log.error("Model has no registered price", {
549
+ conversationId: body.id,
550
+ modelId,
551
+ reason: run.reason,
552
+ });
553
+ // The engine's reason names the registry and the id. That is
554
+ // the operator's to read in the log; the reader gets neutral
555
+ // copy, since nothing they can do changes the answer.
556
+ return refusal("MODEL_UNAVAILABLE", t("modelUnavailable"), where, { reasonCode: run.code }, limitHeaders);
557
+ }
558
+ const notConfigured = run.code === "billing_not_configured";
559
+ if (notConfigured) {
560
+ log.error("Billing is not configured", {
561
+ conversationId: body.id,
562
+ reason: run.reason,
563
+ });
564
+ }
565
+ // The engine's `reason` is English and for the log; the reader
566
+ // gets the deployment's copy, and `reasonCode` says which case.
567
+ return refusal(notConfigured ? "BILLING_NOT_CONFIGURED" : "QUOTA_EXCEEDED", t(notConfigured ? "billingNotConfigured" : "quotaExceeded"), where, { ...(run.code ? { reasonCode: run.code } : {}) }, limitHeaders);
568
+ }
569
+ if (trimAfter) {
570
+ try {
571
+ await deleteTrailingMessages(actor, { id: trimAfter });
572
+ }
573
+ catch (error) {
574
+ // The message may never have been persisted (a failed first
575
+ // attempt). The turn still makes sense.
576
+ log.warn("Could not trim messages before the turn", {
577
+ conversationId: body.id,
578
+ error: errorMessage(error),
579
+ });
580
+ }
581
+ }
582
+ const tools = agent.tools && Object.keys(agent.tools).length > 0
583
+ ? agent.tools
584
+ : undefined;
585
+ const stopWhen = [
586
+ stepCountIs(maxSteps),
587
+ ...(Array.isArray(agent.stopWhen)
588
+ ? agent.stopWhen
589
+ : agent.stopWhen
590
+ ? [agent.stopWhen]
591
+ : []),
592
+ ];
593
+ await emit(() => events.start?.({
594
+ turn,
595
+ executionId: run.id,
596
+ requestId: run.requestId,
597
+ modelId,
598
+ }));
599
+ // Whole-run usage, captured from the run's own finish (which fires
600
+ // when the model run ends, before the UI stream drains) and read
601
+ // back in the outer `onFinish`. `totalUsage`, not `usage`: with
602
+ // tools bound, `usage` is the LAST step only and a five-step turn
603
+ // would be billed for one.
604
+ let captured;
605
+ const settle = async (usage, detail) => {
606
+ const whole = nestedUsage ? sumUsage(usage, nestedUsage) : usage;
607
+ const normalized = config.normalizeUsage
608
+ ? config.normalizeUsage(whole)
609
+ : whole;
610
+ // Settling can throw (unrecorded usage must not be reported as
611
+ // success). Log and continue so a settlement failure does not
612
+ // also cost the user their message history.
613
+ try {
614
+ await run.complete({
615
+ usage: normalized,
616
+ // The model admission priced. A runtime may report a
617
+ // provider-resolved id the registry has no price for.
618
+ model: modelId,
619
+ metadata: { ...metadata, aborted: detail.aborted },
620
+ });
621
+ }
622
+ catch (error) {
623
+ log.error("Execution settlement failed", {
624
+ conversationId: body.id,
625
+ executionId: run.id,
626
+ requestId: run.requestId,
627
+ error: errorMessage(error),
628
+ });
629
+ await emit(() => events.fail?.({ turn, error, phase: "settlement" }));
630
+ return;
631
+ }
632
+ await emit(() => events.complete?.({
633
+ turn,
634
+ executionId: run.id,
635
+ modelId: captured?.modelId ?? modelId,
636
+ usage: normalized,
637
+ aborted: detail.aborted,
638
+ finishReason: captured?.finishReason,
639
+ rawFinishReason: captured?.rawFinishReason,
640
+ providerMetadata: captured?.providerMetadata,
641
+ warnings: captured?.warnings,
642
+ }));
643
+ };
644
+ const applyTitle = async () => {
645
+ if (!pendingTitle)
646
+ return null;
647
+ try {
648
+ const title = await pendingTitle;
649
+ if (!title)
650
+ return null;
651
+ // Only while still untitled: two first turns racing must not
652
+ // overwrite each other's title.
653
+ const current = await getConversation(actor, body.id);
654
+ if (current.title !== null)
655
+ return current.title;
656
+ await renameConversation(actor, body.id, title);
657
+ return title;
658
+ }
659
+ catch (error) {
660
+ log.warn("Deferred title failed", {
661
+ conversationId: body.id,
662
+ error: errorMessage(error),
663
+ });
664
+ await emit(() => events.fail?.({ turn, error, phase: "title" }));
665
+ return null;
666
+ }
667
+ };
668
+ const finishMetadata = (usage) => ({
669
+ modelId: captured?.modelId ?? modelId,
670
+ usage: pickUsage(usage),
671
+ finishedAt: new Date().toISOString(),
672
+ });
673
+ const stream = createUIMessageStream({
674
+ // Without this, `onFinish` sees only the reply and a tool-approval
675
+ // continuation loses its message id. With it, the finished
676
+ // transcript is the prepared messages plus the reply.
677
+ originalMessages: prepared.messages,
678
+ execute: async ({ writer }) => {
679
+ writerSlot = writer;
680
+ const modelMessages = await resolveStoredFiles(actor, prepared.messages);
681
+ if (config.streamTurn) {
682
+ // A runtime the consumer bound. It gets the whole prepared
683
+ // turn and gives back UI chunks; the frames are ours.
684
+ writer.write({ type: "start" });
685
+ const produced = await config.streamTurn(turn, prepared, {
686
+ modelId,
687
+ abortSignal: request.signal,
688
+ writer,
689
+ });
690
+ writer.merge(withoutFrames(produced.stream));
691
+ const usage = await produced.usage;
692
+ captured = {
693
+ usage: pickUsage(usage),
694
+ ...(usage.modelId ? { modelId: usage.modelId } : {}),
695
+ ...(usage.finishReason ? { finishReason: usage.finishReason } : {}),
696
+ };
697
+ await settle(captured.usage, { aborted: request.signal.aborted });
698
+ const title = await applyTitle();
699
+ if (title) {
700
+ writer.write({
701
+ type: "data-chat-title",
702
+ data: title,
703
+ transient: true,
704
+ });
705
+ }
706
+ writer.write({
707
+ type: "finish",
708
+ ...(withMetadata
709
+ ? { messageMetadata: finishMetadata(captured.usage) }
710
+ : {}),
711
+ });
712
+ return;
713
+ }
714
+ const model = await config.model.resolve(modelId, context);
715
+ const outputCeiling = getModelPricing(modelId)?.maxOutputTokens;
716
+ const result = streamText({
717
+ // Admission held the registered model's `maxOutputTokens`;
718
+ // the same number caps what a step may write unless the
719
+ // agent's own setting, spread next, replaces it.
720
+ ...(outputCeiling !== undefined
721
+ ? { maxOutputTokens: outputCeiling }
722
+ : {}),
723
+ // The agent's sampling settings, copied by name from the
724
+ // allowlist. First in the literal on purpose: every key the
725
+ // transport sets below is written after it and wins, so a
726
+ // consumer cannot displace the abort signal, the finish
727
+ // handler or the model admission priced.
728
+ ...pickGenerationOptions(agent.generation),
729
+ model,
730
+ system: prepared.system ?? agent.systemPrompt,
731
+ ...(agent.providerOptions
732
+ ? { providerOptions: agent.providerOptions }
733
+ : {}),
734
+ messages: await convertToModelMessages(modelMessages, {
735
+ ...(tools ? { tools } : {}),
736
+ }),
737
+ ...(tools
738
+ ? {
739
+ tools,
740
+ stopWhen,
741
+ // `activeTools`, not `experimental_activeTools`: the
742
+ // prefixed key lands in `streamText`'s rest parameter,
743
+ // accepted by the compiler inside a conditional spread
744
+ // and ignored at runtime, so the agent would run with
745
+ // every tool.
746
+ ...(agent.activeTools
747
+ ? { activeTools: agent.activeTools }
748
+ : {}),
749
+ }
750
+ : {}),
751
+ // Stop the model when the client goes away. Without this the
752
+ // run continues server-side to completion — billed in full
753
+ // for a reply nobody receives — and `onAbort` never fires.
754
+ abortSignal: request.signal,
755
+ onFinish: async ({ totalUsage, finishReason, rawFinishReason, providerMetadata, warnings, }) => {
756
+ captured = {
757
+ usage: pickUsage(totalUsage),
758
+ finishReason,
759
+ rawFinishReason,
760
+ providerMetadata,
761
+ warnings: warnings,
762
+ };
763
+ // Settled here, where the usage is final, rather than when
764
+ // the UI stream closes: a client that disconnects closes it
765
+ // first, and the run must still be charged.
766
+ await settle(captured.usage, { aborted: false });
767
+ },
768
+ onAbort: async ({ steps }) => {
769
+ await settle(sumStepUsage(steps), { aborted: true });
770
+ },
771
+ });
772
+ writer.merge(result.toUIMessageStream({
773
+ sendReasoning: config.reasoning ?? false,
774
+ sendSources: config.sources ?? false,
775
+ ...(withMetadata
776
+ ? {
777
+ messageMetadata: ({ part }) => part.type === "finish"
778
+ ? finishMetadata(part.totalUsage)
779
+ : undefined,
780
+ }
781
+ : {}),
782
+ }));
783
+ const title = await applyTitle();
784
+ if (title) {
785
+ writer.write({
786
+ type: "data-chat-title",
787
+ data: title,
788
+ transient: true,
789
+ });
790
+ }
791
+ },
792
+ generateId,
793
+ onFinish: async ({ responseMessage, isContinuation }) => {
794
+ writerSlot = null;
795
+ // The run settles itself where its usage becomes known (above).
796
+ // A stream that closed with no usage and no disconnect never
797
+ // will: failing releases the hold and leaves a `failed` row an
798
+ // operator can see. After a disconnect the run is still ending —
799
+ // `onAbort` or the runtime's usage settles it, and `reconcile()`
800
+ // abandons it if neither does.
801
+ if (!captured && !request.signal.aborted) {
802
+ void run.fail({ error: new Error("stream ended without usage") });
803
+ }
804
+ if (config.persist === false)
805
+ return;
806
+ const userMessage = lastUserMessage(body.messages);
807
+ try {
808
+ if (config.persist) {
809
+ await config.persist(turn, {
810
+ userMessage,
811
+ responseMessage,
812
+ isContinuation,
813
+ });
814
+ }
815
+ else {
816
+ await upsertMessages(body.id, [
817
+ ...(userMessage && !isContinuation ? [userMessage] : []),
818
+ responseMessage,
819
+ ]);
820
+ }
821
+ if (userMessage &&
822
+ attachments !== false &&
823
+ attachments.mode === "stored") {
824
+ const ids = storedAttachmentIds([userMessage], attachments);
825
+ if (ids.length > 0) {
826
+ await attachToConversation(actor, {
827
+ ids,
828
+ conversationId: body.id,
829
+ });
830
+ }
831
+ }
832
+ }
833
+ catch (error) {
834
+ log.error("Message persistence failed", {
835
+ conversationId: body.id,
836
+ error: errorMessage(error),
837
+ });
838
+ await emit(() => events.fail?.({ turn, error, phase: "persistence" }));
839
+ }
840
+ },
841
+ onError: (error) => {
842
+ // fail() is a no-op if complete() already won, so a late error
843
+ // after a settled stream cannot corrupt the row. The AI SDK
844
+ // does not await this callback, so nothing here is.
845
+ writerSlot = null;
846
+ void run.fail({ error });
847
+ void emit(() => events.fail?.({ turn, error, phase: "stream" }));
848
+ return t("streamError");
849
+ },
850
+ });
851
+ return createUIMessageStreamResponse({ stream, headers: limitHeaders });
852
+ }
853
+ // DELETE: remove a conversation.
854
+ async function DELETE(request) {
855
+ await config.onRequest?.();
856
+ const t = await messagesFor(request);
857
+ const cors = corsHeaders(request);
858
+ let id = new URL(request.url).searchParams.get("id");
859
+ if (!id) {
860
+ try {
861
+ const json = (await request.json());
862
+ if (typeof json.id === "string")
863
+ id = json.id;
864
+ }
865
+ catch {
866
+ // No body: the query string was the only place to look.
867
+ }
868
+ }
869
+ if (!id) {
870
+ return refusal("BAD_REQUEST", t("invalidBody"), { actor: null, conversationId: null }, {}, cors);
871
+ }
872
+ let actor;
873
+ try {
874
+ actor = await authenticate(request);
875
+ }
876
+ catch {
877
+ return refusal("UNAUTHORIZED", t("unauthorized"), { actor: null, conversationId: id }, {}, cors);
878
+ }
879
+ try {
880
+ await deleteConversation(actor, id);
881
+ }
882
+ catch (error) {
883
+ if (isConversationServiceError(error) &&
884
+ (error.code === "not_found" || error.code === "forbidden")) {
885
+ return refusal("NOT_FOUND", t("notFound"), { actor, conversationId: id }, {}, cors);
886
+ }
887
+ log.error("Failed to delete conversation", {
888
+ conversationId: id,
889
+ error: errorMessage(error),
890
+ });
891
+ return refusal("INTERNAL", t("internalError"), { actor, conversationId: id }, {}, cors);
892
+ }
893
+ return Response.json({ success: true }, { headers: cors });
894
+ }
895
+ // GET: stream resumption. OPTIONS: CORS preflight.
896
+ /**
897
+ * `useChat().resumeStream()` asks here whether a turn is still in
898
+ * flight. `streamText` runs are not durable, so the honest answer is
899
+ * always "nothing to resume" — 204, which the SDK treats as such.
900
+ */
901
+ async function GET(request) {
902
+ return new Response(null, { status: 204, headers: corsHeaders(request) });
903
+ }
904
+ async function OPTIONS(request) {
905
+ const cors = corsHeaders(request);
906
+ return new Response(null, {
907
+ status: 204,
908
+ headers: { ...cors, "Access-Control-Max-Age": "600" },
909
+ });
910
+ }
911
+ return { POST, DELETE, GET, OPTIONS };
912
+ }
913
+ //# sourceMappingURL=handler.js.map