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