@lazyingart/agent-web 0.1.40
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 +22 -0
- package/README.md +438 -0
- package/docs/architecture.md +503 -0
- package/package.json +43 -0
- package/src/aginti-adapter.js +602 -0
- package/src/chat-context.js +1020 -0
- package/src/chat-migrations.js +947 -0
- package/src/chat-store.js +3308 -0
- package/src/cli.js +134 -0
- package/src/cloud-server.js +2043 -0
- package/src/contracts.js +103 -0
- package/src/deterministic-context-summarizer.js +254 -0
- package/src/direct-chat-capability-limits.js +66 -0
- package/src/direct-chat-contract.js +3 -0
- package/src/errors.js +50 -0
- package/src/http-contract.js +592 -0
- package/src/index.js +88 -0
- package/src/localllm-connector.js +667 -0
- package/src/migrations.js +231 -0
- package/src/operator-health.js +184 -0
- package/src/password-verifier.js +131 -0
- package/src/service-config.js +547 -0
- package/src/service.js +408 -0
- package/src/sqlite-health.js +83 -0
- package/src/storage-path.js +130 -0
- package/src/store.js +914 -0
- package/src/validation.js +181 -0
- package/src/vision-attachment.js +404 -0
- package/src/web/aginti-client.js +552 -0
- package/src/web/aginti-protocol.js +1146 -0
- package/src/web/asset-map.js +462 -0
- package/src/web/browser-app.js +6491 -0
- package/src/web/cloud-session-client.js +427 -0
- package/src/web/direct-chat-client.js +1482 -0
- package/src/web/index.js +10 -0
- package/src/web/presentation-state.js +107 -0
- package/src/web/pwa-assets.js +854 -0
- package/src/web/pwa-update-handoff-store.js +179 -0
- package/src/web/safe-rendering.js +836 -0
- package/src/web/vision-image-client.js +546 -0
- package/src/web/vision-image-sanitizer.js +168 -0
- package/src/web/web-release.js +28 -0
|
@@ -0,0 +1,1482 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLOUD_CSRF_HEADER_NAME,
|
|
3
|
+
CloudBrowserProtocolError,
|
|
4
|
+
CloudBrowserTransportError,
|
|
5
|
+
readCloudCsrfCookie,
|
|
6
|
+
} from "./cloud-session-client.js";
|
|
7
|
+
import {
|
|
8
|
+
addWebReleaseHeader,
|
|
9
|
+
inspectWebReleaseResponse,
|
|
10
|
+
optionalWebRelease,
|
|
11
|
+
} from "./web-release.js";
|
|
12
|
+
|
|
13
|
+
const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
14
|
+
const JSON_RESPONSE_LIMIT = 512 * 1024;
|
|
15
|
+
const ERROR_RESPONSE_LIMIT = 16 * 1024;
|
|
16
|
+
const STREAM_RESPONSE_LIMIT = 256 * 1024;
|
|
17
|
+
const VISION_IMAGE_LIMIT = 4 * 1024 * 1024;
|
|
18
|
+
const VISION_IMAGE_COUNT_LIMIT = 4;
|
|
19
|
+
const VISION_IMAGE_TOTAL_LIMIT = 16 * 1024 * 1024;
|
|
20
|
+
const VISION_BASE64_LIMIT = Math.ceil(VISION_IMAGE_LIMIT / 3) * 4;
|
|
21
|
+
const SSE_BLOCK_LIMIT = 32 * 1024;
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
|
+
const VISION_MUTATION_TIMEOUT_MS = 270_000;
|
|
24
|
+
const DEFAULT_STREAM_TIMEOUT_MS = 45_000;
|
|
25
|
+
const PREPARED_RUN_BODIES = new WeakMap();
|
|
26
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
27
|
+
const IDEMPOTENCY_KEY = /^[A-Za-z0-9._~-]{16,160}$/u;
|
|
28
|
+
const HASH = /^[a-f0-9]{64}$/u;
|
|
29
|
+
const MODEL_ALIAS = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
30
|
+
const ERROR_CODE = /^[a-z][a-z0-9_]{0,79}$/u;
|
|
31
|
+
const CSRF_TOKEN = /^[A-Za-z0-9_-]{32,128}$/u;
|
|
32
|
+
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
33
|
+
const GENERATION_STATUSES = new Set(["in_progress", ...TERMINAL_STATUSES]);
|
|
34
|
+
const FAILURE_CODES = new Set([
|
|
35
|
+
"provider_unavailable",
|
|
36
|
+
"timeout",
|
|
37
|
+
"internal_error",
|
|
38
|
+
"response_limit",
|
|
39
|
+
"content_rejected",
|
|
40
|
+
]);
|
|
41
|
+
const UNSAFE_MESSAGE_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
42
|
+
const encoder = new TextEncoder();
|
|
43
|
+
|
|
44
|
+
export const DIRECT_CHAT_ROUTES = Object.freeze({
|
|
45
|
+
capabilities: "/api/chat/capabilities",
|
|
46
|
+
threadsList: "/api/chat/threads/list",
|
|
47
|
+
threadsCreate: "/api/chat/threads/create",
|
|
48
|
+
threadsGet: "/api/chat/threads/get",
|
|
49
|
+
threadsDelete: "/api/chat/threads/delete",
|
|
50
|
+
messagesList: "/api/chat/messages/list",
|
|
51
|
+
attachmentsGet: "/api/chat/attachments/get",
|
|
52
|
+
runsStart: "/api/chat/runs/start",
|
|
53
|
+
runsStatus: "/api/chat/runs/status",
|
|
54
|
+
runsEvents: "/api/chat/runs/events",
|
|
55
|
+
runsCancel: "/api/chat/runs/cancel",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export class DirectChatProtocolError extends CloudBrowserProtocolError {
|
|
59
|
+
constructor(message, options) {
|
|
60
|
+
super(message, options);
|
|
61
|
+
this.name = "DirectChatProtocolError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class DirectChatTransportError extends CloudBrowserTransportError {
|
|
66
|
+
constructor(message, options) {
|
|
67
|
+
super(message, options);
|
|
68
|
+
this.name = "DirectChatTransportError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function exactObject(value, allowed, required, label, { input = false } = {}) {
|
|
73
|
+
const fail = (message) => {
|
|
74
|
+
if (input) throw new TypeError(message);
|
|
75
|
+
throw new DirectChatProtocolError(message);
|
|
76
|
+
};
|
|
77
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
78
|
+
fail(`${label} must be a plain object`);
|
|
79
|
+
}
|
|
80
|
+
const prototype = Object.getPrototypeOf(value);
|
|
81
|
+
if (prototype !== Object.prototype && prototype !== null) fail(`${label} must be a plain object`);
|
|
82
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
83
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
84
|
+
if (typeof key !== "string" || !allowed.includes(key)) fail(`${label} contains an unsupported field`);
|
|
85
|
+
if (!descriptors[key].enumerable || !Object.hasOwn(descriptors[key], "value")) fail(`${label} contains an accessor`);
|
|
86
|
+
}
|
|
87
|
+
for (const key of required) if (!Object.hasOwn(value, key)) fail(`${label}.${key} is required`);
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function exactDenseArrayValues(value, label, { minimum, maximum }) {
|
|
92
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype
|
|
93
|
+
|| value.length < minimum || value.length > maximum) {
|
|
94
|
+
throw new TypeError(`${label} is invalid`);
|
|
95
|
+
}
|
|
96
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
97
|
+
const values = [];
|
|
98
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
99
|
+
const descriptor = descriptors[String(index)];
|
|
100
|
+
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
101
|
+
throw new TypeError(`${label} must be dense data`);
|
|
102
|
+
}
|
|
103
|
+
values.push(descriptor.value);
|
|
104
|
+
}
|
|
105
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
106
|
+
if (key === "length") continue;
|
|
107
|
+
if (typeof key !== "string" || !/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length) {
|
|
108
|
+
throw new TypeError(`${label} contains an unsupported property`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return values;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function utf8Length(value) {
|
|
115
|
+
return encoder.encode(value).byteLength;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function unicodeScalar(value, label, { minimum = 0, maximum, controls = true, input = false } = {}) {
|
|
119
|
+
const fail = (message) => {
|
|
120
|
+
if (input) throw new TypeError(message);
|
|
121
|
+
throw new DirectChatProtocolError(message);
|
|
122
|
+
};
|
|
123
|
+
if (typeof value !== "string") fail(`${label} must be a string`);
|
|
124
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
125
|
+
const code = value.charCodeAt(index);
|
|
126
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
127
|
+
const next = value.charCodeAt(index + 1);
|
|
128
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) fail(`${label} contains invalid Unicode`);
|
|
129
|
+
index += 1;
|
|
130
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
131
|
+
fail(`${label} contains invalid Unicode`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const bytes = utf8Length(value);
|
|
135
|
+
if (bytes < minimum || bytes > maximum || value.includes("\u0000")
|
|
136
|
+
|| (!controls && /[\u0001-\u001f\u007f]/u.test(value))) {
|
|
137
|
+
fail(`${label} is invalid`);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function integer(value, label, { minimum = 0, maximum = Number.MAX_SAFE_INTEGER, input = false } = {}) {
|
|
143
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
144
|
+
if (input) throw new TypeError(`${label} is invalid`);
|
|
145
|
+
throw new DirectChatProtocolError(`${label} is invalid`);
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function identifier(value, label, { input = false, opaque = false } = {}) {
|
|
151
|
+
if (typeof value !== "string" || !IDENTIFIER.test(value) || (opaque && value.length < 16)) {
|
|
152
|
+
if (input) throw new TypeError(`${label} is invalid`);
|
|
153
|
+
throw new DirectChatProtocolError(`${label} is invalid`);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function idempotencyKey(value, { input = false } = {}) {
|
|
159
|
+
if (typeof value !== "string" || !IDEMPOTENCY_KEY.test(value)) {
|
|
160
|
+
if (input) throw new TypeError("idempotencyKey is invalid");
|
|
161
|
+
throw new DirectChatProtocolError("idempotencyKey is invalid");
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hash(value, label, { nullable = false } = {}) {
|
|
167
|
+
if (nullable && value === null) return null;
|
|
168
|
+
if (typeof value !== "string" || !HASH.test(value)) throw new DirectChatProtocolError(`${label} is invalid`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function timestamp(value, label, { nullable = false } = {}) {
|
|
173
|
+
if (nullable && value === null) return null;
|
|
174
|
+
if (typeof value !== "string") throw new DirectChatProtocolError(`${label} is invalid`);
|
|
175
|
+
const parsed = new Date(value);
|
|
176
|
+
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) {
|
|
177
|
+
throw new DirectChatProtocolError(`${label} is invalid`);
|
|
178
|
+
}
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizedBaseOrigin(value) {
|
|
183
|
+
const base = value ?? globalThis.location?.href;
|
|
184
|
+
if (typeof base !== "string") throw new TypeError("baseUrl is required outside a browser");
|
|
185
|
+
const parsed = new URL(base);
|
|
186
|
+
if (!/^https?:$/u.test(parsed.protocol) || parsed.username || parsed.password || parsed.origin === "null") {
|
|
187
|
+
throw new TypeError("baseUrl must be an HTTP(S) URL without credentials");
|
|
188
|
+
}
|
|
189
|
+
return parsed.origin;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function cookieReader(source) {
|
|
193
|
+
if (source === undefined) return () => globalThis.document?.cookie ?? "";
|
|
194
|
+
if (typeof source === "function") return source;
|
|
195
|
+
if (typeof source === "string") return () => source;
|
|
196
|
+
throw new TypeError("cookieSource must be a function or string");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createBrowserOpaqueId(kind = "id") {
|
|
200
|
+
if (typeof kind !== "string" || !/^[a-z][a-z0-9_-]{0,15}$/u.test(kind)) {
|
|
201
|
+
throw new TypeError("opaque identifier kind is invalid");
|
|
202
|
+
}
|
|
203
|
+
let random;
|
|
204
|
+
if (typeof globalThis.crypto?.randomUUID === "function") random = globalThis.crypto.randomUUID();
|
|
205
|
+
else if (typeof globalThis.crypto?.getRandomValues === "function") {
|
|
206
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(24));
|
|
207
|
+
random = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
208
|
+
} else {
|
|
209
|
+
throw new TypeError("secure randomness is unavailable");
|
|
210
|
+
}
|
|
211
|
+
return `${kind}_${random}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function generated(factory, kind, { idempotency = false } = {}) {
|
|
215
|
+
const value = factory(kind);
|
|
216
|
+
return idempotency ? idempotencyKey(value, { input: true }) : identifier(value, kind, { input: true, opaque: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function responseThread(value, expectedThreadId) {
|
|
220
|
+
const thread = exactObject(value, [
|
|
221
|
+
"threadId", "title", "modelAlias", "revision", "ledgerHash", "messageCount", "ledgerBytes",
|
|
222
|
+
"currentGenerationId", "createdAt", "updatedAt",
|
|
223
|
+
], [
|
|
224
|
+
"threadId", "title", "modelAlias", "revision", "ledgerHash", "messageCount", "ledgerBytes",
|
|
225
|
+
"currentGenerationId", "createdAt", "updatedAt",
|
|
226
|
+
], "thread");
|
|
227
|
+
const threadId = identifier(thread.threadId, "thread.threadId");
|
|
228
|
+
if (expectedThreadId !== undefined && threadId !== expectedThreadId) {
|
|
229
|
+
throw new DirectChatProtocolError("thread response ownership does not match the request");
|
|
230
|
+
}
|
|
231
|
+
const revision = integer(thread.revision, "thread.revision", { maximum: 2_000 });
|
|
232
|
+
const ledgerHash = hash(thread.ledgerHash, "thread.ledgerHash", { nullable: revision === 0 });
|
|
233
|
+
if ((revision === 0 && ledgerHash !== null) || (revision > 0 && ledgerHash === null)) {
|
|
234
|
+
throw new DirectChatProtocolError("thread ledger cursor is inconsistent");
|
|
235
|
+
}
|
|
236
|
+
const messageCount = integer(thread.messageCount, "thread.messageCount", { maximum: 2_000 });
|
|
237
|
+
if (messageCount !== revision) throw new DirectChatProtocolError("thread message count is inconsistent");
|
|
238
|
+
const createdAt = timestamp(thread.createdAt, "thread.createdAt");
|
|
239
|
+
const updatedAt = timestamp(thread.updatedAt, "thread.updatedAt");
|
|
240
|
+
if (updatedAt < createdAt) throw new DirectChatProtocolError("thread timestamps are inconsistent");
|
|
241
|
+
if (typeof thread.modelAlias !== "string" || !MODEL_ALIAS.test(thread.modelAlias)) {
|
|
242
|
+
throw new DirectChatProtocolError("thread.modelAlias is invalid");
|
|
243
|
+
}
|
|
244
|
+
const currentGenerationId = thread.currentGenerationId === null
|
|
245
|
+
? null
|
|
246
|
+
: identifier(thread.currentGenerationId, "thread.currentGenerationId");
|
|
247
|
+
return Object.freeze({
|
|
248
|
+
threadId,
|
|
249
|
+
title: unicodeScalar(thread.title, "thread.title", { maximum: 512, controls: false }),
|
|
250
|
+
modelAlias: thread.modelAlias,
|
|
251
|
+
revision,
|
|
252
|
+
ledgerHash,
|
|
253
|
+
messageCount,
|
|
254
|
+
ledgerBytes: integer(thread.ledgerBytes, "thread.ledgerBytes", { maximum: 8 * 1024 * 1024 }),
|
|
255
|
+
currentGenerationId,
|
|
256
|
+
createdAt,
|
|
257
|
+
updatedAt,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function responseMessage(value, expectedThreadId) {
|
|
262
|
+
const message = exactObject(value, [
|
|
263
|
+
"threadId", "messageId", "revision", "role", "content", "contentBytes", "previousHash",
|
|
264
|
+
"messageHash", "generationId", "createdAt", "attachment", "attachments",
|
|
265
|
+
], [
|
|
266
|
+
"threadId", "messageId", "revision", "role", "content", "contentBytes", "previousHash",
|
|
267
|
+
"messageHash", "generationId", "createdAt",
|
|
268
|
+
], "message");
|
|
269
|
+
const threadId = identifier(message.threadId, "message.threadId");
|
|
270
|
+
if (threadId !== expectedThreadId) throw new DirectChatProtocolError("message belongs to an unexpected thread");
|
|
271
|
+
const revision = integer(message.revision, "message.revision", { minimum: 1, maximum: 2_000 });
|
|
272
|
+
if (!["user", "assistant"].includes(message.role)) throw new DirectChatProtocolError("message.role is invalid");
|
|
273
|
+
const content = unicodeScalar(message.content, "message.content", { minimum: 1, maximum: 64 * 1024 });
|
|
274
|
+
if (integer(message.contentBytes, "message.contentBytes", { minimum: 1, maximum: 64 * 1024 }) !== utf8Length(content)) {
|
|
275
|
+
throw new DirectChatProtocolError("message.contentBytes is inconsistent");
|
|
276
|
+
}
|
|
277
|
+
const previousHash = hash(message.previousHash, "message.previousHash", { nullable: revision === 1 });
|
|
278
|
+
if ((revision === 1 && previousHash !== null) || (revision > 1 && previousHash === null)) {
|
|
279
|
+
throw new DirectChatProtocolError("message.previousHash is inconsistent");
|
|
280
|
+
}
|
|
281
|
+
const generationId = message.generationId === null
|
|
282
|
+
? null
|
|
283
|
+
: identifier(message.generationId, "message.generationId");
|
|
284
|
+
if ((message.role === "user" && generationId !== null) || (message.role === "assistant" && generationId === null)) {
|
|
285
|
+
throw new DirectChatProtocolError("message generation ownership is inconsistent");
|
|
286
|
+
}
|
|
287
|
+
if (message.attachment !== undefined && message.attachments !== undefined) {
|
|
288
|
+
throw new DirectChatProtocolError("message attachment shape is ambiguous");
|
|
289
|
+
}
|
|
290
|
+
const rawAttachments = message.attachments ?? (message.attachment === undefined ? [] : [message.attachment]);
|
|
291
|
+
if (!Array.isArray(rawAttachments) || rawAttachments.length > VISION_IMAGE_COUNT_LIMIT
|
|
292
|
+
|| (message.attachments !== undefined && rawAttachments.length < 2)) {
|
|
293
|
+
throw new DirectChatProtocolError("message attachment list is invalid");
|
|
294
|
+
}
|
|
295
|
+
const attachments = [];
|
|
296
|
+
const attachmentIds = new Set();
|
|
297
|
+
let attachmentBytes = 0;
|
|
298
|
+
for (const rawAttachment of rawAttachments) {
|
|
299
|
+
const descriptor = exactObject(rawAttachment, [
|
|
300
|
+
"attachmentId", "mediaType", "byteLength", "width", "height", "sha256",
|
|
301
|
+
], [
|
|
302
|
+
"attachmentId", "mediaType", "byteLength", "width", "height", "sha256",
|
|
303
|
+
], "message.attachment");
|
|
304
|
+
if (!['image/jpeg', 'image/png'].includes(descriptor.mediaType)
|
|
305
|
+
|| typeof descriptor.sha256 !== "string" || !HASH.test(descriptor.sha256)) {
|
|
306
|
+
throw new DirectChatProtocolError("message attachment descriptor is invalid");
|
|
307
|
+
}
|
|
308
|
+
const attachment = Object.freeze({
|
|
309
|
+
attachmentId: identifier(descriptor.attachmentId, "message.attachment.attachmentId"),
|
|
310
|
+
mediaType: descriptor.mediaType,
|
|
311
|
+
byteLength: integer(descriptor.byteLength, "message.attachment.byteLength", { minimum: 1, maximum: VISION_IMAGE_LIMIT }),
|
|
312
|
+
width: integer(descriptor.width, "message.attachment.width", { minimum: 1, maximum: 4_096 }),
|
|
313
|
+
height: integer(descriptor.height, "message.attachment.height", { minimum: 1, maximum: 4_096 }),
|
|
314
|
+
sha256: descriptor.sha256,
|
|
315
|
+
});
|
|
316
|
+
if (attachment.width * attachment.height > 16 * 1024 * 1024 || message.role !== "user") {
|
|
317
|
+
throw new DirectChatProtocolError("message attachment descriptor is invalid");
|
|
318
|
+
}
|
|
319
|
+
if (attachmentIds.has(attachment.attachmentId)) {
|
|
320
|
+
throw new DirectChatProtocolError("message attachment identifiers are not unique");
|
|
321
|
+
}
|
|
322
|
+
attachmentIds.add(attachment.attachmentId);
|
|
323
|
+
attachmentBytes += attachment.byteLength;
|
|
324
|
+
if (attachmentBytes > VISION_IMAGE_TOTAL_LIMIT) {
|
|
325
|
+
throw new DirectChatProtocolError("message attachments exceed the aggregate limit");
|
|
326
|
+
}
|
|
327
|
+
attachments.push(attachment);
|
|
328
|
+
}
|
|
329
|
+
return Object.freeze({
|
|
330
|
+
threadId,
|
|
331
|
+
messageId: identifier(message.messageId, "message.messageId"),
|
|
332
|
+
revision,
|
|
333
|
+
role: message.role,
|
|
334
|
+
content,
|
|
335
|
+
contentBytes: message.contentBytes,
|
|
336
|
+
previousHash,
|
|
337
|
+
messageHash: hash(message.messageHash, "message.messageHash"),
|
|
338
|
+
generationId,
|
|
339
|
+
createdAt: timestamp(message.createdAt, "message.createdAt"),
|
|
340
|
+
...(attachments.length === 0 ? {} : (attachments.length === 1
|
|
341
|
+
? { attachment: attachments[0] }
|
|
342
|
+
: { attachments: Object.freeze(attachments) })),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function requestAttachment(value) {
|
|
347
|
+
const attachment = exactObject(value, ["attachmentId", "mediaType", "data"], [
|
|
348
|
+
"attachmentId", "mediaType", "data",
|
|
349
|
+
], "prepared run attachment", { input: true });
|
|
350
|
+
if (!['image/jpeg', 'image/png'].includes(attachment.mediaType)
|
|
351
|
+
|| typeof attachment.data !== "string" || attachment.data.length < 4
|
|
352
|
+
|| attachment.data.length > VISION_BASE64_LIMIT || attachment.data.length % 4 !== 0
|
|
353
|
+
|| !boundedBase64(attachment.data)) {
|
|
354
|
+
throw new TypeError("prepared run attachment is invalid");
|
|
355
|
+
}
|
|
356
|
+
return Object.freeze({
|
|
357
|
+
attachmentId: identifier(attachment.attachmentId, "attachmentId", { input: true, opaque: true }),
|
|
358
|
+
mediaType: attachment.mediaType,
|
|
359
|
+
data: attachment.data,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function boundedBase64(value) {
|
|
364
|
+
let padding = 0;
|
|
365
|
+
if (value.endsWith("=")) padding = value.endsWith("==") ? 2 : 1;
|
|
366
|
+
const contentLength = value.length - padding;
|
|
367
|
+
if ((padding === 0 && contentLength % 4 !== 0)
|
|
368
|
+
|| (padding === 1 && contentLength % 4 !== 3)
|
|
369
|
+
|| (padding === 2 && contentLength % 4 !== 2)) return false;
|
|
370
|
+
for (let index = 0; index < contentLength; index += 1) {
|
|
371
|
+
const code = value.charCodeAt(index);
|
|
372
|
+
if (!((code >= 65 && code <= 90) || (code >= 97 && code <= 122)
|
|
373
|
+
|| (code >= 48 && code <= 57) || code === 43 || code === 47)) return false;
|
|
374
|
+
}
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function decodedBase64Length(value) {
|
|
379
|
+
return (value.length / 4) * 3 - (value.endsWith("==") ? 2 : (value.endsWith("=") ? 1 : 0));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function bytesToBase64(bytes) {
|
|
383
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > VISION_IMAGE_LIMIT) {
|
|
384
|
+
throw new TypeError("attachment bytes are invalid");
|
|
385
|
+
}
|
|
386
|
+
if (typeof bytes.toBase64 === "function") {
|
|
387
|
+
const encoded = bytes.toBase64();
|
|
388
|
+
if (typeof encoded !== "string" || encoded.length !== Math.ceil(bytes.byteLength / 3) * 4
|
|
389
|
+
|| !boundedBase64(encoded)) {
|
|
390
|
+
throw new TypeError("native attachment base64 encoding is invalid");
|
|
391
|
+
}
|
|
392
|
+
return encoded;
|
|
393
|
+
}
|
|
394
|
+
// Keep both the temporary binary string and every spread call small. A
|
|
395
|
+
// single multi-megabyte binary join materially increases peak memory in a
|
|
396
|
+
// mobile PWA before JSON serialization makes the required wire copy.
|
|
397
|
+
const encoded = [];
|
|
398
|
+
const encodingChunkBytes = 12 * 1024; // Divisible by three, so only the final chunk is padded.
|
|
399
|
+
const spreadChunkBytes = 1024;
|
|
400
|
+
for (let offset = 0; offset < bytes.byteLength; offset += encodingChunkBytes) {
|
|
401
|
+
const end = Math.min(offset + encodingChunkBytes, bytes.byteLength);
|
|
402
|
+
let binary = "";
|
|
403
|
+
for (let cursor = offset; cursor < end; cursor += spreadChunkBytes) {
|
|
404
|
+
binary += String.fromCharCode(...bytes.subarray(cursor, Math.min(cursor + spreadChunkBytes, end)));
|
|
405
|
+
}
|
|
406
|
+
encoded.push(btoa(binary));
|
|
407
|
+
}
|
|
408
|
+
return encoded.join("");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function sha256Bytes(bytes) {
|
|
412
|
+
if (typeof globalThis.crypto?.subtle?.digest !== "function") {
|
|
413
|
+
throw new DirectChatProtocolError("secure attachment verification is unavailable");
|
|
414
|
+
}
|
|
415
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
|
|
416
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function canonicalAttachment(value) {
|
|
420
|
+
const attachment = exactObject(value, [
|
|
421
|
+
"attachmentId", "mediaType", "byteLength", "width", "height", "bytes",
|
|
422
|
+
], [
|
|
423
|
+
"attachmentId", "mediaType", "byteLength", "width", "height", "bytes",
|
|
424
|
+
], "canonical image attachment", { input: true });
|
|
425
|
+
if (!['image/jpeg', 'image/png'].includes(attachment.mediaType)
|
|
426
|
+
|| !(attachment.bytes instanceof Uint8Array)
|
|
427
|
+
|| attachment.byteLength !== attachment.bytes.byteLength
|
|
428
|
+
|| !Number.isSafeInteger(attachment.width) || attachment.width < 1 || attachment.width > 4_096
|
|
429
|
+
|| !Number.isSafeInteger(attachment.height) || attachment.height < 1 || attachment.height > 4_096
|
|
430
|
+
|| attachment.width * attachment.height > 16 * 1024 * 1024) {
|
|
431
|
+
throw new TypeError("canonical image attachment is invalid");
|
|
432
|
+
}
|
|
433
|
+
return requestAttachment({
|
|
434
|
+
attachmentId: attachment.attachmentId,
|
|
435
|
+
mediaType: attachment.mediaType,
|
|
436
|
+
data: bytesToBase64(attachment.bytes),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function canonicalAttachments(value) {
|
|
441
|
+
const values = exactDenseArrayValues(value, "canonical image attachments", {
|
|
442
|
+
minimum: 2,
|
|
443
|
+
maximum: VISION_IMAGE_COUNT_LIMIT,
|
|
444
|
+
});
|
|
445
|
+
const attachments = [];
|
|
446
|
+
const identifiers = new Set();
|
|
447
|
+
let totalBytes = 0;
|
|
448
|
+
for (const raw of values) {
|
|
449
|
+
const attachment = canonicalAttachment(raw);
|
|
450
|
+
if (identifiers.has(attachment.attachmentId)) {
|
|
451
|
+
throw new TypeError("canonical image attachment identifiers must be unique");
|
|
452
|
+
}
|
|
453
|
+
identifiers.add(attachment.attachmentId);
|
|
454
|
+
totalBytes += decodedBase64Length(attachment.data);
|
|
455
|
+
if (totalBytes > VISION_IMAGE_TOTAL_LIMIT) {
|
|
456
|
+
throw new TypeError("canonical image attachments exceed the aggregate limit");
|
|
457
|
+
}
|
|
458
|
+
attachments.push(attachment);
|
|
459
|
+
}
|
|
460
|
+
return Object.freeze(attachments);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function responseGeneration(value, expected = {}) {
|
|
464
|
+
const generation = exactObject(value, [
|
|
465
|
+
"threadId", "generationId", "assistantMessageId", "status", "terminal", "modelAlias",
|
|
466
|
+
"sourceRevision", "sourceHash", "deltaCount", "deltaBytes", "lastDeltaHash", "finalRevision",
|
|
467
|
+
"finalHash", "failureCode", "deltasPruned", "startedAt", "updatedAt", "terminalAt", "prunedAt",
|
|
468
|
+
], [
|
|
469
|
+
"threadId", "generationId", "assistantMessageId", "status", "terminal", "modelAlias",
|
|
470
|
+
"sourceRevision", "sourceHash", "deltaCount", "deltaBytes", "lastDeltaHash", "finalRevision",
|
|
471
|
+
"finalHash", "failureCode", "deltasPruned", "startedAt", "updatedAt", "terminalAt", "prunedAt",
|
|
472
|
+
], "generation");
|
|
473
|
+
const threadId = identifier(generation.threadId, "generation.threadId");
|
|
474
|
+
const generationId = identifier(generation.generationId, "generation.generationId");
|
|
475
|
+
if ((expected.threadId !== undefined && threadId !== expected.threadId)
|
|
476
|
+
|| (expected.generationId !== undefined && generationId !== expected.generationId)) {
|
|
477
|
+
throw new DirectChatProtocolError("generation ownership does not match the request");
|
|
478
|
+
}
|
|
479
|
+
if (!GENERATION_STATUSES.has(generation.status)) throw new DirectChatProtocolError("generation.status is invalid");
|
|
480
|
+
const terminal = TERMINAL_STATUSES.has(generation.status);
|
|
481
|
+
if (generation.terminal !== terminal) throw new DirectChatProtocolError("generation terminal flag is inconsistent");
|
|
482
|
+
if (typeof generation.modelAlias !== "string" || !MODEL_ALIAS.test(generation.modelAlias)) {
|
|
483
|
+
throw new DirectChatProtocolError("generation.modelAlias is invalid");
|
|
484
|
+
}
|
|
485
|
+
const sourceRevision = integer(generation.sourceRevision, "generation.sourceRevision", { minimum: 1, maximum: 2_000 });
|
|
486
|
+
const deltaCount = integer(generation.deltaCount, "generation.deltaCount", { maximum: 8_192 });
|
|
487
|
+
const deltaBytes = integer(generation.deltaBytes, "generation.deltaBytes", { maximum: 64 * 1024 });
|
|
488
|
+
const lastDeltaHash = hash(generation.lastDeltaHash, "generation.lastDeltaHash", { nullable: deltaCount === 0 });
|
|
489
|
+
if ((deltaCount === 0 && (deltaBytes !== 0 || lastDeltaHash !== null))
|
|
490
|
+
|| (deltaCount > 0 && (deltaBytes < 1 || lastDeltaHash === null))) {
|
|
491
|
+
throw new DirectChatProtocolError("generation delta cursor is inconsistent");
|
|
492
|
+
}
|
|
493
|
+
const startedAt = timestamp(generation.startedAt, "generation.startedAt");
|
|
494
|
+
const updatedAt = timestamp(generation.updatedAt, "generation.updatedAt");
|
|
495
|
+
if (updatedAt < startedAt) throw new DirectChatProtocolError("generation timestamps are inconsistent");
|
|
496
|
+
const finalRevision = generation.finalRevision === null
|
|
497
|
+
? null
|
|
498
|
+
: integer(generation.finalRevision, "generation.finalRevision", { minimum: 1, maximum: 2_000 });
|
|
499
|
+
const finalHash = hash(generation.finalHash, "generation.finalHash", { nullable: true });
|
|
500
|
+
const terminalAt = timestamp(generation.terminalAt, "generation.terminalAt", { nullable: true });
|
|
501
|
+
if (generation.status === "completed") {
|
|
502
|
+
if (deltaCount < 1 || finalRevision !== sourceRevision + 1 || finalHash === null
|
|
503
|
+
|| generation.failureCode !== null || terminalAt === null) {
|
|
504
|
+
throw new DirectChatProtocolError("completed generation is inconsistent");
|
|
505
|
+
}
|
|
506
|
+
} else if (generation.status === "in_progress") {
|
|
507
|
+
if (finalRevision !== null || finalHash !== null || generation.failureCode !== null || terminalAt !== null) {
|
|
508
|
+
throw new DirectChatProtocolError("in-progress generation is inconsistent");
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
if (finalRevision !== null || finalHash !== null || terminalAt === null) {
|
|
512
|
+
throw new DirectChatProtocolError("terminal generation is inconsistent");
|
|
513
|
+
}
|
|
514
|
+
if (generation.status === "failed" && !FAILURE_CODES.has(generation.failureCode)) {
|
|
515
|
+
throw new DirectChatProtocolError("generation.failureCode is invalid");
|
|
516
|
+
}
|
|
517
|
+
if (generation.status === "cancelled" && generation.failureCode !== null) {
|
|
518
|
+
throw new DirectChatProtocolError("cancelled generation has a failure code");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (typeof generation.deltasPruned !== "boolean") throw new DirectChatProtocolError("generation.deltasPruned is invalid");
|
|
522
|
+
const prunedAt = timestamp(generation.prunedAt, "generation.prunedAt", { nullable: true });
|
|
523
|
+
if (generation.deltasPruned !== (prunedAt !== null) || (generation.deltasPruned && generation.status !== "completed")) {
|
|
524
|
+
throw new DirectChatProtocolError("generation pruning state is inconsistent");
|
|
525
|
+
}
|
|
526
|
+
return Object.freeze({
|
|
527
|
+
threadId,
|
|
528
|
+
generationId,
|
|
529
|
+
assistantMessageId: identifier(generation.assistantMessageId, "generation.assistantMessageId"),
|
|
530
|
+
status: generation.status,
|
|
531
|
+
terminal,
|
|
532
|
+
modelAlias: generation.modelAlias,
|
|
533
|
+
sourceRevision,
|
|
534
|
+
sourceHash: hash(generation.sourceHash, "generation.sourceHash"),
|
|
535
|
+
deltaCount,
|
|
536
|
+
deltaBytes,
|
|
537
|
+
lastDeltaHash,
|
|
538
|
+
finalRevision,
|
|
539
|
+
finalHash,
|
|
540
|
+
failureCode: generation.failureCode,
|
|
541
|
+
deltasPruned: generation.deltasPruned,
|
|
542
|
+
startedAt,
|
|
543
|
+
updatedAt,
|
|
544
|
+
terminalAt,
|
|
545
|
+
prunedAt,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function responseDelta(value, expected, afterSequence) {
|
|
550
|
+
const delta = exactObject(value, [
|
|
551
|
+
"threadId", "generationId", "sequence", "content", "contentBytes", "previousHash", "deltaHash", "createdAt",
|
|
552
|
+
], [
|
|
553
|
+
"threadId", "generationId", "sequence", "content", "contentBytes", "previousHash", "deltaHash", "createdAt",
|
|
554
|
+
], "delta");
|
|
555
|
+
if (identifier(delta.threadId, "delta.threadId") !== expected.threadId
|
|
556
|
+
|| identifier(delta.generationId, "delta.generationId") !== expected.generationId) {
|
|
557
|
+
throw new DirectChatProtocolError("delta ownership does not match the request");
|
|
558
|
+
}
|
|
559
|
+
const sequence = integer(delta.sequence, "delta.sequence", { minimum: 1, maximum: 8_192 });
|
|
560
|
+
if (sequence !== afterSequence + 1) throw new DirectChatProtocolError("delta sequence is not contiguous");
|
|
561
|
+
const content = unicodeScalar(delta.content, "delta.content", { minimum: 1, maximum: 16 * 1024 });
|
|
562
|
+
if (integer(delta.contentBytes, "delta.contentBytes", { minimum: 1, maximum: 16 * 1024 }) !== utf8Length(content)) {
|
|
563
|
+
throw new DirectChatProtocolError("delta.contentBytes is inconsistent");
|
|
564
|
+
}
|
|
565
|
+
const previousHash = hash(delta.previousHash, "delta.previousHash", { nullable: sequence === 1 });
|
|
566
|
+
if ((sequence === 1 && previousHash !== null) || (sequence > 1 && previousHash === null)) {
|
|
567
|
+
throw new DirectChatProtocolError("delta.previousHash is inconsistent");
|
|
568
|
+
}
|
|
569
|
+
return Object.freeze({
|
|
570
|
+
threadId: delta.threadId,
|
|
571
|
+
generationId: delta.generationId,
|
|
572
|
+
sequence,
|
|
573
|
+
content,
|
|
574
|
+
contentBytes: delta.contentBytes,
|
|
575
|
+
previousHash,
|
|
576
|
+
deltaHash: hash(delta.deltaHash, "delta.deltaHash"),
|
|
577
|
+
createdAt: timestamp(delta.createdAt, "delta.createdAt"),
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function threadTicket(value) {
|
|
582
|
+
const request = exactObject(value, ["threadId", "title", "idempotencyKey"], ["threadId", "title", "idempotencyKey"], "prepared thread", { input: true });
|
|
583
|
+
return Object.freeze({
|
|
584
|
+
threadId: identifier(request.threadId, "threadId", { input: true, opaque: true }),
|
|
585
|
+
title: unicodeScalar(request.title, "title", { maximum: 512, controls: false, input: true }),
|
|
586
|
+
idempotencyKey: idempotencyKey(request.idempotencyKey, { input: true }),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function threadDeletionTicket(value) {
|
|
591
|
+
const request = exactObject(value, [
|
|
592
|
+
"threadId", "expectedRevision", "expectedHash", "idempotencyKey",
|
|
593
|
+
], [
|
|
594
|
+
"threadId", "expectedRevision", "expectedHash", "idempotencyKey",
|
|
595
|
+
], "prepared thread deletion", { input: true });
|
|
596
|
+
const expectedRevision = integer(request.expectedRevision, "expectedRevision", {
|
|
597
|
+
maximum: 2_000,
|
|
598
|
+
input: true,
|
|
599
|
+
});
|
|
600
|
+
if ((expectedRevision === 0 && request.expectedHash !== null)
|
|
601
|
+
|| (expectedRevision > 0 && (typeof request.expectedHash !== "string" || !HASH.test(request.expectedHash)))) {
|
|
602
|
+
throw new TypeError("expectedHash is inconsistent with expectedRevision");
|
|
603
|
+
}
|
|
604
|
+
return Object.freeze({
|
|
605
|
+
threadId: identifier(request.threadId, "threadId", { input: true }),
|
|
606
|
+
expectedRevision,
|
|
607
|
+
expectedHash: request.expectedHash,
|
|
608
|
+
idempotencyKey: idempotencyKey(request.idempotencyKey, { input: true }),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function runTicket(value) {
|
|
613
|
+
const request = exactObject(value, [
|
|
614
|
+
"threadId", "messageId", "generationId", "assistantMessageId", "content",
|
|
615
|
+
"expectedRevision", "expectedHash", "idempotencyKey", "attachment", "attachments",
|
|
616
|
+
], [
|
|
617
|
+
"threadId", "messageId", "generationId", "assistantMessageId", "content",
|
|
618
|
+
"expectedRevision", "expectedHash", "idempotencyKey",
|
|
619
|
+
], "prepared run", { input: true });
|
|
620
|
+
const expectedRevision = integer(request.expectedRevision, "expectedRevision", { maximum: 2_000, input: true });
|
|
621
|
+
if ((expectedRevision === 0 && request.expectedHash !== null)
|
|
622
|
+
|| (expectedRevision > 0 && (typeof request.expectedHash !== "string" || !HASH.test(request.expectedHash)))) {
|
|
623
|
+
throw new TypeError("expectedHash is inconsistent with expectedRevision");
|
|
624
|
+
}
|
|
625
|
+
const content = unicodeScalar(request.content, "content", {
|
|
626
|
+
minimum: 1,
|
|
627
|
+
maximum: 64 * 1024,
|
|
628
|
+
controls: true,
|
|
629
|
+
input: true,
|
|
630
|
+
}).trim();
|
|
631
|
+
if (!content || UNSAFE_MESSAGE_CONTROL.test(content)) {
|
|
632
|
+
throw new TypeError("content is invalid");
|
|
633
|
+
}
|
|
634
|
+
if (request.attachment !== undefined && request.attachments !== undefined) {
|
|
635
|
+
throw new TypeError("prepared run attachment shape is ambiguous");
|
|
636
|
+
}
|
|
637
|
+
const encodedAttachments = request.attachments === undefined
|
|
638
|
+
? undefined
|
|
639
|
+
: exactDenseArrayValues(request.attachments, "prepared run attachments", {
|
|
640
|
+
minimum: 2,
|
|
641
|
+
maximum: VISION_IMAGE_COUNT_LIMIT,
|
|
642
|
+
}).map(requestAttachment);
|
|
643
|
+
const result = {
|
|
644
|
+
threadId: identifier(request.threadId, "threadId", { input: true }),
|
|
645
|
+
messageId: identifier(request.messageId, "messageId", { input: true, opaque: true }),
|
|
646
|
+
generationId: identifier(request.generationId, "generationId", { input: true, opaque: true }),
|
|
647
|
+
assistantMessageId: identifier(request.assistantMessageId, "assistantMessageId", { input: true, opaque: true }),
|
|
648
|
+
content,
|
|
649
|
+
expectedRevision,
|
|
650
|
+
expectedHash: request.expectedHash,
|
|
651
|
+
idempotencyKey: idempotencyKey(request.idempotencyKey, { input: true }),
|
|
652
|
+
...(request.attachment === undefined ? {} : { attachment: requestAttachment(request.attachment) }),
|
|
653
|
+
...(encodedAttachments === undefined ? {} : {
|
|
654
|
+
attachments: Object.freeze(encodedAttachments),
|
|
655
|
+
}),
|
|
656
|
+
};
|
|
657
|
+
if (result.attachments !== undefined) {
|
|
658
|
+
if (new Set(result.attachments.map((attachment) => attachment.attachmentId)).size !== result.attachments.length) {
|
|
659
|
+
throw new TypeError("prepared run attachments are invalid");
|
|
660
|
+
}
|
|
661
|
+
if (result.attachments.reduce((total, attachment) => total + decodedBase64Length(attachment.data), 0)
|
|
662
|
+
> VISION_IMAGE_TOTAL_LIMIT) {
|
|
663
|
+
throw new TypeError("prepared run attachments exceed the aggregate limit");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (new Set([result.messageId, result.generationId, result.assistantMessageId]).size !== 3) {
|
|
667
|
+
throw new TypeError("prepared run identifiers must be distinct");
|
|
668
|
+
}
|
|
669
|
+
return Object.freeze(result);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function serializedRunBody(ticket) {
|
|
673
|
+
const { idempotencyKey: _idempotencyKey, ...body } = ticket;
|
|
674
|
+
const serialized = JSON.stringify(body);
|
|
675
|
+
if (typeof serialized !== "string" || serialized.length > 24 * 1024 * 1024) {
|
|
676
|
+
throw new TypeError("prepared run body exceeds the browser request limit");
|
|
677
|
+
}
|
|
678
|
+
return serialized;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function cancellationTicket(value) {
|
|
682
|
+
const request = exactObject(value, ["threadId", "generationId", "idempotencyKey"], ["threadId", "generationId", "idempotencyKey"], "prepared cancellation", { input: true });
|
|
683
|
+
return Object.freeze({
|
|
684
|
+
threadId: identifier(request.threadId, "threadId", { input: true }),
|
|
685
|
+
generationId: identifier(request.generationId, "generationId", { input: true }),
|
|
686
|
+
idempotencyKey: idempotencyKey(request.idempotencyKey, { input: true }),
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function timeoutSignal(signal, timeoutMs) {
|
|
691
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("signal must be an AbortSignal");
|
|
692
|
+
const controller = new AbortController();
|
|
693
|
+
const forward = () => controller.abort(signal.reason ?? new DOMException("request aborted", "AbortError"));
|
|
694
|
+
if (signal?.aborted) forward();
|
|
695
|
+
else signal?.addEventListener("abort", forward, { once: true });
|
|
696
|
+
const timer = setTimeout(() => controller.abort(new DOMException("request timed out", "TimeoutError")), timeoutMs);
|
|
697
|
+
return Object.freeze({
|
|
698
|
+
signal: controller.signal,
|
|
699
|
+
dispose() {
|
|
700
|
+
clearTimeout(timer);
|
|
701
|
+
signal?.removeEventListener("abort", forward);
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function mediaType(response) {
|
|
707
|
+
return String(response.headers?.get?.("content-type") ?? "").split(";", 1)[0].trim().toLowerCase();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function requireResponse(value) {
|
|
711
|
+
if (value === null || typeof value !== "object" || !Number.isSafeInteger(value.status)
|
|
712
|
+
|| value.status < 100 || value.status > 599 || typeof value.headers?.get !== "function") {
|
|
713
|
+
throw new DirectChatProtocolError("Direct Chat transport returned an invalid response");
|
|
714
|
+
}
|
|
715
|
+
return value;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function requireNoStore(response) {
|
|
719
|
+
const directives = String(response.headers?.get?.("cache-control") ?? "")
|
|
720
|
+
.toLowerCase().split(",").map((value) => value.trim());
|
|
721
|
+
if (!directives.includes("no-store")) throw new DirectChatProtocolError("Direct Chat response is missing its no-store policy");
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function detachReader(reader) {
|
|
725
|
+
// A browser is allowed to defer settlement of the underlying source's
|
|
726
|
+
// cancel algorithm. Terminal generation authority has already arrived on
|
|
727
|
+
// the stream, so transport teardown must never keep the composer busy.
|
|
728
|
+
try {
|
|
729
|
+
const cancellation = reader.cancel();
|
|
730
|
+
if (cancellation && typeof cancellation.catch === "function") {
|
|
731
|
+
void cancellation.catch(() => { /* The delivery transport is already detaching. */ });
|
|
732
|
+
}
|
|
733
|
+
} catch { /* The delivery transport is already detaching. */ }
|
|
734
|
+
try { reader.releaseLock?.(); } catch { /* Cancellation still owns the reader. */ }
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function responseMatchesRoute(response, endpoint) {
|
|
738
|
+
if (response?.redirected === true || response?.type === "opaqueredirect") return false;
|
|
739
|
+
if (typeof response?.url !== "string" || response.url === "") return true;
|
|
740
|
+
try { return new URL(response.url).href === endpoint; }
|
|
741
|
+
catch { return false; }
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function readBoundedText(response, maximum) {
|
|
745
|
+
const advertised = response.headers?.get?.("content-length");
|
|
746
|
+
if (advertised !== null && advertised !== undefined
|
|
747
|
+
&& (!/^\d+$/u.test(advertised) || Number(advertised) > maximum)) {
|
|
748
|
+
throw new DirectChatProtocolError("Direct Chat response exceeded its size limit");
|
|
749
|
+
}
|
|
750
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
751
|
+
const result = await response.text();
|
|
752
|
+
if (utf8Length(result) > maximum) throw new DirectChatProtocolError("Direct Chat response exceeded its size limit");
|
|
753
|
+
return result;
|
|
754
|
+
}
|
|
755
|
+
const reader = response.body.getReader();
|
|
756
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
757
|
+
let size = 0;
|
|
758
|
+
let result = "";
|
|
759
|
+
try {
|
|
760
|
+
while (true) {
|
|
761
|
+
const { done, value } = await reader.read();
|
|
762
|
+
if (done) break;
|
|
763
|
+
if (!(value instanceof Uint8Array)) throw new DirectChatProtocolError("Direct Chat response returned a non-byte chunk");
|
|
764
|
+
size += value.byteLength;
|
|
765
|
+
if (size > maximum) throw new DirectChatProtocolError("Direct Chat response exceeded its size limit");
|
|
766
|
+
result += decoder.decode(value, { stream: true });
|
|
767
|
+
}
|
|
768
|
+
result += decoder.decode();
|
|
769
|
+
return result;
|
|
770
|
+
} catch (error) {
|
|
771
|
+
if (error instanceof DirectChatProtocolError) throw error;
|
|
772
|
+
throw new DirectChatProtocolError("Direct Chat response is not valid UTF-8");
|
|
773
|
+
} finally {
|
|
774
|
+
reader.releaseLock?.();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function readBoundedBytes(response, maximum) {
|
|
779
|
+
const advertised = response.headers?.get?.("content-length");
|
|
780
|
+
if (advertised !== null && advertised !== undefined
|
|
781
|
+
&& (!/^\d+$/u.test(advertised) || Number(advertised) < 1 || Number(advertised) > maximum)) {
|
|
782
|
+
throw new DirectChatProtocolError("Direct Chat attachment exceeded its size limit");
|
|
783
|
+
}
|
|
784
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
785
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
786
|
+
if (bytes.byteLength < 1 || bytes.byteLength > maximum) {
|
|
787
|
+
throw new DirectChatProtocolError("Direct Chat attachment exceeded its size limit");
|
|
788
|
+
}
|
|
789
|
+
return bytes;
|
|
790
|
+
}
|
|
791
|
+
const reader = response.body.getReader();
|
|
792
|
+
const chunks = [];
|
|
793
|
+
let size = 0;
|
|
794
|
+
let completed = false;
|
|
795
|
+
try {
|
|
796
|
+
while (true) {
|
|
797
|
+
const { done, value } = await reader.read();
|
|
798
|
+
if (done) {
|
|
799
|
+
completed = true;
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
if (!(value instanceof Uint8Array)) throw new DirectChatProtocolError("Direct Chat attachment returned a non-byte chunk");
|
|
803
|
+
size += value.byteLength;
|
|
804
|
+
if (size > maximum) throw new DirectChatProtocolError("Direct Chat attachment exceeded its size limit");
|
|
805
|
+
chunks.push(value);
|
|
806
|
+
}
|
|
807
|
+
if (size < 1) throw new DirectChatProtocolError("Direct Chat attachment is empty");
|
|
808
|
+
const bytes = new Uint8Array(size);
|
|
809
|
+
let offset = 0;
|
|
810
|
+
for (const chunk of chunks) {
|
|
811
|
+
bytes.set(chunk, offset);
|
|
812
|
+
offset += chunk.byteLength;
|
|
813
|
+
}
|
|
814
|
+
return bytes;
|
|
815
|
+
} finally {
|
|
816
|
+
if (!completed) detachReader(reader);
|
|
817
|
+
else reader.releaseLock?.();
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function responseFailure(response) {
|
|
822
|
+
let code = "request_failed";
|
|
823
|
+
if (mediaType(response) === "application/json") {
|
|
824
|
+
try {
|
|
825
|
+
const parsed = JSON.parse(await readBoundedText(response, ERROR_RESPONSE_LIMIT));
|
|
826
|
+
const envelope = exactObject(parsed, ["error"], ["error"], "error response");
|
|
827
|
+
const error = exactObject(envelope.error, ["code", "message"], ["code", "message"], "error");
|
|
828
|
+
unicodeScalar(error.message, "error.message", { minimum: 1, maximum: 512, controls: false });
|
|
829
|
+
if (typeof error.code === "string" && ERROR_CODE.test(error.code)) code = error.code;
|
|
830
|
+
} catch { code = "request_failed"; }
|
|
831
|
+
}
|
|
832
|
+
const status = Number.isSafeInteger(response?.status) ? response.status : 503;
|
|
833
|
+
return new DirectChatTransportError("Direct Chat request was not accepted.", {
|
|
834
|
+
code,
|
|
835
|
+
status,
|
|
836
|
+
retryable: ["request_aborted", "request_error"].includes(code)
|
|
837
|
+
|| [408, 425, 429].includes(status) || status >= 500,
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function transportFailure(error, signal) {
|
|
842
|
+
if (error instanceof DirectChatProtocolError || error instanceof DirectChatTransportError) return error;
|
|
843
|
+
const reason = signal?.aborted ? signal.reason : error;
|
|
844
|
+
if (reason?.name === "AbortError" || reason?.name === "TimeoutError") {
|
|
845
|
+
return new DirectChatTransportError("Direct Chat request was interrupted.", {
|
|
846
|
+
code: reason.name === "TimeoutError" ? "request_timeout" : "request_aborted",
|
|
847
|
+
status: reason.name === "TimeoutError" ? 504 : 499,
|
|
848
|
+
retryable: reason.name === "TimeoutError",
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
return new DirectChatTransportError("Direct Chat service is unavailable.");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function requestHeaders(csrf, idempotency, releaseId) {
|
|
855
|
+
const headers = addWebReleaseHeader(new Headers({
|
|
856
|
+
accept: "application/json",
|
|
857
|
+
"content-type": JSON_CONTENT_TYPE,
|
|
858
|
+
[CLOUD_CSRF_HEADER_NAME]: csrf,
|
|
859
|
+
}), releaseId);
|
|
860
|
+
if (idempotency !== undefined) headers.set("idempotency-key", idempotency);
|
|
861
|
+
return headers;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function csrfProvider(value) {
|
|
865
|
+
if (value === undefined) return () => undefined;
|
|
866
|
+
if (typeof value === "function") return value;
|
|
867
|
+
if (typeof value === "string") return () => value;
|
|
868
|
+
throw new TypeError("csrfToken must be a function or string");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function requirePinnedRelease(response, releaseId) {
|
|
872
|
+
const proof = inspectWebReleaseResponse(response, releaseId);
|
|
873
|
+
if (proof.kind === "unpinned" || proof.kind === "match") return;
|
|
874
|
+
if (proof.kind === "mismatch") {
|
|
875
|
+
throw new DirectChatTransportError("Direct Chat requires the current browser app release.", {
|
|
876
|
+
code: "client_release_mismatch",
|
|
877
|
+
status: 409,
|
|
878
|
+
retryable: false,
|
|
879
|
+
serverRelease: proof.releaseId,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
throw new DirectChatProtocolError("Direct Chat response is missing its release identity");
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function parseSseBlock(block) {
|
|
886
|
+
if (!block || block.split("\n").every((line) => line === "" || line.startsWith(":"))) return null;
|
|
887
|
+
const fields = Object.create(null);
|
|
888
|
+
for (const line of block.split("\n")) {
|
|
889
|
+
if (!line || line.startsWith(":")) continue;
|
|
890
|
+
const match = /^(id|event|data): ?([^\n]*)$/u.exec(line);
|
|
891
|
+
if (!match || Object.hasOwn(fields, match[1]) || match[2].includes("\u0000")) {
|
|
892
|
+
throw new DirectChatProtocolError("Direct Chat event stream contains an unsupported or repeated field");
|
|
893
|
+
}
|
|
894
|
+
fields[match[1]] = match[2];
|
|
895
|
+
}
|
|
896
|
+
if (!Object.hasOwn(fields, "event") || !Object.hasOwn(fields, "data")) {
|
|
897
|
+
throw new DirectChatProtocolError("Direct Chat event stream block is incomplete");
|
|
898
|
+
}
|
|
899
|
+
let value;
|
|
900
|
+
try { value = JSON.parse(fields.data); }
|
|
901
|
+
catch { throw new DirectChatProtocolError("Direct Chat event stream data is not valid JSON"); }
|
|
902
|
+
return Object.freeze({ id: fields.id, event: fields.event, value });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function* sseBlocks(response) {
|
|
906
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
907
|
+
throw new DirectChatProtocolError("Direct Chat event stream body is unavailable");
|
|
908
|
+
}
|
|
909
|
+
const reader = response.body.getReader();
|
|
910
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
911
|
+
let buffer = "";
|
|
912
|
+
let bytes = 0;
|
|
913
|
+
let pendingCarriageReturn = false;
|
|
914
|
+
let completed = false;
|
|
915
|
+
const normalize = (text, flush = false) => {
|
|
916
|
+
let result = "";
|
|
917
|
+
for (const character of text) {
|
|
918
|
+
if (pendingCarriageReturn) {
|
|
919
|
+
result += "\n";
|
|
920
|
+
pendingCarriageReturn = false;
|
|
921
|
+
if (character === "\n") continue;
|
|
922
|
+
}
|
|
923
|
+
if (character === "\r") pendingCarriageReturn = true;
|
|
924
|
+
else result += character;
|
|
925
|
+
}
|
|
926
|
+
if (flush && pendingCarriageReturn) {
|
|
927
|
+
result += "\n";
|
|
928
|
+
pendingCarriageReturn = false;
|
|
929
|
+
}
|
|
930
|
+
return result;
|
|
931
|
+
};
|
|
932
|
+
try {
|
|
933
|
+
while (true) {
|
|
934
|
+
const { done, value } = await reader.read();
|
|
935
|
+
if (done) {
|
|
936
|
+
completed = true;
|
|
937
|
+
buffer += normalize(decoder.decode(), true);
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
940
|
+
if (!(value instanceof Uint8Array)) throw new DirectChatProtocolError("Direct Chat stream returned a non-byte chunk");
|
|
941
|
+
bytes += value.byteLength;
|
|
942
|
+
if (bytes > STREAM_RESPONSE_LIMIT) throw new DirectChatProtocolError("Direct Chat stream exceeded its size limit");
|
|
943
|
+
buffer += normalize(decoder.decode(value, { stream: true }));
|
|
944
|
+
let boundary;
|
|
945
|
+
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
|
|
946
|
+
const block = buffer.slice(0, boundary);
|
|
947
|
+
if (utf8Length(block) > SSE_BLOCK_LIMIT) throw new DirectChatProtocolError("Direct Chat SSE block exceeded its size limit");
|
|
948
|
+
buffer = buffer.slice(boundary + 2);
|
|
949
|
+
const parsed = parseSseBlock(block);
|
|
950
|
+
if (parsed) yield parsed;
|
|
951
|
+
}
|
|
952
|
+
if (utf8Length(buffer) > SSE_BLOCK_LIMIT) throw new DirectChatProtocolError("Direct Chat SSE block exceeded its size limit");
|
|
953
|
+
}
|
|
954
|
+
if (buffer.trim() !== "") throw new DirectChatProtocolError("Direct Chat event stream ended with an incomplete block");
|
|
955
|
+
} catch (error) {
|
|
956
|
+
if (error instanceof DirectChatProtocolError || error?.name === "AbortError" || error?.name === "TimeoutError") throw error;
|
|
957
|
+
throw new DirectChatTransportError("Direct Chat event delivery was interrupted.");
|
|
958
|
+
} finally {
|
|
959
|
+
if (!completed) detachReader(reader);
|
|
960
|
+
else reader.releaseLock?.();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function eventRequest(value) {
|
|
965
|
+
const request = exactObject(value, [
|
|
966
|
+
"threadId", "generationId", "afterSequence", "signal", "onCursor", "maxReconnects",
|
|
967
|
+
], ["threadId", "generationId"], "run events request", { input: true });
|
|
968
|
+
const result = {
|
|
969
|
+
threadId: identifier(request.threadId, "threadId", { input: true }),
|
|
970
|
+
generationId: identifier(request.generationId, "generationId", { input: true }),
|
|
971
|
+
afterSequence: integer(request.afterSequence ?? 0, "afterSequence", { maximum: 8_192, input: true }),
|
|
972
|
+
signal: request.signal,
|
|
973
|
+
onCursor: request.onCursor,
|
|
974
|
+
maxReconnects: integer(request.maxReconnects ?? 20, "maxReconnects", { maximum: 100, input: true }),
|
|
975
|
+
};
|
|
976
|
+
if (result.signal !== undefined && !(result.signal instanceof AbortSignal)) throw new TypeError("signal must be an AbortSignal");
|
|
977
|
+
if (result.onCursor !== undefined && typeof result.onCursor !== "function") throw new TypeError("onCursor must be a function");
|
|
978
|
+
return result;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export class DirectChatBrowserClient {
|
|
982
|
+
constructor(options = {}) {
|
|
983
|
+
const config = exactObject(options, [
|
|
984
|
+
"baseUrl", "fetchImpl", "cookieSource", "csrfToken", "releaseId", "makeOpaqueId", "timeoutMs", "streamTimeoutMs", "wait",
|
|
985
|
+
], [], "Direct Chat client options", { input: true });
|
|
986
|
+
const baseUrl = config.baseUrl;
|
|
987
|
+
const fetchImpl = config.fetchImpl ?? globalThis.fetch;
|
|
988
|
+
const cookieSource = config.cookieSource;
|
|
989
|
+
const makeOpaqueId = config.makeOpaqueId ?? createBrowserOpaqueId;
|
|
990
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
991
|
+
const streamTimeoutMs = config.streamTimeoutMs ?? DEFAULT_STREAM_TIMEOUT_MS;
|
|
992
|
+
const wait = config.wait ?? ((milliseconds, signal) => new Promise((resolve, reject) => {
|
|
993
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
994
|
+
signal?.addEventListener("abort", () => {
|
|
995
|
+
clearTimeout(timer);
|
|
996
|
+
reject(signal.reason ?? new DOMException("request aborted", "AbortError"));
|
|
997
|
+
}, { once: true });
|
|
998
|
+
}));
|
|
999
|
+
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function");
|
|
1000
|
+
if (typeof makeOpaqueId !== "function") throw new TypeError("makeOpaqueId must be a function");
|
|
1001
|
+
if (typeof wait !== "function") throw new TypeError("wait must be a function");
|
|
1002
|
+
for (const [name, value] of [["timeoutMs", timeoutMs], ["streamTimeoutMs", streamTimeoutMs]]) {
|
|
1003
|
+
if (!Number.isSafeInteger(value) || value < 1_000 || value > 120_000) throw new TypeError(`${name} is invalid`);
|
|
1004
|
+
}
|
|
1005
|
+
this.baseOrigin = normalizedBaseOrigin(baseUrl);
|
|
1006
|
+
this.fetch = fetchImpl === globalThis.fetch ? fetchImpl.bind(globalThis) : fetchImpl;
|
|
1007
|
+
this.readCookie = cookieReader(cookieSource);
|
|
1008
|
+
this.readRetainedCsrf = csrfProvider(config.csrfToken);
|
|
1009
|
+
this.releaseId = optionalWebRelease(config.releaseId);
|
|
1010
|
+
this.makeOpaqueId = makeOpaqueId;
|
|
1011
|
+
this.timeoutMs = timeoutMs;
|
|
1012
|
+
this.streamTimeoutMs = streamTimeoutMs;
|
|
1013
|
+
this.wait = wait;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
#csrf() {
|
|
1017
|
+
const cookie = readCloudCsrfCookie(this.readCookie);
|
|
1018
|
+
const retained = this.readRetainedCsrf();
|
|
1019
|
+
if (retained !== undefined && (typeof retained !== "string" || !CSRF_TOKEN.test(retained))) {
|
|
1020
|
+
throw new TypeError("CSRF token is invalid");
|
|
1021
|
+
}
|
|
1022
|
+
if (cookie !== undefined && retained !== undefined && cookie !== retained) {
|
|
1023
|
+
throw new DirectChatTransportError("Direct Chat request was not accepted.", {
|
|
1024
|
+
code: "authentication_required",
|
|
1025
|
+
status: 401,
|
|
1026
|
+
retryable: false,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
const token = cookie ?? retained;
|
|
1030
|
+
if (token === undefined) {
|
|
1031
|
+
throw new DirectChatTransportError("Direct Chat request was not accepted.", {
|
|
1032
|
+
code: "authentication_required",
|
|
1033
|
+
status: 401,
|
|
1034
|
+
retryable: false,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
return token;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async #post(route, body, {
|
|
1041
|
+
signal, idempotency, expectedStatus = 200, timeoutMs = this.timeoutMs, serializedBody,
|
|
1042
|
+
} = {}) {
|
|
1043
|
+
const endpoint = `${this.baseOrigin}${route}`;
|
|
1044
|
+
// Serialization is a local pre-dispatch operation. Keep allocation errors
|
|
1045
|
+
// outside the transport ambiguity boundary so the PWA can truthfully keep
|
|
1046
|
+
// the draft as not sent.
|
|
1047
|
+
const requestBody = serializedBody ?? JSON.stringify(body);
|
|
1048
|
+
const deadline = timeoutSignal(signal, timeoutMs);
|
|
1049
|
+
try {
|
|
1050
|
+
const response = requireResponse(await this.fetch(endpoint, {
|
|
1051
|
+
method: "POST",
|
|
1052
|
+
credentials: "same-origin",
|
|
1053
|
+
cache: "no-store",
|
|
1054
|
+
redirect: "error",
|
|
1055
|
+
referrerPolicy: "same-origin",
|
|
1056
|
+
headers: requestHeaders(this.#csrf(), idempotency, this.releaseId),
|
|
1057
|
+
body: requestBody,
|
|
1058
|
+
signal: deadline.signal,
|
|
1059
|
+
}));
|
|
1060
|
+
if (!responseMatchesRoute(response, endpoint)) throw new DirectChatProtocolError("Direct Chat response came from an unexpected URL");
|
|
1061
|
+
requirePinnedRelease(response, this.releaseId);
|
|
1062
|
+
requireNoStore(response);
|
|
1063
|
+
if (response.status !== expectedStatus) throw await responseFailure(response);
|
|
1064
|
+
if (mediaType(response) !== "application/json") throw new DirectChatProtocolError("Direct Chat response content type is invalid");
|
|
1065
|
+
let value;
|
|
1066
|
+
try { value = JSON.parse(await readBoundedText(response, JSON_RESPONSE_LIMIT)); }
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
if (error instanceof DirectChatProtocolError) throw error;
|
|
1069
|
+
throw new DirectChatProtocolError("Direct Chat response is not valid JSON");
|
|
1070
|
+
}
|
|
1071
|
+
return value;
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
throw transportFailure(error, deadline.signal);
|
|
1074
|
+
} finally {
|
|
1075
|
+
deadline.dispose();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async #postAttachment(body, expected, { signal } = {}) {
|
|
1080
|
+
const endpoint = `${this.baseOrigin}${DIRECT_CHAT_ROUTES.attachmentsGet}`;
|
|
1081
|
+
const deadline = timeoutSignal(signal, this.timeoutMs);
|
|
1082
|
+
try {
|
|
1083
|
+
const response = requireResponse(await this.fetch(endpoint, {
|
|
1084
|
+
method: "POST",
|
|
1085
|
+
credentials: "same-origin",
|
|
1086
|
+
cache: "no-store",
|
|
1087
|
+
redirect: "error",
|
|
1088
|
+
referrerPolicy: "same-origin",
|
|
1089
|
+
headers: requestHeaders(this.#csrf(), undefined, this.releaseId),
|
|
1090
|
+
body: JSON.stringify(body),
|
|
1091
|
+
signal: deadline.signal,
|
|
1092
|
+
}));
|
|
1093
|
+
if (!responseMatchesRoute(response, endpoint)) throw new DirectChatProtocolError("Direct Chat attachment came from an unexpected URL");
|
|
1094
|
+
requirePinnedRelease(response, this.releaseId);
|
|
1095
|
+
requireNoStore(response);
|
|
1096
|
+
if (response.status !== 200) throw await responseFailure(response);
|
|
1097
|
+
if (mediaType(response) !== expected.mediaType) throw new DirectChatProtocolError("Direct Chat attachment content type is invalid");
|
|
1098
|
+
const bytes = await readBoundedBytes(response, VISION_IMAGE_LIMIT);
|
|
1099
|
+
if (bytes.byteLength !== expected.byteLength) throw new DirectChatProtocolError("Direct Chat attachment size is inconsistent");
|
|
1100
|
+
return bytes;
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
throw transportFailure(error, deadline.signal);
|
|
1103
|
+
} finally {
|
|
1104
|
+
deadline.dispose();
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
prepareThread(value = {}) {
|
|
1109
|
+
const request = exactObject(value, ["title"], [], "new thread", { input: true });
|
|
1110
|
+
const title = request.title ?? "";
|
|
1111
|
+
return threadTicket({
|
|
1112
|
+
threadId: generated(this.makeOpaqueId, "chat"),
|
|
1113
|
+
title: unicodeScalar(title, "title", { maximum: 512, controls: false, input: true }),
|
|
1114
|
+
idempotencyKey: generated(this.makeOpaqueId, "thread_create", { idempotency: true }),
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async capabilities(value = {}) {
|
|
1119
|
+
const request = exactObject(value, ["signal"], [], "chat capabilities request", { input: true });
|
|
1120
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.capabilities, {}, {
|
|
1121
|
+
signal: request.signal,
|
|
1122
|
+
}), ["visionInput", "visionMediaTypes", "maximumImageBytes"], [
|
|
1123
|
+
"visionInput", "visionMediaTypes", "maximumImageBytes",
|
|
1124
|
+
], "chat capabilities response");
|
|
1125
|
+
if (typeof response.visionInput !== "boolean" || !Array.isArray(response.visionMediaTypes)
|
|
1126
|
+
|| Object.getPrototypeOf(response.visionMediaTypes) !== Array.prototype
|
|
1127
|
+
|| response.visionMediaTypes.some((type) => !['image/jpeg', 'image/png'].includes(type))
|
|
1128
|
+
|| new Set(response.visionMediaTypes).size !== response.visionMediaTypes.length
|
|
1129
|
+
|| !Number.isSafeInteger(response.maximumImageBytes)
|
|
1130
|
+
|| (response.visionInput
|
|
1131
|
+
? response.maximumImageBytes !== VISION_IMAGE_LIMIT
|
|
1132
|
+
|| response.visionMediaTypes.join(',') !== 'image/jpeg,image/png'
|
|
1133
|
+
: response.maximumImageBytes !== 0 || response.visionMediaTypes.length !== 0)) {
|
|
1134
|
+
throw new DirectChatProtocolError("chat capabilities response is invalid");
|
|
1135
|
+
}
|
|
1136
|
+
return Object.freeze({
|
|
1137
|
+
visionInput: response.visionInput,
|
|
1138
|
+
visionMediaTypes: Object.freeze([...response.visionMediaTypes]),
|
|
1139
|
+
maximumImageBytes: response.maximumImageBytes,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
async createThread(prepared, options = {}) {
|
|
1144
|
+
const { signal } = exactObject(options, ["signal"], [], "create thread options", { input: true });
|
|
1145
|
+
const ticket = threadTicket(prepared);
|
|
1146
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.threadsCreate, {
|
|
1147
|
+
threadId: ticket.threadId,
|
|
1148
|
+
title: ticket.title,
|
|
1149
|
+
}, { signal, idempotency: ticket.idempotencyKey, expectedStatus: 201 }), ["thread"], ["thread"], "thread creation response");
|
|
1150
|
+
return Object.freeze({ request: ticket, thread: responseThread(response.thread, ticket.threadId) });
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
retryCreateThread(prepared, options) {
|
|
1154
|
+
return this.createThread(prepared, options);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async listThreads(value = {}) {
|
|
1158
|
+
const request = exactObject(value, ["limit", "signal"], [], "thread list request", { input: true });
|
|
1159
|
+
const limit = request.limit ?? 50;
|
|
1160
|
+
const signal = request.signal;
|
|
1161
|
+
integer(limit, "limit", { minimum: 1, maximum: 200, input: true });
|
|
1162
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.threadsList, { limit }, { signal }), ["threads"], ["threads"], "thread list response");
|
|
1163
|
+
if (!Array.isArray(response.threads) || response.threads.length > 200) throw new DirectChatProtocolError("thread list is invalid");
|
|
1164
|
+
const threads = response.threads.map((thread) => responseThread(thread));
|
|
1165
|
+
if (new Set(threads.map((thread) => thread.threadId)).size !== threads.length) {
|
|
1166
|
+
throw new DirectChatProtocolError("thread list contains a duplicate identifier");
|
|
1167
|
+
}
|
|
1168
|
+
return Object.freeze({ threads: Object.freeze(threads) });
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async getThread(threadId, options = {}) {
|
|
1172
|
+
const { signal } = exactObject(options, ["signal"], [], "get thread options", { input: true });
|
|
1173
|
+
identifier(threadId, "threadId", { input: true });
|
|
1174
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.threadsGet, { threadId }, { signal }), ["thread"], ["thread"], "thread response");
|
|
1175
|
+
return Object.freeze({ thread: responseThread(response.thread, threadId) });
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
prepareThreadDeletion(value = {}) {
|
|
1179
|
+
const request = exactObject(value, [
|
|
1180
|
+
"threadId", "expectedRevision", "expectedHash",
|
|
1181
|
+
], [
|
|
1182
|
+
"threadId", "expectedRevision", "expectedHash",
|
|
1183
|
+
], "new thread deletion", { input: true });
|
|
1184
|
+
return threadDeletionTicket({
|
|
1185
|
+
...request,
|
|
1186
|
+
idempotencyKey: generated(this.makeOpaqueId, "thread_delete", { idempotency: true }),
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async deleteThread(prepared, options = {}) {
|
|
1191
|
+
const { signal } = exactObject(options, ["signal"], [], "delete thread options", { input: true });
|
|
1192
|
+
const ticket = threadDeletionTicket(prepared);
|
|
1193
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.threadsDelete, {
|
|
1194
|
+
threadId: ticket.threadId,
|
|
1195
|
+
expectedRevision: ticket.expectedRevision,
|
|
1196
|
+
expectedHash: ticket.expectedHash,
|
|
1197
|
+
}, { signal, idempotency: ticket.idempotencyKey }), ["deleted", "threadId"], [
|
|
1198
|
+
"deleted", "threadId",
|
|
1199
|
+
], "thread deletion response");
|
|
1200
|
+
if (response.deleted !== true
|
|
1201
|
+
|| identifier(response.threadId, "thread deletion response threadId") !== ticket.threadId) {
|
|
1202
|
+
throw new DirectChatProtocolError("thread deletion response is invalid");
|
|
1203
|
+
}
|
|
1204
|
+
return Object.freeze({ request: ticket, deleted: true, threadId: ticket.threadId });
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
retryDeleteThread(prepared, options) {
|
|
1208
|
+
return this.deleteThread(prepared, options);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async listMessages(value = {}) {
|
|
1212
|
+
const request = exactObject(
|
|
1213
|
+
value,
|
|
1214
|
+
["threadId", "afterRevision", "limit", "signal"],
|
|
1215
|
+
["threadId"],
|
|
1216
|
+
"message list request",
|
|
1217
|
+
{ input: true },
|
|
1218
|
+
);
|
|
1219
|
+
const threadId = request.threadId;
|
|
1220
|
+
const afterRevision = request.afterRevision ?? 0;
|
|
1221
|
+
const limit = request.limit ?? 100;
|
|
1222
|
+
const signal = request.signal;
|
|
1223
|
+
identifier(threadId, "threadId", { input: true });
|
|
1224
|
+
integer(afterRevision, "afterRevision", { maximum: 2_000, input: true });
|
|
1225
|
+
integer(limit, "limit", { minimum: 1, maximum: 200, input: true });
|
|
1226
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.messagesList, {
|
|
1227
|
+
threadId,
|
|
1228
|
+
afterRevision,
|
|
1229
|
+
limit,
|
|
1230
|
+
attachmentSchema: 2,
|
|
1231
|
+
}, { signal }), ["messages"], ["messages"], "message list response");
|
|
1232
|
+
if (!Array.isArray(response.messages) || response.messages.length > limit) throw new DirectChatProtocolError("message list is invalid");
|
|
1233
|
+
const messages = response.messages.map((message) => responseMessage(message, threadId));
|
|
1234
|
+
let previousRevision = afterRevision;
|
|
1235
|
+
let previousMessageHash;
|
|
1236
|
+
for (const message of messages) {
|
|
1237
|
+
if (message.revision !== previousRevision + 1) throw new DirectChatProtocolError("message revisions are not contiguous");
|
|
1238
|
+
if (previousMessageHash !== undefined && message.previousHash !== previousMessageHash) {
|
|
1239
|
+
throw new DirectChatProtocolError("message hash chain is inconsistent");
|
|
1240
|
+
}
|
|
1241
|
+
previousRevision = message.revision;
|
|
1242
|
+
previousMessageHash = message.messageHash;
|
|
1243
|
+
}
|
|
1244
|
+
return Object.freeze({ messages: Object.freeze(messages) });
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async getAttachment(value = {}) {
|
|
1248
|
+
const request = exactObject(value, ["threadId", "attachment", "signal"], [
|
|
1249
|
+
"threadId", "attachment",
|
|
1250
|
+
], "attachment request", { input: true });
|
|
1251
|
+
const threadId = identifier(request.threadId, "threadId", { input: true });
|
|
1252
|
+
const descriptor = exactObject(request.attachment, [
|
|
1253
|
+
"attachmentId", "mediaType", "byteLength", "width", "height", "sha256",
|
|
1254
|
+
], [
|
|
1255
|
+
"attachmentId", "mediaType", "byteLength", "width", "height", "sha256",
|
|
1256
|
+
], "attachment descriptor", { input: true });
|
|
1257
|
+
const normalized = {
|
|
1258
|
+
attachmentId: identifier(descriptor.attachmentId, "attachmentId", { input: true }),
|
|
1259
|
+
mediaType: descriptor.mediaType,
|
|
1260
|
+
byteLength: integer(descriptor.byteLength, "byteLength", { minimum: 1, maximum: VISION_IMAGE_LIMIT, input: true }),
|
|
1261
|
+
width: integer(descriptor.width, "width", { minimum: 1, maximum: 4_096, input: true }),
|
|
1262
|
+
height: integer(descriptor.height, "height", { minimum: 1, maximum: 4_096, input: true }),
|
|
1263
|
+
sha256: descriptor.sha256,
|
|
1264
|
+
};
|
|
1265
|
+
if (!['image/jpeg', 'image/png'].includes(normalized.mediaType)
|
|
1266
|
+
|| normalized.width * normalized.height > 16 * 1024 * 1024
|
|
1267
|
+
|| typeof normalized.sha256 !== "string" || !HASH.test(normalized.sha256)) {
|
|
1268
|
+
throw new TypeError("attachment descriptor is invalid");
|
|
1269
|
+
}
|
|
1270
|
+
const bytes = await this.#postAttachment({ threadId, attachmentId: normalized.attachmentId }, normalized, {
|
|
1271
|
+
signal: request.signal,
|
|
1272
|
+
});
|
|
1273
|
+
if (await sha256Bytes(bytes) !== normalized.sha256) {
|
|
1274
|
+
throw new DirectChatProtocolError("Direct Chat attachment digest is inconsistent");
|
|
1275
|
+
}
|
|
1276
|
+
return Object.freeze({
|
|
1277
|
+
descriptor: Object.freeze(normalized),
|
|
1278
|
+
bytes,
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
prepareRun(value = {}) {
|
|
1283
|
+
const request = exactObject(
|
|
1284
|
+
value,
|
|
1285
|
+
["threadId", "content", "expectedRevision", "expectedHash", "attachment", "attachments"],
|
|
1286
|
+
["threadId", "content", "expectedRevision", "expectedHash"],
|
|
1287
|
+
"new run",
|
|
1288
|
+
{ input: true },
|
|
1289
|
+
);
|
|
1290
|
+
const { threadId, content, expectedRevision, expectedHash } = request;
|
|
1291
|
+
if (request.attachment !== undefined && request.attachments !== undefined) {
|
|
1292
|
+
throw new TypeError("new run attachment shape is ambiguous");
|
|
1293
|
+
}
|
|
1294
|
+
identifier(threadId, "threadId", { input: true });
|
|
1295
|
+
const ids = {
|
|
1296
|
+
messageId: generated(this.makeOpaqueId, "message"),
|
|
1297
|
+
generationId: generated(this.makeOpaqueId, "generation"),
|
|
1298
|
+
assistantMessageId: generated(this.makeOpaqueId, "assistant"),
|
|
1299
|
+
};
|
|
1300
|
+
const ticket = runTicket({
|
|
1301
|
+
threadId,
|
|
1302
|
+
...ids,
|
|
1303
|
+
content,
|
|
1304
|
+
expectedRevision,
|
|
1305
|
+
expectedHash,
|
|
1306
|
+
...(request.attachment === undefined ? {} : { attachment: canonicalAttachment(request.attachment) }),
|
|
1307
|
+
...(request.attachments === undefined ? {} : { attachments: canonicalAttachments(request.attachments) }),
|
|
1308
|
+
idempotencyKey: generated(this.makeOpaqueId, "run_start", { idempotency: true }),
|
|
1309
|
+
});
|
|
1310
|
+
PREPARED_RUN_BODIES.set(ticket, serializedRunBody(ticket));
|
|
1311
|
+
return ticket;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async startRun(prepared, options = {}) {
|
|
1315
|
+
const { signal } = exactObject(options, ["signal"], [], "start run options", { input: true });
|
|
1316
|
+
const ticket = runTicket(prepared);
|
|
1317
|
+
const { idempotencyKey: key, ...body } = ticket;
|
|
1318
|
+
const serializedBody = PREPARED_RUN_BODIES.get(prepared) ?? serializedRunBody(ticket);
|
|
1319
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.runsStart, body, {
|
|
1320
|
+
signal,
|
|
1321
|
+
idempotency: key,
|
|
1322
|
+
expectedStatus: 202,
|
|
1323
|
+
timeoutMs: ticket.attachment === undefined && ticket.attachments === undefined
|
|
1324
|
+
? this.timeoutMs
|
|
1325
|
+
: Math.max(this.timeoutMs, VISION_MUTATION_TIMEOUT_MS),
|
|
1326
|
+
serializedBody,
|
|
1327
|
+
}), ["generation"], ["generation"], "run start response");
|
|
1328
|
+
return Object.freeze({
|
|
1329
|
+
request: ticket,
|
|
1330
|
+
generation: responseGeneration(response.generation, ticket),
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
retryRun(prepared, options) {
|
|
1335
|
+
return this.startRun(prepared, options);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async getRunStatus(value = {}) {
|
|
1339
|
+
const request = exactObject(
|
|
1340
|
+
value,
|
|
1341
|
+
["threadId", "generationId", "signal"],
|
|
1342
|
+
["threadId", "generationId"],
|
|
1343
|
+
"run status request",
|
|
1344
|
+
{ input: true },
|
|
1345
|
+
);
|
|
1346
|
+
const { threadId, generationId, signal } = request;
|
|
1347
|
+
identifier(threadId, "threadId", { input: true });
|
|
1348
|
+
identifier(generationId, "generationId", { input: true });
|
|
1349
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.runsStatus, {
|
|
1350
|
+
threadId,
|
|
1351
|
+
generationId,
|
|
1352
|
+
}, { signal }), ["generation"], ["generation"], "run status response");
|
|
1353
|
+
return Object.freeze({ generation: responseGeneration(response.generation, { threadId, generationId }) });
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
prepareCancellation(value = {}) {
|
|
1357
|
+
const request = exactObject(
|
|
1358
|
+
value,
|
|
1359
|
+
["threadId", "generationId"],
|
|
1360
|
+
["threadId", "generationId"],
|
|
1361
|
+
"new cancellation",
|
|
1362
|
+
{ input: true },
|
|
1363
|
+
);
|
|
1364
|
+
const { threadId, generationId } = request;
|
|
1365
|
+
identifier(threadId, "threadId", { input: true });
|
|
1366
|
+
identifier(generationId, "generationId", { input: true });
|
|
1367
|
+
return cancellationTicket({
|
|
1368
|
+
threadId,
|
|
1369
|
+
generationId,
|
|
1370
|
+
idempotencyKey: generated(this.makeOpaqueId, "run_cancel", { idempotency: true }),
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
async cancelRun(prepared, options = {}) {
|
|
1375
|
+
const { signal } = exactObject(options, ["signal"], [], "cancel run options", { input: true });
|
|
1376
|
+
const ticket = cancellationTicket(prepared);
|
|
1377
|
+
const response = exactObject(await this.#post(DIRECT_CHAT_ROUTES.runsCancel, {
|
|
1378
|
+
threadId: ticket.threadId,
|
|
1379
|
+
generationId: ticket.generationId,
|
|
1380
|
+
}, { signal, idempotency: ticket.idempotencyKey }), ["generation"], ["generation"], "run cancellation response");
|
|
1381
|
+
const generation = responseGeneration(response.generation, ticket);
|
|
1382
|
+
if (generation.status !== "cancelled") throw new DirectChatProtocolError("run cancellation did not return a cancelled generation");
|
|
1383
|
+
return Object.freeze({ request: ticket, generation });
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
async *streamRunEvents(value = {}) {
|
|
1387
|
+
const request = eventRequest(value);
|
|
1388
|
+
let afterSequence = request.afterSequence;
|
|
1389
|
+
let lastDeliveredDeltaHash;
|
|
1390
|
+
let reconnects = 0;
|
|
1391
|
+
while (!request.signal?.aborted) {
|
|
1392
|
+
const endpoint = `${this.baseOrigin}${DIRECT_CHAT_ROUTES.runsEvents}`;
|
|
1393
|
+
const deadline = timeoutSignal(request.signal, this.streamTimeoutMs);
|
|
1394
|
+
let reconnect = false;
|
|
1395
|
+
try {
|
|
1396
|
+
const response = requireResponse(await this.fetch(endpoint, {
|
|
1397
|
+
method: "POST",
|
|
1398
|
+
credentials: "same-origin",
|
|
1399
|
+
cache: "no-store",
|
|
1400
|
+
redirect: "error",
|
|
1401
|
+
referrerPolicy: "same-origin",
|
|
1402
|
+
headers: addWebReleaseHeader(new Headers({
|
|
1403
|
+
accept: "text/event-stream",
|
|
1404
|
+
"content-type": JSON_CONTENT_TYPE,
|
|
1405
|
+
[CLOUD_CSRF_HEADER_NAME]: this.#csrf(),
|
|
1406
|
+
}), this.releaseId),
|
|
1407
|
+
body: JSON.stringify({
|
|
1408
|
+
threadId: request.threadId,
|
|
1409
|
+
generationId: request.generationId,
|
|
1410
|
+
afterSequence,
|
|
1411
|
+
}),
|
|
1412
|
+
signal: deadline.signal,
|
|
1413
|
+
}));
|
|
1414
|
+
if (!responseMatchesRoute(response, endpoint)) throw new DirectChatProtocolError("Direct Chat stream came from an unexpected URL");
|
|
1415
|
+
requirePinnedRelease(response, this.releaseId);
|
|
1416
|
+
requireNoStore(response);
|
|
1417
|
+
if (response.status !== 200) throw await responseFailure(response);
|
|
1418
|
+
if (mediaType(response) !== "text/event-stream") throw new DirectChatProtocolError("Direct Chat stream content type is invalid");
|
|
1419
|
+
for await (const block of sseBlocks(response)) {
|
|
1420
|
+
if (block.event === "delta") {
|
|
1421
|
+
if (block.id === undefined || !/^\d+$/u.test(block.id)) throw new DirectChatProtocolError("delta SSE id is invalid");
|
|
1422
|
+
const delta = responseDelta(block.value, request, afterSequence);
|
|
1423
|
+
if (String(delta.sequence) !== block.id) throw new DirectChatProtocolError("delta SSE id does not match its envelope");
|
|
1424
|
+
if (lastDeliveredDeltaHash !== undefined && delta.previousHash !== lastDeliveredDeltaHash) {
|
|
1425
|
+
throw new DirectChatProtocolError("delivered delta hash chain is inconsistent");
|
|
1426
|
+
}
|
|
1427
|
+
afterSequence = delta.sequence;
|
|
1428
|
+
lastDeliveredDeltaHash = delta.deltaHash;
|
|
1429
|
+
if (request.onCursor) {
|
|
1430
|
+
try { await request.onCursor(Object.freeze({ afterSequence }), delta); }
|
|
1431
|
+
catch { throw new DirectChatProtocolError("generation cursor persistence failed", { code: "cursor_persistence_failed" }); }
|
|
1432
|
+
}
|
|
1433
|
+
yield Object.freeze({ type: "delta", delta, afterSequence });
|
|
1434
|
+
} else if (block.event === "generation") {
|
|
1435
|
+
if (block.id !== undefined) throw new DirectChatProtocolError("generation SSE block may not contain an id");
|
|
1436
|
+
const generation = responseGeneration(block.value, request);
|
|
1437
|
+
if (!generation.terminal) throw new DirectChatProtocolError("generation SSE block is not terminal");
|
|
1438
|
+
if (!generation.deltasPruned && generation.deltaCount !== afterSequence) {
|
|
1439
|
+
throw new DirectChatProtocolError("terminal generation cursor does not match delivered deltas");
|
|
1440
|
+
}
|
|
1441
|
+
if (lastDeliveredDeltaHash !== undefined && generation.lastDeltaHash !== lastDeliveredDeltaHash) {
|
|
1442
|
+
throw new DirectChatProtocolError("terminal generation hash does not match delivered deltas");
|
|
1443
|
+
}
|
|
1444
|
+
yield Object.freeze({ type: "generation", generation, afterSequence });
|
|
1445
|
+
return;
|
|
1446
|
+
} else if (block.event === "reconnect") {
|
|
1447
|
+
if (block.id !== undefined) throw new DirectChatProtocolError("reconnect SSE block may not contain an id");
|
|
1448
|
+
const cursor = exactObject(block.value, ["afterSequence"], ["afterSequence"], "reconnect event");
|
|
1449
|
+
if (integer(cursor.afterSequence, "reconnect.afterSequence", { maximum: 8_192 }) !== afterSequence) {
|
|
1450
|
+
throw new DirectChatProtocolError("reconnect cursor does not match delivered deltas");
|
|
1451
|
+
}
|
|
1452
|
+
reconnect = true;
|
|
1453
|
+
} else {
|
|
1454
|
+
throw new DirectChatProtocolError("Direct Chat stream contains an unsupported event type");
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (!reconnect) throw new DirectChatProtocolError("Direct Chat stream ended without a terminal or reconnect event");
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
if (request.signal?.aborted) return;
|
|
1460
|
+
const failure = transportFailure(error, deadline.signal);
|
|
1461
|
+
if (failure instanceof DirectChatProtocolError || !failure.retryable) throw failure;
|
|
1462
|
+
reconnect = true;
|
|
1463
|
+
} finally {
|
|
1464
|
+
deadline.dispose();
|
|
1465
|
+
}
|
|
1466
|
+
if (!reconnect) return;
|
|
1467
|
+
if (reconnects >= request.maxReconnects) {
|
|
1468
|
+
throw new DirectChatTransportError("Direct Chat event delivery was interrupted.", {
|
|
1469
|
+
code: "stream_interrupted",
|
|
1470
|
+
status: 503,
|
|
1471
|
+
retryable: true,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
reconnects += 1;
|
|
1475
|
+
try { await this.wait(Math.min(2_000, 100 * (2 ** (reconnects - 1))), request.signal); }
|
|
1476
|
+
catch (error) {
|
|
1477
|
+
if (request.signal?.aborted) return;
|
|
1478
|
+
throw transportFailure(error, request.signal);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|