@hue-run/sdk 0.17.0 → 0.18.0

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.
@@ -0,0 +1,907 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { isLoopbackHost } from "../config.js";
4
+ import { uuid } from "./json.js";
5
+ const messages = {
6
+ not_found: "Hue holds no file with this digest for the trace or snapshot",
7
+ missing: "The trace lists this file, but its bytes were never uploaded or have been erased",
8
+ unavailable: "Hue could not read the file now; retry later",
9
+ forbidden: "The key cannot read recorded content; use a Read or Read and write key",
10
+ too_large: "The file is larger than maxBytes",
11
+ digest_mismatch: "The downloaded bytes do not match the requested SHA-256",
12
+ failed: "The media request failed",
13
+ };
14
+ /** Thrown by {@link MediaClient.fetch}. The message is fixed and never includes response text. */
15
+ export class MediaFetchError extends Error {
16
+ reason;
17
+ status;
18
+ retryAfterSeconds;
19
+ constructor(
20
+ /** What failed. */
21
+ reason,
22
+ /** HTTP status of the answer that failed, when there was one. */
23
+ status,
24
+ /** Seconds Hue asked the caller to wait, on `unavailable`, when it said. */
25
+ retryAfterSeconds, options) {
26
+ super(reason === "failed" && status ? `${messages.failed} (HTTP ${status})` : messages[reason], options);
27
+ this.reason = reason;
28
+ this.status = status;
29
+ this.retryAfterSeconds = retryAfterSeconds;
30
+ this.name = "MediaFetchError";
31
+ }
32
+ }
33
+ const SHA256 = /^[0-9a-f]{64}$/;
34
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
35
+ const TRACE = /^[0-9a-f]{32}$/i;
36
+ const SPAN = /^[0-9a-f]{16}$/i;
37
+ const MEDIA_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+$/;
38
+ /** The default and largest `maxBytes`: Hue keeps values of up to 1 GB. */
39
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
40
+ const MAX_BYTES = 1_000_000_000;
41
+ /** The least time a download may go without a byte before it ends: two minutes. Tests shorten it;
42
+ * it is not exported from the package. */
43
+ export const mediaDownload = { minIdleMillis: 120_000 };
44
+ /** The longest `Retry-After` the one retry of an unavailable read waits. */
45
+ const MAX_RETRY_AFTER_SECONDS = 10;
46
+ /** Bytes of a JSON answer read: a link answer or an error body. */
47
+ const MAX_ANSWER_BYTES = 64 * 1024;
48
+ /** What the SDKs keep of text media over 64 KiB in its content field: its first 16 KiB. */
49
+ const PREFIX_BYTES = 16 * 1024;
50
+ /** Nesting a value is walked to; deeper parts stay as they are. */
51
+ const MAX_DEPTH = 128;
52
+ function digestOf(value) {
53
+ const digest = typeof value === "string" ? value.toLowerCase() : value;
54
+ if (typeof digest !== "string" || !SHA256.test(digest))
55
+ throw new TypeError("sha256 must be 64 hex characters");
56
+ return digest;
57
+ }
58
+ function maxBytesOf(value) {
59
+ const bytes = value ?? DEFAULT_MAX_BYTES;
60
+ if (!Number.isSafeInteger(bytes) || bytes < 1 || bytes > MAX_BYTES)
61
+ throw new RangeError("maxBytes must be an integer from 1 to 1000000000");
62
+ return bytes;
63
+ }
64
+ /** The route of a file and the digest it names, validated before anything is sent. */
65
+ function routeOf(target) {
66
+ if (!target || typeof target !== "object")
67
+ throw new TypeError("Name a traceId or snapshotId");
68
+ const sha256 = digestOf(target.sha256);
69
+ if (target.snapshotId !== undefined) {
70
+ if (target.traceId !== undefined || target.spanId !== undefined)
71
+ throw new TypeError("Name a traceId or a snapshotId, not both");
72
+ return {
73
+ path: `/trace-snapshots/${uuid(target.snapshotId).toLowerCase()}/blobs/${sha256}`,
74
+ sha256,
75
+ };
76
+ }
77
+ const traceId = target.traceId;
78
+ if (typeof traceId !== "string" ||
79
+ !(UUID.test(traceId) || (TRACE.test(traceId) && !/^0+$/.test(traceId))))
80
+ throw new TypeError("traceId must be a Hue trace ID or a 32-hex OpenTelemetry trace ID");
81
+ let query = "";
82
+ if (target.spanId !== undefined) {
83
+ if (typeof target.spanId !== "string" ||
84
+ !SPAN.test(target.spanId) ||
85
+ /^0+$/.test(target.spanId))
86
+ throw new TypeError("spanId must be a 16-hex OpenTelemetry span ID");
87
+ query = `?spanId=${target.spanId.toLowerCase()}`;
88
+ }
89
+ return { path: `/traces/${traceId.toLowerCase()}/blobs/${sha256}${query}`, sha256 };
90
+ }
91
+ /** The host an `http:` link writes, before the URL parser rewrites it: after the authority's last
92
+ * `@`, up to its port, the authority ending at the first `/`, `?` or `#`, as Python splits it. */
93
+ const WRITTEN_HTTP_HOST = /^http:\/\/(?:[^/?#]*@)?(\[[^\]/?#]*\]|[^/?#:@]*)/i;
94
+ /**
95
+ * A download link Hue issued: HTTPS, or HTTP to a loopback host written `localhost`, `127.0.0.1`
96
+ * or `[::1]`, without credentials or a fragment. A host the URL parser rewrites to one of those
97
+ * (`127.1`, `0x7f.0.0.1`, `[0::1]`) is refused, as the Python SDK refuses it.
98
+ */
99
+ function linkOf(value) {
100
+ // eslint-disable-next-line no-control-regex -- control characters are refused
101
+ if (typeof value !== "string" || value.length > 8192 || /[\x00-\x20\x7f]/u.test(value))
102
+ return undefined;
103
+ try {
104
+ const url = new URL(value);
105
+ const allowed = url.protocol === "https:" ||
106
+ (url.protocol === "http:" &&
107
+ isLoopbackHost(url.hostname) &&
108
+ WRITTEN_HTTP_HOST.exec(value)?.[1]?.toLowerCase() === url.hostname);
109
+ return allowed && !url.username && !url.password && !url.hash ? value : undefined;
110
+ }
111
+ catch {
112
+ return undefined;
113
+ }
114
+ }
115
+ /** The whole seconds of a `Retry-After` header, up to a day, when it is one. */
116
+ function retryAfterOf(response) {
117
+ const header = response.headers.get("retry-after")?.trim() ?? "";
118
+ return /^\d{1,6}$/.test(header) ? Math.min(Number(header), 86_400) : undefined;
119
+ }
120
+ /** Waits `millis`, or less when `signal` ends first. */
121
+ function pause(millis, signal) {
122
+ return new Promise((resolve) => {
123
+ const done = () => {
124
+ clearTimeout(timer);
125
+ signal?.removeEventListener("abort", done);
126
+ resolve();
127
+ };
128
+ const timer = setTimeout(done, millis);
129
+ signal?.addEventListener("abort", done, { once: true });
130
+ if (signal?.aborted)
131
+ done();
132
+ });
133
+ }
134
+ /** The connection failed while an answer's body was read: no answer, unlike a malformed one. */
135
+ class AnswerReadError extends Error {
136
+ }
137
+ /** A JSON answer read to a bound; undefined when it is larger or not JSON. Throws
138
+ * {@link AnswerReadError} when the connection fails while it is read. */
139
+ async function answerOf(response) {
140
+ const reader = response.body?.getReader();
141
+ if (!reader)
142
+ return undefined;
143
+ const chunks = [];
144
+ let size = 0;
145
+ try {
146
+ for (;;) {
147
+ let read;
148
+ try {
149
+ read = await reader.read();
150
+ }
151
+ catch (error) {
152
+ throw new AnswerReadError("The answer could not be read", { cause: error });
153
+ }
154
+ if (read.done)
155
+ break;
156
+ size += read.value.byteLength;
157
+ if (size > MAX_ANSWER_BYTES)
158
+ return undefined;
159
+ chunks.push(read.value);
160
+ }
161
+ try {
162
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
168
+ finally {
169
+ await reader.cancel().catch(() => undefined);
170
+ }
171
+ }
172
+ function grantOf(answer, sha256) {
173
+ const fields = answer !== null && typeof answer === "object" && !Array.isArray(answer)
174
+ ? answer
175
+ : undefined;
176
+ const url = linkOf(fields?.url);
177
+ const size = fields?.size;
178
+ if (!fields ||
179
+ fields.sha256 !== sha256 ||
180
+ url === undefined ||
181
+ typeof size !== "number" ||
182
+ !Number.isSafeInteger(size) ||
183
+ size < 0 ||
184
+ (fields.method !== undefined && fields.method !== "GET"))
185
+ throw new MediaFetchError("failed");
186
+ const contentType = typeof fields.contentType === "string" &&
187
+ fields.contentType.length <= 255 &&
188
+ MEDIA_TYPE.test(fields.contentType.split(";")[0].trim())
189
+ ? fields.contentType
190
+ : undefined;
191
+ const fileName = typeof fields.fileName === "string" &&
192
+ fields.fileName.length > 0 &&
193
+ fields.fileName.length <= 255
194
+ ? fields.fileName
195
+ : undefined;
196
+ return {
197
+ url,
198
+ size,
199
+ ...(contentType !== undefined ? { contentType } : {}),
200
+ ...(fileName !== undefined ? { fileName } : {}),
201
+ };
202
+ }
203
+ /**
204
+ * Hue's media reads, `client.media`: one recorded file by digest (`fetch`), and every digest part
205
+ * of a recorded value written back with its bytes (`resolve`). The project key is sent to Hue
206
+ * only; the file itself comes from a short-lived link fetched without it.
207
+ */
208
+ export class MediaClient {
209
+ #baseUrl;
210
+ #apiKey;
211
+ #timeoutMillis;
212
+ /** @internal Created by the evaluation client as its `media`. */
213
+ constructor(options) {
214
+ this.#baseUrl = options.baseUrl;
215
+ this.#apiKey = options.apiKey;
216
+ this.#timeoutMillis = options.timeoutMillis;
217
+ }
218
+ /**
219
+ * Downloads one recorded file and checks it against its digest: from a trace (Hue reads the
220
+ * span's record that lists the digest under `hue.blobs`) or from a snapshot's pinned copy. A
221
+ * read Hue answers as unavailable (503 or 429) is asked once more after its `Retry-After`, at
222
+ * most 10 seconds, and so is one that lost its connection or timed out.
223
+ *
224
+ * @throws MediaFetchError for a file Hue does not hold, a refused key, an unavailable store, a
225
+ * file over `maxBytes`, bytes that do not match the digest or a failed request.
226
+ * @throws TypeError for an invalid trace, span or snapshot ID or digest.
227
+ */
228
+ async fetch(target, options = {}) {
229
+ const { path, sha256 } = routeOf(target);
230
+ const maxBytes = maxBytesOf(options.maxBytes);
231
+ const grant = await this.#grant(path, sha256, options.signal);
232
+ if (grant.size > maxBytes)
233
+ throw new MediaFetchError("too_large");
234
+ const bytes = await this.#download(grant, sha256, options.signal);
235
+ return {
236
+ bytes,
237
+ sha256,
238
+ size: bytes.byteLength,
239
+ ...(grant.contentType !== undefined ? { contentType: grant.contentType } : {}),
240
+ ...(grant.fileName !== undefined ? { fileName: grant.fileName } : {}),
241
+ };
242
+ }
243
+ /**
244
+ * Writes the bytes of every digest part in `value` back into its content field, the reverse of
245
+ * the SDK's media capture: each distinct digest is downloaded once with {@link fetch}, at most
246
+ * `concurrency` at a time. Returns a new value; a part whose file cannot be read stays as it is
247
+ * and is reported in `failures`. Parse attributes recorded as JSON text (such as
248
+ * `gen_ai.input.messages`) before passing them; pass the record's `hue.blobs` as `blobs` when
249
+ * reading a snapshot, whose copies carry no media type.
250
+ *
251
+ * @throws TypeError for invalid identifiers or options; never for one file's failure.
252
+ */
253
+ async resolve(value, options) {
254
+ if (!options || typeof options !== "object")
255
+ throw new TypeError("Name a traceId or snapshotId");
256
+ const as = writeModeOf(options.as);
257
+ const refs = blobRefsOf(options.blobs);
258
+ const listed = listedTypes(refs);
259
+ const concurrency = options.concurrency ?? 4;
260
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16)
261
+ throw new RangeError("concurrency must be 1–16");
262
+ const maxBytes = maxBytesOf(options.maxBytes);
263
+ const target = (sha256) => options.snapshotId !== undefined
264
+ ? { snapshotId: options.snapshotId, sha256 }
265
+ : {
266
+ traceId: options.traceId,
267
+ sha256,
268
+ ...(options.spanId !== undefined ? { spanId: options.spanId } : {}),
269
+ };
270
+ // Identifiers are checked once, before any request.
271
+ routeOf(target("0".repeat(64)));
272
+ const parts = digestParts(value);
273
+ const prefixes = prefixFailures(value, refs);
274
+ const digests = [...new Set([...parts.values()].map((part) => part.sha256))];
275
+ const files = new Map();
276
+ let next = 0;
277
+ const worker = async () => {
278
+ for (let index = next++; index < digests.length; index = next++) {
279
+ const sha256 = digests[index];
280
+ try {
281
+ const file = await this.fetch(target(sha256), { maxBytes, signal: options.signal });
282
+ const listedType = file.contentType === undefined ? listed.get(sha256) : undefined;
283
+ files.set(sha256, listedType === undefined ? file : { ...file, contentType: listedType });
284
+ }
285
+ catch (error) {
286
+ files.set(sha256, error instanceof MediaFetchError
287
+ ? error
288
+ : new MediaFetchError("failed", undefined, undefined, { cause: error }));
289
+ }
290
+ }
291
+ };
292
+ await Promise.all(Array.from({ length: Math.min(concurrency, digests.length) }, worker));
293
+ const resolution = writeBack(value, parts, (sha256) => {
294
+ const file = files.get(sha256);
295
+ return file instanceof MediaFetchError ? file.reason : file;
296
+ }, as);
297
+ resolution.failures.push(...prefixes);
298
+ return resolution;
299
+ }
300
+ /** The download link of one file, asked once more after an unavailable answer or a lost
301
+ * connection. */
302
+ async #grant(path, sha256, signal) {
303
+ for (let attempt = 0;; attempt++) {
304
+ /** A lost connection, before or while the answer is read: asked once more, then failed. */
305
+ const lost = async (error) => {
306
+ if (attempt > 0 || signal?.aborted)
307
+ throw new MediaFetchError("failed", undefined, undefined, { cause: error });
308
+ await pause(250 + Math.random() * 250, signal);
309
+ };
310
+ let response;
311
+ try {
312
+ response = await fetch(`${this.#baseUrl}/api/v1${path}`, {
313
+ headers: { Authorization: `Bearer ${this.#apiKey}` },
314
+ // A redirect is never followed: it is an unexpected answer, failed with its status
315
+ // and not asked again, as a lost connection is.
316
+ redirect: "manual",
317
+ signal: AbortSignal.any([
318
+ AbortSignal.timeout(this.#timeoutMillis),
319
+ ...(signal ? [signal] : []),
320
+ ]),
321
+ });
322
+ }
323
+ catch (error) {
324
+ await lost(error);
325
+ continue;
326
+ }
327
+ let answer;
328
+ const status = response.status;
329
+ if (status === 503 || status === 429) {
330
+ await response.body?.cancel().catch(() => undefined);
331
+ const seconds = retryAfterOf(response);
332
+ const wait = seconds ?? 1;
333
+ if (attempt > 0 || wait > MAX_RETRY_AFTER_SECONDS || signal?.aborted)
334
+ throw new MediaFetchError("unavailable", status, seconds);
335
+ await pause(wait * 1000, signal);
336
+ continue;
337
+ }
338
+ if (status === 404 || response.ok) {
339
+ try {
340
+ answer = await answerOf(response);
341
+ }
342
+ catch (error) {
343
+ if (!(error instanceof AnswerReadError))
344
+ throw error;
345
+ await lost(error);
346
+ continue;
347
+ }
348
+ }
349
+ if (status === 404) {
350
+ const missing = answer !== null &&
351
+ typeof answer === "object" &&
352
+ answer.state === "missing";
353
+ throw new MediaFetchError(missing ? "missing" : "not_found", status);
354
+ }
355
+ if (status === 401 || status === 403) {
356
+ await response.body?.cancel().catch(() => undefined);
357
+ throw new MediaFetchError("forbidden", status);
358
+ }
359
+ if (!response.ok) {
360
+ await response.body?.cancel().catch(() => undefined);
361
+ throw new MediaFetchError("failed", status);
362
+ }
363
+ return grantOf(answer, sha256);
364
+ }
365
+ }
366
+ /** The bytes behind a link, fetched without the key, stopped one byte past the reported size
367
+ * and checked against the digest. The download ends once no byte has arrived for the client's
368
+ * timeout, at least two minutes; a large file that keeps arriving is never cut off. */
369
+ async #download(grant, sha256, signal) {
370
+ const idleMillis = Math.max(this.#timeoutMillis, mediaDownload.minIdleMillis);
371
+ const idle = new AbortController();
372
+ let timer;
373
+ const restart = () => {
374
+ clearTimeout(timer);
375
+ timer = setTimeout(() => idle.abort(new DOMException("No byte arrived in time", "TimeoutError")), idleMillis);
376
+ };
377
+ restart();
378
+ let reader;
379
+ try {
380
+ let response;
381
+ try {
382
+ response = await fetch(grant.url, {
383
+ redirect: "manual",
384
+ signal: AbortSignal.any([idle.signal, ...(signal ? [signal] : [])]),
385
+ });
386
+ }
387
+ catch (error) {
388
+ throw new MediaFetchError("failed", undefined, undefined, { cause: error });
389
+ }
390
+ if (!response.ok) {
391
+ await response.body?.cancel().catch(() => undefined);
392
+ throw new MediaFetchError("failed", response.status);
393
+ }
394
+ // The answer arrived: its body has the same time again for each piece.
395
+ restart();
396
+ reader = response.body?.getReader();
397
+ const hash = createHash("sha256");
398
+ const bytes = new Uint8Array(grant.size);
399
+ let size = 0;
400
+ try {
401
+ if (reader)
402
+ for (;;) {
403
+ const { done, value } = await reader.read();
404
+ if (done)
405
+ break;
406
+ restart();
407
+ if (size + value.byteLength > grant.size)
408
+ throw new MediaFetchError("digest_mismatch");
409
+ bytes.set(value, size);
410
+ hash.update(value);
411
+ size += value.byteLength;
412
+ }
413
+ }
414
+ catch (error) {
415
+ if (error instanceof MediaFetchError)
416
+ throw error;
417
+ throw new MediaFetchError("failed", undefined, undefined, { cause: error });
418
+ }
419
+ if (size !== grant.size || hash.digest("hex") !== sha256)
420
+ throw new MediaFetchError("digest_mismatch");
421
+ return bytes;
422
+ }
423
+ finally {
424
+ clearTimeout(timer);
425
+ await reader?.cancel().catch(() => undefined);
426
+ }
427
+ }
428
+ }
429
+ const BLOB_CONTENT_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+(?: *;[ -~]*)?$/;
430
+ /**
431
+ * The entries a record's `hue.blobs` lists, read as Hue's reader reads them (`blobRefsOf`): an
432
+ * array of compact JSON object strings, of objects, or one string holding the JSON array; at most
433
+ * 64, one per key, the first winning. An entry without a valid key, digest and size is ignored, and
434
+ * so is anything that is no list.
435
+ */
436
+ function blobRefsOf(listed) {
437
+ let entries = listed;
438
+ if (typeof entries === "string") {
439
+ try {
440
+ entries = JSON.parse(entries);
441
+ }
442
+ catch {
443
+ return [];
444
+ }
445
+ }
446
+ if (!Array.isArray(entries))
447
+ return [];
448
+ const refs = [];
449
+ const keys = new Set();
450
+ for (const item of entries.slice(0, 64)) {
451
+ let entry = item;
452
+ if (typeof entry === "string") {
453
+ if (entry.length > 2048)
454
+ continue;
455
+ try {
456
+ entry = JSON.parse(entry);
457
+ }
458
+ catch {
459
+ continue;
460
+ }
461
+ }
462
+ const fields = entry !== null && typeof entry === "object" && !Array.isArray(entry)
463
+ ? entry
464
+ : undefined;
465
+ if (!fields)
466
+ continue;
467
+ const key = fields.key;
468
+ const sha256 = typeof fields.sha256 === "string" ? fields.sha256.toLowerCase() : "";
469
+ const size = typeof fields.size === "number"
470
+ ? fields.size
471
+ : typeof fields.size === "string" && /^\d{1,10}$/.test(fields.size)
472
+ ? Number(fields.size)
473
+ : NaN;
474
+ if (typeof key !== "string" ||
475
+ key.length < 1 ||
476
+ key.length > 256 ||
477
+ // eslint-disable-next-line no-control-regex -- control characters are refused
478
+ /[\u0000-\u001f\u007f]/.test(key) ||
479
+ keys.has(key) ||
480
+ !SHA256.test(sha256) ||
481
+ !Number.isSafeInteger(size) ||
482
+ size < 1 ||
483
+ size > 1_000_000_000)
484
+ continue;
485
+ keys.add(key);
486
+ const type = fields.content_type ?? fields.contentType;
487
+ refs.push({
488
+ key,
489
+ sha256,
490
+ size,
491
+ ...(typeof type === "string" &&
492
+ type.length >= 3 &&
493
+ type.length <= 255 &&
494
+ BLOB_CONTENT_TYPE.test(type)
495
+ ? { contentType: type }
496
+ : {}),
497
+ });
498
+ }
499
+ return refs;
500
+ }
501
+ /** The media type each digest has in a record's `hue.blobs` entries, the first typed one of a
502
+ * digest. */
503
+ function listedTypes(refs) {
504
+ const types = new Map();
505
+ for (const ref of refs)
506
+ if (ref.contentType !== undefined && !types.has(ref.sha256))
507
+ types.set(ref.sha256, ref.contentType);
508
+ return types;
509
+ }
510
+ /**
511
+ * The parts of `value` whose content field holds only the first 16 KiB of a file (text media over
512
+ * 64 KiB, as the SDKs record it), found by the `hue.blobs` entries that name that field
513
+ * (`<attribute>#<pointer to the field>`): reported as `prefix_only` with the entry's SHA-256, never
514
+ * written. An entry names a pointer, not the value, so entries of other attributes at the same
515
+ * pointer are reported too; a part in digest form, or whose field holds no such prefix, is left
516
+ * out.
517
+ */
518
+ function prefixFailures(value, refs) {
519
+ const failures = [];
520
+ const seen = new Set();
521
+ for (const ref of refs) {
522
+ const at = ref.key.indexOf("#");
523
+ const pointer = at === -1 ? "" : ref.key.slice(at + 1);
524
+ if (!pointer.startsWith("/"))
525
+ continue;
526
+ const segments = pointer
527
+ .slice(1)
528
+ .split("/")
529
+ .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
530
+ const nodes = [value];
531
+ for (const segment of segments) {
532
+ const node = nodes.at(-1);
533
+ const next = Array.isArray(node)
534
+ ? /^\d+$/.test(segment)
535
+ ? node[Number(segment)]
536
+ : undefined
537
+ : plain(node) && Object.hasOwn(node, segment)
538
+ ? node[segment]
539
+ : undefined;
540
+ if (next === undefined)
541
+ break;
542
+ nodes.push(next);
543
+ }
544
+ const recorded = nodes.at(-1);
545
+ if (nodes.length !== segments.length + 1 || typeof recorded !== "string")
546
+ continue;
547
+ // The SDKs keep the first 16 KiB of the content, cut back to a character.
548
+ const kept = Buffer.byteLength(recorded, "utf8");
549
+ if (kept > PREFIX_BYTES || kept <= PREFIX_BYTES - 4 || ref.size <= kept)
550
+ continue;
551
+ // The part is a few levels above its content field, as the field's slot names it.
552
+ for (let up = 1; up <= Math.min(3, segments.length); up++) {
553
+ const part = plain(nodes[nodes.length - 1 - up]);
554
+ const slot = part && slotOf(part);
555
+ if (!part || !slot || typeof part.sha256 === "string")
556
+ continue;
557
+ const path = [...slot.owner, slot.key];
558
+ const tail = segments.slice(-up);
559
+ if (path.length !== tail.length || path.some((segment, index) => segment !== tail[index]))
560
+ continue;
561
+ const partPointer = segments
562
+ .slice(0, segments.length - up)
563
+ .map((segment) => `/${segment.replaceAll("~", "~0").replaceAll("/", "~1")}`)
564
+ .join("");
565
+ const identity = `${partPointer}\u0000${ref.sha256}`;
566
+ if (!seen.has(identity)) {
567
+ seen.add(identity);
568
+ failures.push({ pointer: partPointer, sha256: ref.sha256, reason: "prefix_only" });
569
+ }
570
+ break;
571
+ }
572
+ }
573
+ return failures;
574
+ }
575
+ function writeModeOf(value) {
576
+ if (value === undefined)
577
+ return "dataUrl";
578
+ if (value !== "dataUrl" && value !== "bytes")
579
+ throw new TypeError('as must be "dataUrl" or "bytes"');
580
+ return value;
581
+ }
582
+ /**
583
+ * Writes case files back into the digest parts of a case's inputs, by SHA-256: the files a runner
584
+ * hands a target (`context.files`) or any bytes the caller holds, so a target can send the
585
+ * recorded model call again with the real image, document or audio. Each file is read once and
586
+ * checked against its digest; a part with no matching file, or whose file does not match, stays
587
+ * as it is and is reported in `failures`. Returns a new value; the input is never changed.
588
+ */
589
+ export async function rehydrateMedia(value, files, options = {}) {
590
+ const as = writeModeOf(options.as);
591
+ if (!Array.isArray(files))
592
+ throw new TypeError("files must be a list of files");
593
+ const byDigest = new Map();
594
+ for (const file of files) {
595
+ const digest = typeof file?.sha256 === "string" ? file.sha256.toLowerCase() : undefined;
596
+ if (digest !== undefined && SHA256.test(digest) && !byDigest.has(digest))
597
+ byDigest.set(digest, file);
598
+ }
599
+ const parts = digestParts(value);
600
+ const read = new Map();
601
+ for (const sha256 of new Set([...parts.values()].map((part) => part.sha256))) {
602
+ const file = byDigest.get(sha256);
603
+ if (!file) {
604
+ read.set(sha256, "not_found");
605
+ continue;
606
+ }
607
+ let bytes;
608
+ try {
609
+ if (file.bytes !== undefined && !(file.bytes instanceof Uint8Array))
610
+ throw new TypeError("bytes must be a Uint8Array");
611
+ bytes = file.bytes ?? (await readFile(file.path));
612
+ }
613
+ catch {
614
+ read.set(sha256, "failed");
615
+ continue;
616
+ }
617
+ if (createHash("sha256").update(bytes).digest("hex") !== sha256) {
618
+ read.set(sha256, "digest_mismatch");
619
+ continue;
620
+ }
621
+ read.set(sha256, {
622
+ bytes,
623
+ sha256,
624
+ size: bytes.byteLength,
625
+ ...(file.contentType !== undefined ? { contentType: file.contentType } : {}),
626
+ });
627
+ }
628
+ return writeBack(value, parts, (sha256) => read.get(sha256), as);
629
+ }
630
+ /** A plain object, the only kind of object the walk copies and writes into. */
631
+ function plain(value) {
632
+ if (value === null || typeof value !== "object" || Array.isArray(value))
633
+ return undefined;
634
+ const prototype = Object.getPrototypeOf(value);
635
+ return prototype === Object.prototype || prototype === null ? value : undefined;
636
+ }
637
+ function text(value) {
638
+ return typeof value === "string" && value.length > 0 ? value : undefined;
639
+ }
640
+ function declaredType(part) {
641
+ return text(part.mime_type ?? part.mediaType ?? part.media_type ?? part.mimeType);
642
+ }
643
+ const AUDIO_FORMATS = {
644
+ mp3: "audio/mpeg",
645
+ mpeg: "audio/mpeg",
646
+ mpga: "audio/mpeg",
647
+ wav: "audio/wav",
648
+ wave: "audio/wav",
649
+ ogg: "audio/ogg",
650
+ oga: "audio/ogg",
651
+ opus: "audio/opus",
652
+ flac: "audio/flac",
653
+ aac: "audio/aac",
654
+ m4a: "audio/mp4",
655
+ mp4: "audio/mp4",
656
+ webm: "audio/webm",
657
+ pcm16: "audio/pcm",
658
+ pcm: "audio/pcm",
659
+ };
660
+ const BEDROCK_TYPES = {
661
+ image: {
662
+ png: "image/png",
663
+ jpeg: "image/jpeg",
664
+ jpg: "image/jpeg",
665
+ gif: "image/gif",
666
+ webp: "image/webp",
667
+ },
668
+ document: {
669
+ pdf: "application/pdf",
670
+ csv: "text/csv",
671
+ doc: "application/msword",
672
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
673
+ xls: "application/vnd.ms-excel",
674
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
675
+ html: "text/html",
676
+ txt: "text/plain",
677
+ md: "text/markdown",
678
+ },
679
+ };
680
+ const audioType = (format) => typeof format === "string" ? AUDIO_FORMATS[format.trim().toLowerCase()] : undefined;
681
+ /** The content slot of a part read by its shape, the shapes Hue reads media parts in; undefined
682
+ * for text, tool and reference parts (a provider file ID, a remote URL). */
683
+ function slotOf(part) {
684
+ const type = typeof part.type === "string" ? part.type.toLowerCase().replaceAll("-", "_") : undefined;
685
+ const reference = typeof part.file_id === "string";
686
+ switch (type) {
687
+ case "blob":
688
+ return {
689
+ owner: [],
690
+ key: "content",
691
+ encoding: "base64",
692
+ mediaType: declaredType(part),
693
+ };
694
+ case "file": {
695
+ const file = plain(part.file);
696
+ if (file)
697
+ return typeof file.file_id === "string"
698
+ ? undefined
699
+ : {
700
+ owner: ["file"],
701
+ key: "file_data",
702
+ encoding: "dataUrl",
703
+ mediaType: declaredType(part),
704
+ };
705
+ return reference
706
+ ? undefined
707
+ : { owner: [], key: "data", encoding: "dataUrl", mediaType: declaredType(part) };
708
+ }
709
+ case "image":
710
+ case "document": {
711
+ const source = plain(part.source);
712
+ // A source without a type, absent or null, is read as base64, as Hue reads it.
713
+ if (source)
714
+ return source.type === undefined || source.type === null || source.type === "base64"
715
+ ? {
716
+ owner: ["source"],
717
+ key: "data",
718
+ encoding: "base64",
719
+ mediaType: text(source.media_type) ?? declaredType(part),
720
+ }
721
+ : undefined;
722
+ return type === "image"
723
+ ? { owner: [], key: "image", encoding: "dataUrl", mediaType: declaredType(part) }
724
+ : undefined;
725
+ }
726
+ case "image_url":
727
+ return plain(part.image_url)
728
+ ? { owner: ["image_url"], key: "url", encoding: "dataUrl", mediaType: declaredType(part) }
729
+ : { owner: [], key: "image_url", encoding: "dataUrl", mediaType: declaredType(part) };
730
+ case "input_image":
731
+ case "computer_screenshot":
732
+ return reference
733
+ ? undefined
734
+ : { owner: [], key: "image_url", encoding: "dataUrl", mediaType: declaredType(part) };
735
+ case "input_audio": {
736
+ const audio = plain(part.input_audio);
737
+ return audio
738
+ ? {
739
+ owner: ["input_audio"],
740
+ key: "data",
741
+ encoding: "base64",
742
+ mediaType: audioType(audio.format),
743
+ }
744
+ : {
745
+ owner: [],
746
+ key: "data",
747
+ encoding: "base64",
748
+ mediaType: audioType(part.format) ?? declaredType(part),
749
+ };
750
+ }
751
+ case "input_file":
752
+ return reference || typeof part.file_url === "string"
753
+ ? undefined
754
+ : { owner: [], key: "file_data", encoding: "dataUrl", mediaType: declaredType(part) };
755
+ case "media":
756
+ return {
757
+ owner: [],
758
+ key: "data",
759
+ encoding: "base64",
760
+ mediaType: declaredType(part),
761
+ };
762
+ case undefined: {
763
+ for (const key of ["inlineData", "inline_data"]) {
764
+ const data = plain(part[key]);
765
+ if (data)
766
+ return {
767
+ owner: [key],
768
+ key: "data",
769
+ encoding: "base64",
770
+ mediaType: text(data.mimeType ?? data.mime_type),
771
+ };
772
+ }
773
+ for (const key of ["image", "document"]) {
774
+ const block = plain(part[key]);
775
+ const source = plain(block?.source);
776
+ if (block && source && source.s3Location === undefined)
777
+ return {
778
+ owner: [key, "source"],
779
+ key: "bytes",
780
+ encoding: "base64",
781
+ mediaType: typeof block.format === "string"
782
+ ? BEDROCK_TYPES[key][block.format.toLowerCase()]
783
+ : undefined,
784
+ };
785
+ }
786
+ // An OpenAI Chat assistant message's spoken reply: the message itself is the part.
787
+ const audio = plain(part.audio);
788
+ if (audio && typeof audio.id === "string")
789
+ return {
790
+ owner: ["audio"],
791
+ key: "data",
792
+ encoding: "base64",
793
+ mediaType: audioType(audio.format),
794
+ };
795
+ return undefined;
796
+ }
797
+ default:
798
+ return undefined;
799
+ }
800
+ }
801
+ /** The slot of a part in digest form: a known shape that records a SHA-256 in place of its
802
+ * content, its content field absent or empty. */
803
+ function digestSlotOf(part) {
804
+ if (typeof part.sha256 !== "string" || !SHA256.test(part.sha256))
805
+ return undefined;
806
+ const slot = slotOf(part);
807
+ if (!slot)
808
+ return undefined;
809
+ let owner = part;
810
+ for (const segment of slot.owner)
811
+ owner = plain(owner?.[segment]);
812
+ if (!owner)
813
+ return undefined;
814
+ const content = owner[slot.key];
815
+ return content === undefined || content === null || content === "" ? slot : undefined;
816
+ }
817
+ const escape = (key) => key.replaceAll("~", "~0").replaceAll("/", "~1");
818
+ /** The digest parts of a value, by the part object, each once, in reading order. */
819
+ function digestParts(value) {
820
+ const found = new Map();
821
+ const visit = (node, pointer, depth) => {
822
+ if (depth > MAX_DEPTH)
823
+ return;
824
+ if (Array.isArray(node)) {
825
+ node.forEach((item, index) => visit(item, `${pointer}/${index}`, depth + 1));
826
+ return;
827
+ }
828
+ const part = plain(node);
829
+ if (!part)
830
+ return;
831
+ const slot = digestSlotOf(part);
832
+ // The slot's owners must sit within the depth the copy is made to.
833
+ if (slot && depth + slot.owner.length < MAX_DEPTH && !found.has(part))
834
+ found.set(part, { pointer, sha256: part.sha256, slot });
835
+ // A message whose `audio` is a part still holds the parts of its `content`.
836
+ for (const [key, item] of Object.entries(part))
837
+ visit(item, `${pointer}/${escape(key)}`, depth + 1);
838
+ };
839
+ visit(value, "", 0);
840
+ return found;
841
+ }
842
+ /** The type a `data:` URL declares: the part's own, else the file's, without parameters. */
843
+ function dataUrlType(slot, file) {
844
+ for (const candidate of [slot.mediaType, file.contentType]) {
845
+ const type = candidate?.split(";")[0].trim();
846
+ if (type && MEDIA_TYPE.test(type))
847
+ return type.toLowerCase();
848
+ }
849
+ return "application/octet-stream";
850
+ }
851
+ /**
852
+ * A file's bytes as a slot's convention spells them: bare base64 or a `data:` URL. A GenAI `blob`
853
+ * or Vertex `media` part takes base64 for every media type, as the GenAI conventions define its
854
+ * content and as Hue's reader decodes content that reads as base64; raw text such as `test`
855
+ * would itself read as base64 and stand for other bytes.
856
+ */
857
+ function spelled(slot, file) {
858
+ const base64 = Buffer.from(file.bytes).toString("base64");
859
+ return slot.encoding === "dataUrl" ? `data:${dataUrlType(slot, file)};base64,${base64}` : base64;
860
+ }
861
+ /** Sets an own enumerable property, a key named `__proto__` included, which assignment would take
862
+ * as the object's prototype instead. */
863
+ function setOwn(target, key, value) {
864
+ Object.defineProperty(target, key, {
865
+ value,
866
+ writable: true,
867
+ enumerable: true,
868
+ configurable: true,
869
+ });
870
+ }
871
+ /** A copy of `value` with each digest part whose file was read holding its bytes. */
872
+ function writeBack(value, parts, fileOf, as) {
873
+ const failures = [];
874
+ let resolved = 0;
875
+ const reported = new Set();
876
+ const visit = (node, depth) => {
877
+ if (depth > MAX_DEPTH)
878
+ return node;
879
+ if (Array.isArray(node))
880
+ return node.map((item) => visit(item, depth + 1));
881
+ const source = plain(node);
882
+ if (!source)
883
+ return node;
884
+ const part = parts.get(source);
885
+ const file = part && fileOf(part.sha256);
886
+ if (part && typeof file === "string" && !reported.has(source)) {
887
+ failures.push({ pointer: part.pointer, sha256: part.sha256, reason: file });
888
+ reported.add(source);
889
+ }
890
+ const copy = {};
891
+ for (const [key, item] of Object.entries(source))
892
+ setOwn(copy, key, visit(item, depth + 1));
893
+ if (part && file !== undefined && typeof file !== "string") {
894
+ // The owners along the slot are this copy's own objects: the walk copied every plain one.
895
+ let owner = copy;
896
+ for (const segment of part.slot.owner)
897
+ owner = owner[segment];
898
+ owner[part.slot.key] = as === "bytes" ? file.bytes : spelled(part.slot, file);
899
+ delete copy.sha256;
900
+ delete copy.size;
901
+ resolved++;
902
+ }
903
+ return copy;
904
+ };
905
+ const result = visit(value, 0);
906
+ return { value: result, resolved, failures };
907
+ }