@hue-run/sdk 0.16.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.
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
3
+ import { lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
4
4
  import { extname, join, resolve } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { ArtifactSizeError, HueApiError } from "./client.js";
7
+ import { uuid } from "./json.js";
7
8
  /** Roles the target receives. Organization templates stay with grading, as in the managed protocol. */
8
9
  export const targetFileRoles = [
9
10
  "source",
@@ -30,6 +31,18 @@ export const outputContentTypes = [
30
31
  "image/png",
31
32
  "image/jpeg",
32
33
  "image/webp",
34
+ "image/gif",
35
+ "image/avif",
36
+ "audio/mpeg",
37
+ "audio/wav",
38
+ "audio/ogg",
39
+ "audio/webm",
40
+ "audio/mp4",
41
+ "audio/flac",
42
+ "audio/opus",
43
+ "audio/aac",
44
+ "video/mp4",
45
+ "video/webm",
33
46
  ];
34
47
  /** Thrown when the target's declared files cannot be used; recorded as the target's error. */
35
48
  export class OutputFileError extends Error {
@@ -401,33 +414,172 @@ export async function uploadOutputFiles(client, executionId, files, save, timing
401
414
  if (file.artifactId)
402
415
  continue;
403
416
  const bytes = await verifiedBytes(file.path, file);
404
- const reserved = await client.reserveArtifact({
417
+ file.artifactId = await publishArtifact(client, {
405
418
  idempotencyKey: `hue-sdk:${executionId}:${file.sha256}:${sha256(Buffer.from(file.filename)).slice(0, 16)}`,
406
419
  filename: file.filename,
407
420
  contentType: file.contentType,
408
421
  byteSize: file.byteSize,
409
422
  sha256: file.sha256,
410
- });
411
- let state = reserved.state;
412
- if (state !== "ready") {
413
- // A verification already running holds the uploaded bytes; Hue refuses another upload then.
414
- if (reserved.copyState !== "acknowledged" && state !== "verifying") {
415
- const upload = await client.requestArtifactUpload(reserved.id);
416
- try {
417
- await client.uploadArtifactBytes(upload, bytes, file.contentType);
418
- }
419
- catch {
420
- // A lost staging acknowledgement or an immutable object already present can only be
421
- // settled by verified completion; never rewrite a final object here.
422
- }
423
+ }, bytes, `Generated file ${file.filename}`, timing);
424
+ await save();
425
+ }
426
+ }
427
+ /** Reserve, stage and verify one artifact; its ID once Hue verified it. Replaying the key settles
428
+ * on the same artifact, including one Hue finished verifying after an earlier attempt stopped. */
429
+ async function publishArtifact(client, declared, bytes, label, timing) {
430
+ const reserved = await client.reserveArtifact(declared);
431
+ let state = reserved.state;
432
+ if (state !== "ready") {
433
+ // A verification already running holds the uploaded bytes; Hue refuses another upload then.
434
+ if (reserved.copyState !== "acknowledged" && state !== "verifying") {
435
+ const upload = await client.requestArtifactUpload(reserved.id);
436
+ try {
437
+ await client.uploadArtifactBytes(upload, bytes, declared.contentType);
438
+ }
439
+ catch {
440
+ // A lost staging acknowledgement or an immutable object already present can only be
441
+ // settled by verified completion; never rewrite a final object here.
423
442
  }
424
- state = await settleArtifact(client, reserved.id, timing);
425
443
  }
426
- if (state !== "ready")
427
- throw new Error(`Generated file ${file.filename} was not verified by Hue (${state})`);
428
- file.artifactId = reserved.id;
429
- await save();
444
+ state = await settleArtifact(client, reserved.id, timing);
445
+ }
446
+ if (state !== "ready")
447
+ throw new Error(`${label} was not verified by Hue (${state})`);
448
+ return reserved.id;
449
+ }
450
+ /** Roles a pinned case input can carry. */
451
+ const caseFileRoles = [
452
+ ...targetFileRoles,
453
+ "org_template",
454
+ "evaluator_reference",
455
+ ];
456
+ /** The most input files one case pins. */
457
+ const MAX_CASE_FILES = 32;
458
+ /** A case file name as the artifact API takes it: well-formed Unicode of 1–255 UTF-16 code units,
459
+ * as Hue counts them, without a control character or a path separator. */
460
+ function caseFileName(name) {
461
+ if (typeof name !== "string" ||
462
+ !name.length ||
463
+ name.length > 255 ||
464
+ !name.isWellFormed() ||
465
+ [...name].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127 || c === "/" || c === "\\"))
466
+ throw new TypeError("A case file needs a well-formed filename of 1–255 UTF-16 code units without path separators or control characters");
467
+ return name;
468
+ }
469
+ /** The bytes of a case file given by path: a regular file within the size limit, read once. */
470
+ async function caseFileBytes(path, label) {
471
+ if (typeof path !== "string" || !path)
472
+ throw new TypeError(`${label} needs bytes or a path`);
473
+ // A path with no file behind it, or one that cannot be read, is refused as any invalid file.
474
+ const unreadable = (error) => new TypeError(`${label} could not be read`, { cause: error });
475
+ const info = await stat(path).catch((error) => {
476
+ throw unreadable(error);
477
+ });
478
+ if (!info.isFile())
479
+ throw new TypeError(`${label} is not a regular file`);
480
+ if (info.size > outputFileLimits.bytes)
481
+ throw new RangeError(`${label} exceeds 25 MiB`);
482
+ return readFile(path).catch((error) => {
483
+ throw unreadable(error);
484
+ });
485
+ }
486
+ /** A stand-in for an artifact a case file is uploaded as, the length of a real ID. */
487
+ const STAND_IN_ID = "00000000-0000-4000-8000-000000000000";
488
+ /**
489
+ * The `files` of a case write as the case API takes them: references kept, and each file given
490
+ * as bytes or a path checked against the artifact policy (role, name, one of
491
+ * `outputContentTypes`, 1 byte to 25 MiB) and uploaded as a verified artifact. Every file is
492
+ * checked, and then `beforeUpload` is given the references with a stand-in ID for each upload,
493
+ * before the first upload. The reservation key names the version, the case's `externalKey`, the
494
+ * role and the bytes, so the same write sent again reuses its artifacts. Each artifact is pinned
495
+ * once with a role, even when a reference names the artifact a file was uploaded as.
496
+ */
497
+ export async function caseFileReferences(client, versionId, input, beforeUpload = () => undefined, timing = artifactSettling) {
498
+ const files = input.files;
499
+ if (!Array.isArray(files))
500
+ throw new TypeError("files must be a list of case files");
501
+ if (files.length > MAX_CASE_FILES)
502
+ throw new RangeError(`A case pins at most ${MAX_CASE_FILES} files`);
503
+ const prepared = [];
504
+ const seen = new Set();
505
+ for (const [index, file] of files.entries()) {
506
+ const label = `Case file ${index + 1}`;
507
+ if (!file || typeof file !== "object")
508
+ throw new TypeError(`${label} is not a file`);
509
+ if (!caseFileRoles.includes(file.role))
510
+ throw new TypeError(`${label} has an unsupported role ${String(file.role)}`);
511
+ let identity;
512
+ if (file.artifactId !== undefined) {
513
+ // A reference keeps the artifact's own name and type; a file field beside it is refused
514
+ // rather than ignored.
515
+ const fields = file;
516
+ if (fields.filename !== undefined ||
517
+ fields.contentType !== undefined ||
518
+ fields.bytes !== undefined ||
519
+ fields.path !== undefined)
520
+ throw new TypeError(`${label} names an artifactId and a file; give one`);
521
+ const reference = { artifactId: uuid(file.artifactId).toLowerCase(), role: file.role };
522
+ prepared.push(reference);
523
+ identity = `artifact:${reference.artifactId}`;
524
+ }
525
+ else {
526
+ const filename = caseFileName(file.filename);
527
+ if (!outputContentTypes.includes(file.contentType))
528
+ throw new TypeError(`${label} has an unsupported content type ${String(file.contentType)}`);
529
+ if (file.bytes !== undefined && file.path !== undefined)
530
+ throw new TypeError(`${label} names bytes and a path; give one`);
531
+ if (file.bytes !== undefined && !(file.bytes instanceof Uint8Array))
532
+ throw new TypeError(`${label} bytes must be a Uint8Array`);
533
+ const bytes = file.bytes ?? (await caseFileBytes(file.path, label));
534
+ if (!bytes.byteLength)
535
+ throw new RangeError(`${label} is empty`);
536
+ if (bytes.byteLength > outputFileLimits.bytes)
537
+ throw new RangeError(`${label} exceeds 25 MiB`);
538
+ const digest = sha256(bytes);
539
+ prepared.push({
540
+ role: file.role,
541
+ filename,
542
+ contentType: file.contentType,
543
+ bytes,
544
+ sha256: digest,
545
+ });
546
+ identity = `file:${digest}:${file.contentType}:${filename}`;
547
+ }
548
+ const pinned = `${identity}:${file.role}`;
549
+ if (seen.has(pinned))
550
+ throw new TypeError(`${label} is pinned twice with the role ${file.role}`);
551
+ seen.add(pinned);
552
+ }
553
+ beforeUpload(prepared.map((item) => "artifactId" in item ? item : { artifactId: STAND_IN_ID, role: item.role }));
554
+ const caseKey = sha256(Buffer.from(String(input.externalKey))).slice(0, 16);
555
+ const references = [];
556
+ // A reference and an upload that settle on the same artifact are pinned once: Hue refuses an
557
+ // artifact pinned twice with one role.
558
+ const pins = new Set();
559
+ const pin = (reference) => {
560
+ const identity = `${reference.artifactId.toLowerCase()}:${reference.role}`;
561
+ if (pins.has(identity))
562
+ return;
563
+ pins.add(identity);
564
+ references.push(reference);
565
+ };
566
+ for (const item of prepared) {
567
+ if ("artifactId" in item) {
568
+ pin(item);
569
+ continue;
570
+ }
571
+ const artifactId = await publishArtifact(client, {
572
+ // The key names everything the reservation declares, so a corrected name or type reserves
573
+ // a new artifact instead of colliding with the earlier one.
574
+ idempotencyKey: `hue-sdk:case:${versionId}:${caseKey}:${item.role}:${item.sha256}:${sha256(Buffer.from(item.filename)).slice(0, 16)}:${sha256(Buffer.from(item.contentType)).slice(0, 16)}`,
575
+ filename: item.filename,
576
+ contentType: item.contentType,
577
+ byteSize: item.bytes.byteLength,
578
+ sha256: item.sha256,
579
+ }, item.bytes, `Case file ${item.filename}`, timing);
580
+ pin({ artifactId, role: item.role });
430
581
  }
582
+ return references;
431
583
  }
432
584
  /** Project uploaded staged files as the verified local files handed to scorers. */
433
585
  export function localOutputFiles(files) {
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Reading recorded media back. A trace records a large file as a digest part: the provider part
3
+ * with its content field removed and the file's `sha256` and `size` added, its bytes uploaded
4
+ * under the record's `hue.blobs`. `fetch` downloads one file by digest through a short-lived
5
+ * link Hue issues; `resolve` and {@link rehydrateMedia} put the bytes back into the parts of a
6
+ * recorded value, in the field and encoding each part's shape uses.
7
+ */
8
+ /** Why a media read failed. */
9
+ export type MediaFetchErrorReason =
10
+ /** Hue holds no such file for the trace or snapshot: an unknown trace, another project's, or a
11
+ * digest none of its records list (with `spanId`, that span's record). */
12
+ "not_found"
13
+ /** The trace lists the file, but its bytes were never uploaded or have been erased. */
14
+ | "missing"
15
+ /** Hue could not read its evidence store, again after one retry; `retryAfterSeconds` says when
16
+ * to ask again. */
17
+ | "unavailable"
18
+ /** The key cannot read recorded content: use a Read or Read and write key, and a Read and write
19
+ * key for a snapshot's copy. */
20
+ | "forbidden"
21
+ /** The file is larger than `maxBytes`; nothing was downloaded. */
22
+ | "too_large"
23
+ /** The downloaded bytes do not have the requested SHA-256 or the size Hue reported. */
24
+ | "digest_mismatch"
25
+ /** A connection failure, a timeout, an unexpected status or an invalid response. */
26
+ | "failed";
27
+ /** Thrown by {@link MediaClient.fetch}. The message is fixed and never includes response text. */
28
+ export declare class MediaFetchError extends Error {
29
+ /** What failed. */
30
+ readonly reason: MediaFetchErrorReason;
31
+ /** HTTP status of the answer that failed, when there was one. */
32
+ readonly status?: number | undefined;
33
+ /** Seconds Hue asked the caller to wait, on `unavailable`, when it said. */
34
+ readonly retryAfterSeconds?: number | undefined;
35
+ constructor(
36
+ /** What failed. */
37
+ reason: MediaFetchErrorReason,
38
+ /** HTTP status of the answer that failed, when there was one. */
39
+ status?: number | undefined,
40
+ /** Seconds Hue asked the caller to wait, on `unavailable`, when it said. */
41
+ retryAfterSeconds?: number | undefined, options?: {
42
+ cause?: unknown;
43
+ });
44
+ }
45
+ /** One recorded file, downloaded and checked against its digest. */
46
+ export interface MediaFile {
47
+ /** The file's bytes; their SHA-256 is `sha256`. */
48
+ bytes: Uint8Array;
49
+ /** Lowercase hex SHA-256 of `bytes`, as requested. */
50
+ sha256: string;
51
+ /** Byte length of `bytes`. */
52
+ size: number;
53
+ /** The media type the record lists for the file; absent for a snapshot's copy, whose type is
54
+ * the part's own. */
55
+ contentType?: string;
56
+ /** The recorded file name as Hue serves it; absent for a snapshot's copy. */
57
+ fileName?: string;
58
+ }
59
+ /** The file {@link MediaClient.fetch} reads: from a trace, or from a snapshot's pinned copy. */
60
+ export type MediaFetchTarget = {
61
+ /** Hue trace ID (UUID) or the 32-hex OpenTelemetry trace ID. */
62
+ traceId: string;
63
+ /** Lowercase hex SHA-256 the part records. */
64
+ sha256: string;
65
+ /** 16-hex OpenTelemetry span ID of the span that recorded the part. Pass it when known: Hue
66
+ * then reads that span's record alone instead of searching the trace. */
67
+ spanId?: string;
68
+ /** Not set when reading a live trace's file. */
69
+ snapshotId?: undefined;
70
+ } | {
71
+ /** Trace snapshot ID (UUID), such as the one a case converted from a trace keeps. Reading
72
+ * its copy needs a Read and write key. */
73
+ snapshotId: string;
74
+ /** Lowercase hex SHA-256 the part records. */
75
+ sha256: string;
76
+ /** Not set when reading a snapshot's pinned copy. */
77
+ traceId?: undefined;
78
+ /** Not set when reading a snapshot's pinned copy. */
79
+ spanId?: undefined;
80
+ };
81
+ /** Options for {@link MediaClient.fetch}. */
82
+ export interface MediaFetchOptions {
83
+ /** Largest file read into memory, in bytes; a larger one fails with `too_large` before any byte
84
+ * is downloaded. Default 64 MiB, at most 1 GB. */
85
+ maxBytes?: number;
86
+ /** Ends the request, the retry wait and the download. */
87
+ signal?: AbortSignal;
88
+ }
89
+ /** Where {@link MediaClient.resolve} reads the files of a recorded value from, and how it writes
90
+ * them. */
91
+ export type MediaResolveOptions = ({
92
+ /** Hue trace ID (UUID) or the 32-hex OpenTelemetry trace ID the value was recorded in. */
93
+ traceId: string;
94
+ /** 16-hex OpenTelemetry span ID of the span that recorded the value. */
95
+ spanId?: string;
96
+ /** Not set when resolving a live trace's files. */
97
+ snapshotId?: undefined;
98
+ } | {
99
+ /** Trace snapshot ID whose pinned copies hold the files; reading them needs a Read and
100
+ * write key. */
101
+ snapshotId: string;
102
+ /** Not set when resolving a snapshot's pinned copies. */
103
+ traceId?: undefined;
104
+ /** Not set when resolving a snapshot's pinned copies. */
105
+ spanId?: undefined;
106
+ }) & MediaWriteOptions & {
107
+ /** The `hue.blobs` entries of the record the value was read from, in any form Hue reads: an
108
+ * array of compact JSON object strings (what the SDKs write), an array of objects, or one
109
+ * string holding the JSON array. A part that declares no media type, read from a copy that
110
+ * carries none (a snapshot's), takes the type of the entry with its SHA-256 for its `data:`
111
+ * URL, rather than `application/octet-stream`. A part whose content field an entry names and
112
+ * that holds only the first 16 KiB of its file is reported as `prefix_only`. Entries that do
113
+ * not parse are ignored. */
114
+ blobs?: readonly (string | Readonly<Record<string, unknown>>)[] | string;
115
+ /** Files downloaded at once, 1–16. Default 4. */
116
+ concurrency?: number;
117
+ /** Largest file read, as in {@link MediaFetchOptions.maxBytes}. */
118
+ maxBytes?: number;
119
+ /** Ends every request and download. */
120
+ signal?: AbortSignal;
121
+ };
122
+ /** How resolved bytes are written into a part. */
123
+ export interface MediaWriteOptions {
124
+ /**
125
+ * `"dataUrl"` (the default) writes text: a `data:` URL where the part's field takes one (OpenAI
126
+ * `image_url` and `file_data`, Responses `input_image`, `input_file` and `computer_screenshot`,
127
+ * AI SDK `image` and `file` `data`), and bare base64 where its convention requires it (GenAI
128
+ * `blob` `content`, OpenAI `input_audio` and assistant `audio` `data`, Anthropic `source.data`,
129
+ * Gemini `inlineData.data`, Vertex `media` `data`, Bedrock `source.bytes`), so the value can be
130
+ * sent to the provider again. `"bytes"` writes the `Uint8Array` itself in the same field.
131
+ */
132
+ as?: "dataUrl" | "bytes";
133
+ }
134
+ /** A part {@link MediaClient.resolve} or {@link rehydrateMedia} left unchanged. */
135
+ export interface MediaResolveFailure {
136
+ /** RFC 6901 JSON pointer of the part in the value. */
137
+ pointer: string;
138
+ /** The SHA-256 the part records, or for `prefix_only` the SHA-256 of the entry naming its
139
+ * content field. */
140
+ sha256: string;
141
+ /** Why its bytes were not written: as {@link MediaFetchError.reason}, `not_found` when no local
142
+ * file has the digest, or `prefix_only` for a part whose content field holds only the first
143
+ * 16 KiB of its file, as a `blobs` entry names it; {@link MediaClient.fetch} returns that file
144
+ * whole. */
145
+ reason: MediaFetchErrorReason | "prefix_only";
146
+ }
147
+ /** A recorded value with the bytes of its digest parts written back. */
148
+ export interface MediaResolution<T = unknown> {
149
+ /** A new value: the input is never changed. Each resolved part holds its bytes in its content
150
+ * field and no longer carries `sha256` and `size`. */
151
+ value: T;
152
+ /** Parts whose bytes were written. */
153
+ resolved: number;
154
+ /** Parts left unchanged, in reading order. */
155
+ failures: MediaResolveFailure[];
156
+ }
157
+ /** A local copy of a file {@link rehydrateMedia} reads by digest: a runner's verified case file
158
+ * (`LocalFile`, read from `path`) or bytes already in memory. */
159
+ export type MediaSourceFile = {
160
+ /** Lowercase hex SHA-256 of the file's bytes. */
161
+ sha256: string;
162
+ /** The file's media type, used when the part declares none. */
163
+ contentType?: string;
164
+ } & ({
165
+ /** Path of the file on this machine. */
166
+ path: string;
167
+ /** Not set when the file is given as a path. */
168
+ bytes?: undefined;
169
+ } | {
170
+ /** The file's bytes. */
171
+ bytes: Uint8Array;
172
+ /** Not set when the file is given as bytes. */
173
+ path?: undefined;
174
+ });
175
+ /** The least time a download may go without a byte before it ends: two minutes. Tests shorten it;
176
+ * it is not exported from the package. */
177
+ export declare const mediaDownload: {
178
+ minIdleMillis: number;
179
+ };
180
+ /**
181
+ * Hue's media reads, `client.media`: one recorded file by digest (`fetch`), and every digest part
182
+ * of a recorded value written back with its bytes (`resolve`). The project key is sent to Hue
183
+ * only; the file itself comes from a short-lived link fetched without it.
184
+ */
185
+ export declare class MediaClient {
186
+ #private;
187
+ /**
188
+ * Downloads one recorded file and checks it against its digest: from a trace (Hue reads the
189
+ * span's record that lists the digest under `hue.blobs`) or from a snapshot's pinned copy. A
190
+ * read Hue answers as unavailable (503 or 429) is asked once more after its `Retry-After`, at
191
+ * most 10 seconds, and so is one that lost its connection or timed out.
192
+ *
193
+ * @throws MediaFetchError for a file Hue does not hold, a refused key, an unavailable store, a
194
+ * file over `maxBytes`, bytes that do not match the digest or a failed request.
195
+ * @throws TypeError for an invalid trace, span or snapshot ID or digest.
196
+ */
197
+ fetch(target: MediaFetchTarget, options?: MediaFetchOptions): Promise<MediaFile>;
198
+ /**
199
+ * Writes the bytes of every digest part in `value` back into its content field, the reverse of
200
+ * the SDK's media capture: each distinct digest is downloaded once with {@link fetch}, at most
201
+ * `concurrency` at a time. Returns a new value; a part whose file cannot be read stays as it is
202
+ * and is reported in `failures`. Parse attributes recorded as JSON text (such as
203
+ * `gen_ai.input.messages`) before passing them; pass the record's `hue.blobs` as `blobs` when
204
+ * reading a snapshot, whose copies carry no media type.
205
+ *
206
+ * @throws TypeError for invalid identifiers or options; never for one file's failure.
207
+ */
208
+ resolve<T>(value: T, options: MediaResolveOptions): Promise<MediaResolution<T>>;
209
+ }
210
+ /**
211
+ * Writes case files back into the digest parts of a case's inputs, by SHA-256: the files a runner
212
+ * hands a target (`context.files`) or any bytes the caller holds, so a target can send the
213
+ * recorded model call again with the real image, document or audio. Each file is read once and
214
+ * checked against its digest; a part with no matching file, or whose file does not match, stays
215
+ * as it is and is reported in `failures`. Returns a new value; the input is never changed.
216
+ */
217
+ export declare function rehydrateMedia<T>(value: T, files: readonly MediaSourceFile[], options?: MediaWriteOptions): Promise<MediaResolution<T>>;