@intelligo-dev/chat 1.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.md +116 -0
  4. package/dist/artifact-writer.d.ts +45 -0
  5. package/dist/artifact-writer.d.ts.map +1 -0
  6. package/dist/artifact-writer.js +77 -0
  7. package/dist/artifact-writer.js.map +1 -0
  8. package/dist/attachments.d.ts +56 -0
  9. package/dist/attachments.d.ts.map +1 -0
  10. package/dist/attachments.js +204 -0
  11. package/dist/attachments.js.map +1 -0
  12. package/dist/body.d.ts +72 -0
  13. package/dist/body.d.ts.map +1 -0
  14. package/dist/body.js +174 -0
  15. package/dist/body.js.map +1 -0
  16. package/dist/client.d.ts +65 -0
  17. package/dist/client.d.ts.map +1 -0
  18. package/dist/client.js +61 -0
  19. package/dist/client.js.map +1 -0
  20. package/dist/config.d.ts +322 -0
  21. package/dist/config.d.ts.map +1 -0
  22. package/dist/config.js +11 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/errors.d.ts +23 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +41 -0
  27. package/dist/errors.js.map +1 -0
  28. package/dist/feedback.d.ts +22 -0
  29. package/dist/feedback.d.ts.map +1 -0
  30. package/dist/feedback.js +46 -0
  31. package/dist/feedback.js.map +1 -0
  32. package/dist/generation.d.ts +104 -0
  33. package/dist/generation.d.ts.map +1 -0
  34. package/dist/generation.js +85 -0
  35. package/dist/generation.js.map +1 -0
  36. package/dist/handler.d.ts +30 -0
  37. package/dist/handler.d.ts.map +1 -0
  38. package/dist/handler.js +913 -0
  39. package/dist/handler.js.map +1 -0
  40. package/dist/index.d.ts +31 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +20 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/messages.d.ts +19 -0
  45. package/dist/messages.d.ts.map +1 -0
  46. package/dist/messages.js +34 -0
  47. package/dist/messages.js.map +1 -0
  48. package/dist/parts.d.ts +117 -0
  49. package/dist/parts.d.ts.map +1 -0
  50. package/dist/parts.js +13 -0
  51. package/dist/parts.js.map +1 -0
  52. package/dist/quota.d.ts +32 -0
  53. package/dist/quota.d.ts.map +1 -0
  54. package/dist/quota.js +81 -0
  55. package/dist/quota.js.map +1 -0
  56. package/dist/share.d.ts +24 -0
  57. package/dist/share.d.ts.map +1 -0
  58. package/dist/share.js +78 -0
  59. package/dist/share.js.map +1 -0
  60. package/dist/testing.d.ts +36 -0
  61. package/dist/testing.d.ts.map +1 -0
  62. package/dist/testing.js +82 -0
  63. package/dist/testing.js.map +1 -0
  64. package/dist/title.d.ts +7 -0
  65. package/dist/title.d.ts.map +1 -0
  66. package/dist/title.js +15 -0
  67. package/dist/title.js.map +1 -0
  68. package/dist/usage.d.ts +21 -0
  69. package/dist/usage.d.ts.map +1 -0
  70. package/dist/usage.js +35 -0
  71. package/dist/usage.js.map +1 -0
  72. package/dist/windowing.d.ts +38 -0
  73. package/dist/windowing.d.ts.map +1 -0
  74. package/dist/windowing.js +82 -0
  75. package/dist/windowing.js.map +1 -0
  76. package/package.json +78 -0
  77. package/src/artifact-writer.ts +114 -0
  78. package/src/attachments.ts +262 -0
  79. package/src/body.ts +236 -0
  80. package/src/client.ts +133 -0
  81. package/src/config.ts +376 -0
  82. package/src/errors.ts +80 -0
  83. package/src/feedback.ts +62 -0
  84. package/src/generation.ts +150 -0
  85. package/src/handler.ts +1164 -0
  86. package/src/index.ts +93 -0
  87. package/src/messages.ts +39 -0
  88. package/src/parts.ts +143 -0
  89. package/src/quota.ts +105 -0
  90. package/src/share.ts +103 -0
  91. package/src/testing.ts +150 -0
  92. package/src/title.ts +15 -0
  93. package/src/usage.ts +46 -0
  94. package/src/windowing.ts +110 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Stored attachments: the upload route and the route that serves them.
3
+ *
4
+ * Inline attachments (the default policy) travel inside the message as
5
+ * data URLs and need nothing here. A deployment that wants files
6
+ * outside the transcript — larger than a message should carry, kept
7
+ * after the conversation is shared, extracted to text for the model —
8
+ * sets `attachments.mode: "stored"`, binds a storage adapter from its
9
+ * composition root, and mounts these two handlers:
10
+ *
11
+ * // app/api/chat/upload/route.ts
12
+ * export const { POST } = createChatUploadHandler(chatServerConfig);
13
+ * // app/api/chat/attachments/[id]/route.ts
14
+ * export const { GET } = createChatAttachmentHandler(chatServerConfig);
15
+ *
16
+ * The upload answers `{ id, url, filename, mediaType, size }`; the
17
+ * composer puts `url` in the file part; the transport signs it for the
18
+ * model and persists the app URL, which this route redirects to a
19
+ * fresh signed URL for anyone in the workspace who can open the
20
+ * conversation. No signed URL is ever persisted or shared.
21
+ */
22
+
23
+ import { requireWorkspace } from "@intelligo-dev/auth";
24
+ import {
25
+ createAttachment,
26
+ getAttachment,
27
+ isAttachmentServiceError,
28
+ setExtractedText,
29
+ } from "@intelligo-dev/core/attachments";
30
+ import { createLogger } from "@intelligo-dev/core/logger";
31
+ import {
32
+ attachmentStorageKey,
33
+ getStorageAdapter,
34
+ StorageUnavailableError,
35
+ } from "@intelligo-dev/core/storage";
36
+
37
+ import { ATTACHMENT_MAX_BYTES, attachmentUrl } from "./body";
38
+
39
+ /** Room for the multipart boundaries and headers around the file. */
40
+ const FORM_OVERHEAD_BYTES = 64 * 1024;
41
+ import type { ChatActor, ChatServerConfig } from "./config";
42
+ import { DEFAULT_CHAT_MESSAGES, refuse } from "./errors";
43
+
44
+ const log = createLogger("Chat");
45
+
46
+ const SIGNED_URL_SECONDS = 900;
47
+
48
+ export type ChatUploadResult = {
49
+ id: string;
50
+ url: string;
51
+ filename: string;
52
+ mediaType: string;
53
+ size: number;
54
+ };
55
+
56
+ export type ChatUploadHandler = {
57
+ POST: (request: Request) => Promise<Response>;
58
+ };
59
+
60
+ export type ChatAttachmentHandler = {
61
+ GET: (
62
+ request: Request,
63
+ context: { params: { id: string } | Promise<{ id: string }> }
64
+ ) => Promise<Response>;
65
+ };
66
+
67
+ async function defaultAuthenticate(): Promise<ChatActor> {
68
+ const { workspace, user } = await requireWorkspace();
69
+ return { workspaceId: workspace.id, userId: user.id };
70
+ }
71
+
72
+ function safeFilename(name: string): string {
73
+ const base = name.split(/[\\/]/).pop() ?? "";
74
+ return base.replace(/[\x00-\x1f]/g, "").slice(0, 255) || "file";
75
+ }
76
+
77
+ function errorMessage(error: unknown): string {
78
+ return error instanceof Error ? error.message : String(error);
79
+ }
80
+
81
+ /**
82
+ * `POST multipart/form-data` with one `file` field → an attachment row
83
+ * and the URL the composer puts in the file part. Refuses a type the
84
+ * policy does not accept, a file over `maxBytes`, and anything without
85
+ * a session.
86
+ */
87
+ export function createChatUploadHandler(
88
+ config: ChatServerConfig
89
+ ): ChatUploadHandler {
90
+ const authenticate = config.authenticate ?? defaultAuthenticate;
91
+ const policy = config.attachments;
92
+
93
+ async function messagesFor(request: Request) {
94
+ return config.messages ? config.messages(request) : DEFAULT_CHAT_MESSAGES;
95
+ }
96
+
97
+ async function POST(request: Request): Promise<Response> {
98
+ await config.onRequest?.();
99
+ const t = await messagesFor(request);
100
+
101
+ if (!policy || policy.mode !== "stored") {
102
+ return refuse("BAD_REQUEST", t("attachmentRejected"));
103
+ }
104
+
105
+ let actor: ChatActor;
106
+ try {
107
+ actor = await authenticate(request);
108
+ } catch {
109
+ return refuse("UNAUTHORIZED", t("unauthorized"));
110
+ }
111
+
112
+ // Refused before the body is read: `formData()` buffers all of it.
113
+ const maxBytes = policy.maxBytes ?? ATTACHMENT_MAX_BYTES;
114
+ const declared = Number(request.headers.get("content-length"));
115
+ if (
116
+ Number.isFinite(declared) &&
117
+ declared > maxBytes + FORM_OVERHEAD_BYTES
118
+ ) {
119
+ return refuse("BAD_REQUEST", t("attachmentRejected"));
120
+ }
121
+
122
+ let form: FormData;
123
+ try {
124
+ form = await request.formData();
125
+ } catch {
126
+ return refuse("BAD_REQUEST", t("invalidBody"));
127
+ }
128
+ const file = form.get("file");
129
+ if (!(file instanceof Blob)) {
130
+ return refuse("BAD_REQUEST", t("invalidBody"));
131
+ }
132
+ const mediaType = file.type || "application/octet-stream";
133
+ if (!policy.accept.includes(mediaType)) {
134
+ return refuse("BAD_REQUEST", t("attachmentRejected"));
135
+ }
136
+ if (file.size > maxBytes) {
137
+ return refuse("BAD_REQUEST", t("attachmentRejected"));
138
+ }
139
+
140
+ let storage;
141
+ try {
142
+ storage = getStorageAdapter();
143
+ } catch (error) {
144
+ if (error instanceof StorageUnavailableError) {
145
+ log.error("Attachment upload with no storage adapter bound");
146
+ return refuse("INTERNAL", t("internalError"));
147
+ }
148
+ throw error;
149
+ }
150
+
151
+ const id = crypto.randomUUID();
152
+ const filename = safeFilename(
153
+ typeof (file as File).name === "string" ? (file as File).name : "file"
154
+ );
155
+ const key = attachmentStorageKey(actor.workspaceId, id);
156
+
157
+ try {
158
+ await storage.put({
159
+ key,
160
+ body: file,
161
+ contentType: mediaType,
162
+ contentLength: file.size,
163
+ });
164
+ await createAttachment(actor, {
165
+ id,
166
+ storageKey: key,
167
+ filename,
168
+ mediaType,
169
+ sizeBytes: file.size,
170
+ });
171
+ } catch (error) {
172
+ log.error("Attachment upload failed", { error: errorMessage(error) });
173
+ return refuse("INTERNAL", t("internalError"));
174
+ }
175
+
176
+ // Text extraction is best effort: a PDF that will not parse still
177
+ // uploads, and the model gets the file part alone.
178
+ if (policy.extractText && !mediaType.startsWith("image/")) {
179
+ try {
180
+ const text = await policy.extractText({
181
+ id,
182
+ filename,
183
+ mediaType,
184
+ bytes: async () => new Uint8Array(await file.arrayBuffer()),
185
+ });
186
+ if (text) await setExtractedText(actor, { id, text });
187
+ } catch (error) {
188
+ log.warn("Attachment text extraction failed", {
189
+ attachmentId: id,
190
+ error: errorMessage(error),
191
+ });
192
+ }
193
+ }
194
+
195
+ const result: ChatUploadResult = {
196
+ id,
197
+ url: attachmentUrl(policy, id),
198
+ filename,
199
+ mediaType,
200
+ size: file.size,
201
+ };
202
+ return Response.json(result, { status: 201 });
203
+ }
204
+
205
+ return { POST };
206
+ }
207
+
208
+ /**
209
+ * `GET /api/chat/attachments/[id]` → a redirect to a short-lived signed
210
+ * URL, for anyone signed in to the attachment's workspace. The app URL
211
+ * is what the transcript persists, so it must keep working after the
212
+ * signed one expires.
213
+ */
214
+ export function createChatAttachmentHandler(
215
+ config: ChatServerConfig
216
+ ): ChatAttachmentHandler {
217
+ const authenticate = config.authenticate ?? defaultAuthenticate;
218
+
219
+ async function messagesFor(request: Request) {
220
+ return config.messages ? config.messages(request) : DEFAULT_CHAT_MESSAGES;
221
+ }
222
+
223
+ async function GET(
224
+ request: Request,
225
+ context: { params: { id: string } | Promise<{ id: string }> }
226
+ ): Promise<Response> {
227
+ await config.onRequest?.();
228
+ const t = await messagesFor(request);
229
+ const { id } = await context.params;
230
+
231
+ let actor: ChatActor;
232
+ try {
233
+ actor = await authenticate(request);
234
+ } catch {
235
+ return refuse("UNAUTHORIZED", t("unauthorized"));
236
+ }
237
+
238
+ try {
239
+ const row = await getAttachment(actor, id);
240
+ const url = await getStorageAdapter().getSignedUrl(row.storageKey, {
241
+ expiresInSeconds: SIGNED_URL_SECONDS,
242
+ disposition: "inline",
243
+ filename: row.filename,
244
+ });
245
+ return new Response(null, {
246
+ status: 302,
247
+ headers: { location: url, "cache-control": "private, no-store" },
248
+ });
249
+ } catch (error) {
250
+ if (isAttachmentServiceError(error) && error.code === "not_found") {
251
+ return refuse("NOT_FOUND", t("notFound"));
252
+ }
253
+ log.error("Attachment read failed", {
254
+ attachmentId: id,
255
+ error: errorMessage(error),
256
+ });
257
+ return refuse("INTERNAL", t("internalError"));
258
+ }
259
+ }
260
+
261
+ return { GET };
262
+ }
package/src/body.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The request body, validated by hand.
3
+ *
4
+ * Not zod: a schema library in a package's public surface would have
5
+ * to be a peer (its `instanceof` fails across copies), and the body
6
+ * has four fields. The shape is the AI SDK's `DefaultChatTransport`
7
+ * one — `id`, `messages`, `trigger`, `messageId` — plus whatever the
8
+ * application's transport added, which is handed back untouched as
9
+ * `extra` for `resolveAgent` to read (an `agentId`, a model choice).
10
+ */
11
+
12
+ import type { UIMessage } from "ai";
13
+
14
+ import { extractText } from "./windowing";
15
+
16
+ export type ChatAttachmentPolicy = {
17
+ /** Media types a file part may carry, e.g. `["image/jpeg", "image/png"]`. */
18
+ accept: readonly string[];
19
+ /** Largest payload accepted, in bytes. `ATTACHMENT_MAX_BYTES` when omitted. */
20
+ maxBytes?: number;
21
+ /**
22
+ * `inline` (default): the file travels in the message as a data URL
23
+ * and is persisted with it. `stored`: the file was uploaded first
24
+ * through the upload route and the part carries the app URL the
25
+ * attachment route serves; the transport signs it for the model.
26
+ */
27
+ mode?: "inline" | "stored";
28
+ /** The app URL of a stored attachment. Default `/api/chat/attachments/<id>`. */
29
+ urlFor?: (id: string) => string;
30
+ /**
31
+ * Turns a stored, non-image file into text for the model — a PDF, a
32
+ * spreadsheet. Runs once, at upload; the text is kept on the row and
33
+ * appended to the message the model sees. Unset: the file part is
34
+ * passed through as-is and the provider decides what to do with it.
35
+ */
36
+ extractText?: (file: {
37
+ id: string;
38
+ filename: string;
39
+ mediaType: string;
40
+ bytes: () => Promise<Uint8Array>;
41
+ }) => Promise<string | null>;
42
+ };
43
+
44
+ export type ChatBody = {
45
+ id: string;
46
+ messages: UIMessage[];
47
+ trigger: "submit-message" | "regenerate-message" | undefined;
48
+ messageId: string | undefined;
49
+ /** Fields the application's transport added beyond the SDK's own. */
50
+ extra: Record<string, unknown>;
51
+ };
52
+
53
+ export type ChatBodyRejection = {
54
+ key: "invalidBody" | "messageTooLong" | "attachmentRejected";
55
+ params?: Record<string, string | number>;
56
+ };
57
+
58
+ export type ParsedChatBody =
59
+ { ok: true; body: ChatBody } | { ok: false; rejection: ChatBodyRejection };
60
+
61
+ /** 10 MB: the limit a policy that names none gets. */
62
+ export const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
63
+
64
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
65
+ /**
66
+ * No `system`: the system prompt is the server's. A client that could
67
+ * send one could rewrite the agent's instructions.
68
+ */
69
+ const ROLES = new Set(["user", "assistant"]);
70
+ const SDK_FIELDS = new Set(["id", "messages", "trigger", "messageId"]);
71
+ const ID_PLACEHOLDER = "__ATTACHMENT_ID__";
72
+
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ }
76
+
77
+ function isUIMessage(value: unknown): value is UIMessage {
78
+ if (!isRecord(value)) return false;
79
+ if (typeof value.id !== "string" || !value.id) return false;
80
+ if (typeof value.role !== "string" || !ROLES.has(value.role)) return false;
81
+ if (!Array.isArray(value.parts)) return false;
82
+ return value.parts.every(
83
+ (part) => isRecord(part) && typeof part.type === "string"
84
+ );
85
+ }
86
+
87
+ function dataUrlBytes(url: string): number | null {
88
+ if (!url.startsWith("data:")) return null;
89
+ const comma = url.indexOf(",");
90
+ if (comma === -1) return null;
91
+ const payload = url.slice(comma + 1);
92
+ return url.slice(0, comma).endsWith(";base64")
93
+ ? Math.floor((payload.length * 3) / 4)
94
+ : payload.length;
95
+ }
96
+
97
+ /** The app URL of a stored attachment under this policy. */
98
+ export function attachmentUrl(
99
+ policy: Pick<ChatAttachmentPolicy, "urlFor">,
100
+ id: string
101
+ ): string {
102
+ return policy.urlFor ? policy.urlFor(id) : `/api/chat/attachments/${id}`;
103
+ }
104
+
105
+ /**
106
+ * The attachment id a stored file part's URL names, or null when the
107
+ * URL is not one this policy hands out. Absolute and relative forms
108
+ * of the same path both match.
109
+ */
110
+ export function attachmentIdFromUrl(
111
+ policy: Pick<ChatAttachmentPolicy, "urlFor">,
112
+ url: string
113
+ ): string | null {
114
+ const template = attachmentUrl(policy, ID_PLACEHOLDER);
115
+ const at = template.indexOf(ID_PLACEHOLDER);
116
+ if (at === -1) return null;
117
+ const prefix = template.slice(0, at);
118
+ const suffix = template.slice(at + ID_PLACEHOLDER.length);
119
+
120
+ let path = url;
121
+ if (/^https?:\/\//i.test(url)) {
122
+ try {
123
+ const parsed = new URL(url);
124
+ path = parsed.pathname + parsed.search;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+ if (!path.startsWith(prefix) || !path.endsWith(suffix)) return null;
130
+ const id = path.slice(prefix.length, path.length - suffix.length);
131
+ return id && !id.includes("/") ? id : null;
132
+ }
133
+
134
+ function rejectedAttachment(
135
+ message: UIMessage,
136
+ policy: ChatAttachmentPolicy | false
137
+ ): boolean {
138
+ for (const part of message.parts) {
139
+ if (part.type !== "file") continue;
140
+ if (policy === false) return true;
141
+ const file = part as { mediaType?: unknown; url?: unknown };
142
+ if (
143
+ typeof file.mediaType !== "string" ||
144
+ !policy.accept.includes(file.mediaType)
145
+ ) {
146
+ return true;
147
+ }
148
+ if (typeof file.url !== "string") return true;
149
+ if (policy.mode === "stored") {
150
+ // A stored part names an upload; the row is checked by the
151
+ // handler, which knows the tenant. A data URL here bypassed the
152
+ // upload route and its limits, so it is refused.
153
+ if (attachmentIdFromUrl(policy, file.url) === null) return true;
154
+ continue;
155
+ }
156
+ const bytes = dataUrlBytes(file.url);
157
+ if (bytes !== null && bytes > (policy.maxBytes ?? ATTACHMENT_MAX_BYTES)) {
158
+ return true;
159
+ }
160
+ }
161
+ return false;
162
+ }
163
+
164
+ export function parseChatBody(
165
+ json: unknown,
166
+ options: {
167
+ maxMessageLength: number;
168
+ attachments: ChatAttachmentPolicy | false;
169
+ }
170
+ ): ParsedChatBody {
171
+ const invalid: ParsedChatBody = {
172
+ ok: false,
173
+ rejection: { key: "invalidBody" },
174
+ };
175
+ if (!isRecord(json)) return invalid;
176
+ if (typeof json.id !== "string" || !UUID.test(json.id)) return invalid;
177
+ if (!Array.isArray(json.messages) || json.messages.length === 0)
178
+ return invalid;
179
+ if (!json.messages.every(isUIMessage)) return invalid;
180
+ if (
181
+ json.trigger !== undefined &&
182
+ json.trigger !== "submit-message" &&
183
+ json.trigger !== "regenerate-message"
184
+ ) {
185
+ return invalid;
186
+ }
187
+ if (json.messageId !== undefined && typeof json.messageId !== "string") {
188
+ return invalid;
189
+ }
190
+
191
+ const messages = json.messages as UIMessage[];
192
+ // Every user message, not only the new one: the history is the
193
+ // client's too, and a turn is billed for all of it.
194
+ for (const message of messages) {
195
+ if (message.role !== "user") continue;
196
+ const length = extractText(message.parts).length;
197
+ if (length > options.maxMessageLength) {
198
+ return {
199
+ ok: false,
200
+ rejection: {
201
+ key: "messageTooLong",
202
+ params: { max: options.maxMessageLength },
203
+ },
204
+ };
205
+ }
206
+ if (rejectedAttachment(message, options.attachments)) {
207
+ return { ok: false, rejection: { key: "attachmentRejected" } };
208
+ }
209
+ }
210
+
211
+ const last = messages[messages.length - 1]!;
212
+ if (last.role === "assistant") {
213
+ // A turn that continues the assistant's own message — tool results
214
+ // or approval answers added client-side — names that message. The
215
+ // SDK sends exactly this; anything else is a hand-made body that
216
+ // would make the reply a fresh message with the tool loop lost.
217
+ if (json.trigger === "regenerate-message") return invalid;
218
+ if (json.messageId !== last.id) return invalid;
219
+ }
220
+
221
+ const extra: Record<string, unknown> = {};
222
+ for (const [key, value] of Object.entries(json)) {
223
+ if (!SDK_FIELDS.has(key)) extra[key] = value;
224
+ }
225
+
226
+ return {
227
+ ok: true,
228
+ body: {
229
+ id: json.id,
230
+ messages,
231
+ trigger: json.trigger as ChatBody["trigger"],
232
+ messageId: json.messageId as string | undefined,
233
+ extra,
234
+ },
235
+ };
236
+ }
package/src/client.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * What a chat client needs to know about the transport. Imports nothing at
3
+ * runtime (`./parts` is types and one type guard), so the UI never pulls
4
+ * the server handler, or Drizzle, Stripe and the auth server behind it,
5
+ * into the browser.
6
+ */
7
+
8
+ /** Stable codes the transport answers with. The status is fixed per code. */
9
+ export const CHAT_ERROR_CODES = [
10
+ "BAD_REQUEST",
11
+ "UNAUTHORIZED",
12
+ "QUOTA_EXCEEDED",
13
+ "FEATURE_GATED",
14
+ "NOT_FOUND",
15
+ "RATE_LIMITED",
16
+ "INTERNAL",
17
+ "BILLING_NOT_CONFIGURED",
18
+ "MODEL_UNAVAILABLE",
19
+ ] as const;
20
+
21
+ export type ChatErrorCode = (typeof CHAT_ERROR_CODES)[number];
22
+
23
+ /** The JSON body of every non-2xx answer from the chat transport. */
24
+ export type ChatErrorBody = {
25
+ /** Human-readable, in the caller's locale when the app bound a translator. */
26
+ error: string;
27
+ code: ChatErrorCode;
28
+ /**
29
+ * The entitlement port's own refusal code (`insufficient_credits`,
30
+ * `allowance_depleted`, …) when `code` is `QUOTA_EXCEEDED`,
31
+ * `BILLING_NOT_CONFIGURED` or `MODEL_UNAVAILABLE` (`unknown_model`).
32
+ */
33
+ reasonCode?: string;
34
+ /** Set on `RATE_LIMITED`. */
35
+ retryAfterSeconds?: number;
36
+ };
37
+
38
+ /**
39
+ * What the chat page opened with: a server-rendered estimate of the
40
+ * caller's credit, so the page can say "you are out" before a message
41
+ * is spent on finding out. Amounts are micros of the billing currency.
42
+ */
43
+ export type ChatQuotaState = {
44
+ /** False when the next turn would be refused. */
45
+ allowed: boolean;
46
+ /** Why, when the engine refused. */
47
+ reason: string | null;
48
+ /** Typed refusal, for a UI that wants to distinguish them. */
49
+ code: string | null;
50
+ /** Balance left across every pool. */
51
+ remaining: number;
52
+ /** Worst-case cost of one turn. */
53
+ estimated: number;
54
+ /** Where "upgrade" and "top up" should go. */
55
+ upgradeHref: string;
56
+ };
57
+
58
+ /**
59
+ * A model the composer may offer. `id` is a registered model id; the
60
+ * transport refuses any other. `featureKey` gates it by plan.
61
+ */
62
+ export type ChatModelOption = {
63
+ id: string;
64
+ label: string;
65
+ description?: string;
66
+ /** Plan feature the workspace needs for this model; unset means every plan. */
67
+ featureKey?: string;
68
+ };
69
+
70
+ export type {
71
+ ChatAgentData,
72
+ ChatArtifactData,
73
+ ChatAuthorizationData,
74
+ ChatCompactionData,
75
+ ChatDataChunk,
76
+ ChatDataPart,
77
+ ChatDataPartName,
78
+ ChatDataParts,
79
+ ChatMessageMetadata,
80
+ ChatQuestionData,
81
+ ChatQuestionOption,
82
+ ChatStatusData,
83
+ ChatTaskData,
84
+ ChatTaskItem,
85
+ ChatTaskStatus,
86
+ ChatUIMessage,
87
+ ChatUIMessageChunk,
88
+ } from "./parts";
89
+ export { isChatDataPart } from "./parts";
90
+
91
+ const CODES: ReadonlySet<string> = new Set(CHAT_ERROR_CODES);
92
+
93
+ /**
94
+ * The transport's error body, from the error `useChat` surfaces.
95
+ *
96
+ * The AI SDK's transport throws an `Error` whose message is the
97
+ * response text, so a JSON refusal arrives as a string. Anything that
98
+ * is not one of ours — a network failure, a proxy's HTML page — is
99
+ * null, and belongs in a generic error strip rather than a banner.
100
+ */
101
+ export function parseChatError(error: unknown): ChatErrorBody | null {
102
+ const text =
103
+ error instanceof Error
104
+ ? error.message
105
+ : typeof error === "string"
106
+ ? error
107
+ : null;
108
+ if (!text) return null;
109
+ try {
110
+ const parsed = JSON.parse(text) as Partial<ChatErrorBody>;
111
+ if (
112
+ typeof parsed !== "object" ||
113
+ parsed === null ||
114
+ typeof parsed.error !== "string" ||
115
+ typeof parsed.code !== "string" ||
116
+ !CODES.has(parsed.code)
117
+ ) {
118
+ return null;
119
+ }
120
+ return {
121
+ error: parsed.error,
122
+ code: parsed.code as ChatErrorCode,
123
+ ...(typeof parsed.reasonCode === "string"
124
+ ? { reasonCode: parsed.reasonCode }
125
+ : {}),
126
+ ...(typeof parsed.retryAfterSeconds === "number"
127
+ ? { retryAfterSeconds: parsed.retryAfterSeconds }
128
+ : {}),
129
+ };
130
+ } catch {
131
+ return null;
132
+ }
133
+ }