@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.
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +116 -0
- package/dist/artifact-writer.d.ts +45 -0
- package/dist/artifact-writer.d.ts.map +1 -0
- package/dist/artifact-writer.js +77 -0
- package/dist/artifact-writer.js.map +1 -0
- package/dist/attachments.d.ts +56 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +204 -0
- package/dist/attachments.js.map +1 -0
- package/dist/body.d.ts +72 -0
- package/dist/body.d.ts.map +1 -0
- package/dist/body.js +174 -0
- package/dist/body.js.map +1 -0
- package/dist/client.d.ts +65 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +61 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +322 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/feedback.d.ts +22 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +46 -0
- package/dist/feedback.js.map +1 -0
- package/dist/generation.d.ts +104 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +85 -0
- package/dist/generation.js.map +1 -0
- package/dist/handler.d.ts +30 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +913 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/messages.d.ts +19 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +34 -0
- package/dist/messages.js.map +1 -0
- package/dist/parts.d.ts +117 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +13 -0
- package/dist/parts.js.map +1 -0
- package/dist/quota.d.ts +32 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +81 -0
- package/dist/quota.js.map +1 -0
- package/dist/share.d.ts +24 -0
- package/dist/share.d.ts.map +1 -0
- package/dist/share.js +78 -0
- package/dist/share.js.map +1 -0
- package/dist/testing.d.ts +36 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +82 -0
- package/dist/testing.js.map +1 -0
- package/dist/title.d.ts +7 -0
- package/dist/title.d.ts.map +1 -0
- package/dist/title.js +15 -0
- package/dist/title.js.map +1 -0
- package/dist/usage.d.ts +21 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +35 -0
- package/dist/usage.js.map +1 -0
- package/dist/windowing.d.ts +38 -0
- package/dist/windowing.d.ts.map +1 -0
- package/dist/windowing.js +82 -0
- package/dist/windowing.js.map +1 -0
- package/package.json +78 -0
- package/src/artifact-writer.ts +114 -0
- package/src/attachments.ts +262 -0
- package/src/body.ts +236 -0
- package/src/client.ts +133 -0
- package/src/config.ts +376 -0
- package/src/errors.ts +80 -0
- package/src/feedback.ts +62 -0
- package/src/generation.ts +150 -0
- package/src/handler.ts +1164 -0
- package/src/index.ts +93 -0
- package/src/messages.ts +39 -0
- package/src/parts.ts +143 -0
- package/src/quota.ts +105 -0
- package/src/share.ts +103 -0
- package/src/testing.ts +150 -0
- package/src/title.ts +15 -0
- package/src/usage.ts +46 -0
- 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
|
+
}
|