@hue-run/sdk 0.18.0 → 0.18.2
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/CLI.md +130 -8
- package/EVALUATIONS.md +7 -4
- package/README.md +86 -22
- package/dist/attachment.d.ts +51 -7
- package/dist/attachment.js +141 -33
- package/dist/blobs.d.ts +22 -5
- package/dist/blobs.js +187 -72
- package/dist/cli/import.d.ts +35 -0
- package/dist/cli/import.js +692 -0
- package/dist/cli/langfuse.d.ts +309 -0
- package/dist/cli/langfuse.js +1075 -0
- package/dist/client.d.ts +9 -4
- package/dist/client.js +46 -15
- package/dist/evals/client.d.ts +7 -2
- package/dist/evals/client.js +20 -4
- package/dist/evals/files.js +100 -12
- package/dist/evals/media.d.ts +5 -1
- package/dist/evals/media.js +15 -3
- package/dist/evals/types.d.ts +4 -3
- package/dist/inline-files.d.ts +16 -6
- package/dist/inline-files.js +18 -14
- package/dist/json-edits.js +26 -7
- package/dist/large-media.d.ts +106 -0
- package/dist/large-media.js +414 -0
- package/dist/setup/cli.js +7 -3
- package/dist/snapshot.d.ts +2 -0
- package/dist/snapshot.js +19 -1
- package/dist/transport.d.ts +4 -0
- package/dist/transport.js +36 -16
- package/dist/types.d.ts +10 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -166,10 +166,15 @@ export declare class HueClient {
|
|
|
166
166
|
/**
|
|
167
167
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
168
168
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
169
|
-
* and, when `captureContent` is true, `hue.file.name`. `data`
|
|
170
|
-
* uploaded only with `upload: true
|
|
171
|
-
*
|
|
172
|
-
*
|
|
169
|
+
* and, when `captureContent` is true, `hue.file.name`. `data` or the file at `path` is hashed and
|
|
170
|
+
* measured locally and uploaded only with `upload: true`: `data` and a file up to 25 MiB are
|
|
171
|
+
* held in memory until then, while a larger regular file, up to Hue's 1 GB upload limit, is
|
|
172
|
+
* hashed a slice at a time and read again from disk when it is uploaded, and is uploaded only if
|
|
173
|
+
* it still has the size and SHA-256 it was recorded with; a `path` that is no regular file, such
|
|
174
|
+
* as a named pipe, is refused without waiting on it. The event is metadata, so it is
|
|
175
|
+
* recorded in both capture modes. An invalid record, or one without an active span, is omitted
|
|
176
|
+
* and counted, never thrown; an invalid name alone is omitted and counted while the rest is
|
|
177
|
+
* recorded.
|
|
173
178
|
*/
|
|
174
179
|
recordFile(file: FileRecord, explicitContext?: Context): void;
|
|
175
180
|
/** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
|
package/dist/client.js
CHANGED
|
@@ -13,8 +13,8 @@ import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, provider
|
|
|
13
13
|
import { toolCatalogSummary } from "./tool-definitions.js";
|
|
14
14
|
import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, spanSource, } from "./safety.js";
|
|
15
15
|
import { TRUNCATED_KEY, withTruncatedKeys } from "./privacy.js";
|
|
16
|
-
import { countFallback, MAX_BLOB_BYTES, newTally } from "./blobs.js";
|
|
17
|
-
import { attachmentPart, attachmentState, holdsAttachment, MAX_ATTACHMENT_DEPTH, } from "./attachment.js";
|
|
16
|
+
import { countFallback, MAX_BLOB_BYTES, newTally, sizeOf } from "./blobs.js";
|
|
17
|
+
import { attachmentPart, attachmentState, fileSource, holdsAttachment, MAX_ATTACHMENT_DEPTH, readPath, } from "./attachment.js";
|
|
18
18
|
import { createHueTransport, exportBudgetMillis, HueExportError, } from "./transport.js";
|
|
19
19
|
import { verifyTrace } from "./receipt.js";
|
|
20
20
|
import { sdkVersion } from "./version.js";
|
|
@@ -611,21 +611,22 @@ export class HueClient {
|
|
|
611
611
|
const files = [];
|
|
612
612
|
const tally = newTally();
|
|
613
613
|
for (const { state, part, depth } of placed) {
|
|
614
|
-
if (!part ||
|
|
614
|
+
if (!part || state.size === undefined || !state.sha256) {
|
|
615
615
|
this.instrumentationFailed(signal);
|
|
616
616
|
continue;
|
|
617
617
|
}
|
|
618
618
|
if (state.invalidName && captureContent)
|
|
619
619
|
this.instrumentationFailed(signal);
|
|
620
|
-
if (!this.transport.offloadsValues() || !state.
|
|
620
|
+
if (!this.transport.offloadsValues() || !state.size)
|
|
621
621
|
continue;
|
|
622
622
|
if (cut || depth > MAX_ATTACHMENT_DEPTH) {
|
|
623
623
|
countFallback(tally, "budget", traceId);
|
|
624
624
|
continue;
|
|
625
625
|
}
|
|
626
626
|
files.push({
|
|
627
|
-
|
|
628
|
-
|
|
627
|
+
// A file given by path over 25 MiB is read again from disk when it is uploaded.
|
|
628
|
+
bytes: state.file ? fileSource(state.file) : state.bytes,
|
|
629
|
+
digest: { sha256: state.sha256, size: state.size },
|
|
629
630
|
contentType: state.contentType,
|
|
630
631
|
...(state.name === undefined ? {} : { name: state.name }),
|
|
631
632
|
});
|
|
@@ -788,10 +789,15 @@ export class HueClient {
|
|
|
788
789
|
/**
|
|
789
790
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
790
791
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
791
|
-
* and, when `captureContent` is true, `hue.file.name`. `data`
|
|
792
|
-
* uploaded only with `upload: true
|
|
793
|
-
*
|
|
794
|
-
*
|
|
792
|
+
* and, when `captureContent` is true, `hue.file.name`. `data` or the file at `path` is hashed and
|
|
793
|
+
* measured locally and uploaded only with `upload: true`: `data` and a file up to 25 MiB are
|
|
794
|
+
* held in memory until then, while a larger regular file, up to Hue's 1 GB upload limit, is
|
|
795
|
+
* hashed a slice at a time and read again from disk when it is uploaded, and is uploaded only if
|
|
796
|
+
* it still has the size and SHA-256 it was recorded with; a `path` that is no regular file, such
|
|
797
|
+
* as a named pipe, is refused without waiting on it. The event is metadata, so it is
|
|
798
|
+
* recorded in both capture modes. An invalid record, or one without an active span, is omitted
|
|
799
|
+
* and counted, never thrown; an invalid name alone is omitted and counted while the rest is
|
|
800
|
+
* recorded.
|
|
795
801
|
*/
|
|
796
802
|
recordFile(file, explicitContext) {
|
|
797
803
|
if (!this.enabled || this.closed)
|
|
@@ -800,7 +806,7 @@ export class HueClient {
|
|
|
800
806
|
const span = trace.getSpan(explicitContext ?? this.storage.getStore()?.context ?? context.active());
|
|
801
807
|
if (!span?.isRecording())
|
|
802
808
|
throw new Error("File records require an active span");
|
|
803
|
-
const { role, mediaType, data, name } = file;
|
|
809
|
+
const { role, mediaType, data, name, path } = file;
|
|
804
810
|
if (role !== "input" && role !== "attachment" && role !== "output")
|
|
805
811
|
throw new TypeError("Invalid file role");
|
|
806
812
|
if (!isLabel(mediaType))
|
|
@@ -808,7 +814,31 @@ export class HueClient {
|
|
|
808
814
|
let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
|
|
809
815
|
let byteSize = file.byteSize;
|
|
810
816
|
let bytes;
|
|
811
|
-
|
|
817
|
+
// A file given by path over 25 MiB: hashed now and read again from disk when it is uploaded.
|
|
818
|
+
let onDisk;
|
|
819
|
+
let given = data;
|
|
820
|
+
if (path !== undefined) {
|
|
821
|
+
if (data !== undefined)
|
|
822
|
+
throw new TypeError("A file takes data or a path, not both");
|
|
823
|
+
if (typeof path !== "string" || !path)
|
|
824
|
+
throw new TypeError("Invalid file path");
|
|
825
|
+
const read = readPath(path);
|
|
826
|
+
if (!read)
|
|
827
|
+
throw new RangeError("File must be a regular file within Hue's 1 GB upload limit");
|
|
828
|
+
if ("bytes" in read)
|
|
829
|
+
given = read.bytes;
|
|
830
|
+
else {
|
|
831
|
+
onDisk = read.file;
|
|
832
|
+
// A caller-supplied digest or size must describe the same bytes.
|
|
833
|
+
if ((sha256 !== undefined && sha256 !== onDisk.sha256) ||
|
|
834
|
+
(byteSize !== undefined && byteSize !== onDisk.size))
|
|
835
|
+
throw new TypeError("File digest or size does not match its data");
|
|
836
|
+
sha256 = onDisk.sha256;
|
|
837
|
+
byteSize = onDisk.size;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (given !== undefined) {
|
|
841
|
+
const data = given;
|
|
812
842
|
bytes =
|
|
813
843
|
typeof data === "string"
|
|
814
844
|
? (() => {
|
|
@@ -888,10 +918,11 @@ export class HueClient {
|
|
|
888
918
|
if (eventTime)
|
|
889
919
|
this.fileEventTimes.set(source, eventTime);
|
|
890
920
|
if (file.upload === true && this.transport.offloadsValues()) {
|
|
891
|
-
|
|
921
|
+
const upload = bytes ?? (onDisk ? fileSource(onDisk) : undefined);
|
|
922
|
+
if (!this.transport.stageMedia(span, upload && eventTime
|
|
892
923
|
? {
|
|
893
|
-
bytes,
|
|
894
|
-
digest: { sha256, size:
|
|
924
|
+
bytes: upload,
|
|
925
|
+
digest: { sha256, size: sizeOf(upload) },
|
|
895
926
|
contentType: mediaType,
|
|
896
927
|
eventTime,
|
|
897
928
|
...(isMediaName(name) ? { name } : {}),
|
package/dist/evals/client.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export declare class EvaluationClient {
|
|
|
79
79
|
}): Promise<Uint8Array>;
|
|
80
80
|
/** Stage bytes at the storage capability Hue issued. The Hue key is never sent to storage. */
|
|
81
81
|
uploadArtifactBytes(upload: ArtifactUpload, bytes: Uint8Array, contentType: string): Promise<void>;
|
|
82
|
+
private putArtifact;
|
|
82
83
|
private page;
|
|
83
84
|
private registryPage;
|
|
84
85
|
/** Reads the current project to confirm the key and origin. */
|
|
@@ -106,10 +107,14 @@ export declare class EvaluationClient {
|
|
|
106
107
|
* `files` given as bytes or a path is checked against the artifact policy and uploaded as a
|
|
107
108
|
* verified artifact first, under a key derived from the version, the case's `externalKey`, the
|
|
108
109
|
* role and the bytes, so sending the same case again reuses the artifact. Every file and the
|
|
109
|
-
* case itself are checked before the first upload.
|
|
110
|
+
* case itself are checked before the first upload. A file given by `path` is hashed a slice at
|
|
111
|
+
* a time when it is checked and read again when it is uploaded, one file at a time, so no more
|
|
112
|
+
* than one file is held in memory; only bytes that match the checked SHA-256 are sent.
|
|
110
113
|
*
|
|
111
114
|
* @throws TypeError or RangeError for `files` outside the artifact policy, or a case Hue would
|
|
112
|
-
* refuse for its fields or size, before any request.
|
|
115
|
+
* refuse for its fields or size, before any request. TypeError when a file given by `path`
|
|
116
|
+
* changed after it was checked, or can no longer be read, before any of its bytes are sent: it
|
|
117
|
+
* is not staged and the case is not written.
|
|
113
118
|
*/
|
|
114
119
|
addCase(id: string, input: CaseWrite): Promise<{
|
|
115
120
|
/** The stored case. */
|
package/dist/evals/client.js
CHANGED
|
@@ -402,17 +402,29 @@ export class EvaluationClient {
|
|
|
402
402
|
}
|
|
403
403
|
}
|
|
404
404
|
/** Stage bytes at the storage capability Hue issued. The Hue key is never sent to storage. */
|
|
405
|
-
|
|
405
|
+
uploadArtifactBytes(upload, bytes, contentType) {
|
|
406
|
+
return this.putArtifact(upload, contentType, bytes);
|
|
407
|
+
}
|
|
408
|
+
/** @internal Stage `byteSize` bytes read from `body` a slice at a time, without the copy a
|
|
409
|
+
* byte body is given; the request declares their length, as it does for bytes. */
|
|
410
|
+
uploadArtifactStream(upload, body, byteSize, contentType) {
|
|
411
|
+
return this.putArtifact(upload, contentType, body, byteSize);
|
|
412
|
+
}
|
|
413
|
+
async putArtifact(upload, contentType, body, byteSize) {
|
|
406
414
|
const uploadUrl = signedUploadUrl(upload.uploadUrl);
|
|
407
415
|
if (upload.method !== "PUT")
|
|
408
416
|
throw new HueApiError();
|
|
409
417
|
const headers = signedUploadHeaders(upload.headers, contentType);
|
|
418
|
+
// Storage refuses a body of unknown length, which a stream would otherwise be sent as.
|
|
419
|
+
if (byteSize !== undefined)
|
|
420
|
+
headers["content-length"] = String(byteSize);
|
|
410
421
|
let response;
|
|
411
422
|
try {
|
|
412
423
|
response = await fetch(uploadUrl, {
|
|
413
424
|
method: "PUT",
|
|
414
425
|
headers,
|
|
415
|
-
body
|
|
426
|
+
body,
|
|
427
|
+
...(byteSize !== undefined ? { duplex: "half" } : {}),
|
|
416
428
|
redirect: "error",
|
|
417
429
|
signal: AbortSignal.timeout(Math.max(this.timeoutMillis, 120_000)),
|
|
418
430
|
});
|
|
@@ -480,10 +492,14 @@ export class EvaluationClient {
|
|
|
480
492
|
* `files` given as bytes or a path is checked against the artifact policy and uploaded as a
|
|
481
493
|
* verified artifact first, under a key derived from the version, the case's `externalKey`, the
|
|
482
494
|
* role and the bytes, so sending the same case again reuses the artifact. Every file and the
|
|
483
|
-
* case itself are checked before the first upload.
|
|
495
|
+
* case itself are checked before the first upload. A file given by `path` is hashed a slice at
|
|
496
|
+
* a time when it is checked and read again when it is uploaded, one file at a time, so no more
|
|
497
|
+
* than one file is held in memory; only bytes that match the checked SHA-256 are sent.
|
|
484
498
|
*
|
|
485
499
|
* @throws TypeError or RangeError for `files` outside the artifact policy, or a case Hue would
|
|
486
|
-
* refuse for its fields or size, before any request.
|
|
500
|
+
* refuse for its fields or size, before any request. TypeError when a file given by `path`
|
|
501
|
+
* changed after it was checked, or can no longer be read, before any of its bytes are sent: it
|
|
502
|
+
* is not staged and the case is not written.
|
|
487
503
|
*/
|
|
488
504
|
async addCase(id, input) {
|
|
489
505
|
// The canonical spelling: the reservation keys of uploaded files name the version.
|
package/dist/evals/files.js
CHANGED
|
@@ -426,15 +426,22 @@ export async function uploadOutputFiles(client, executionId, files, save, timing
|
|
|
426
426
|
}
|
|
427
427
|
/** Reserve, stage and verify one artifact; its ID once Hue verified it. Replaying the key settles
|
|
428
428
|
* on the same artifact, including one Hue finished verifying after an earlier attempt stopped. */
|
|
429
|
-
async function publishArtifact(client, declared,
|
|
429
|
+
async function publishArtifact(client, declared, body, label, timing) {
|
|
430
430
|
const reserved = await client.reserveArtifact(declared);
|
|
431
431
|
let state = reserved.state;
|
|
432
432
|
if (state !== "ready") {
|
|
433
433
|
// A verification already running holds the uploaded bytes; Hue refuses another upload then.
|
|
434
434
|
if (reserved.copyState !== "acknowledged" && state !== "verifying") {
|
|
435
|
+
// A checked file is read again, whole, only now and only if it still has the bytes its
|
|
436
|
+
// check hashed: a file that changed sends nothing.
|
|
437
|
+
const bytes = body instanceof Uint8Array ? body : await checkedBytes(body);
|
|
435
438
|
const upload = await client.requestArtifactUpload(reserved.id);
|
|
436
439
|
try {
|
|
437
|
-
|
|
440
|
+
if (body instanceof Uint8Array)
|
|
441
|
+
await client.uploadArtifactBytes(upload, bytes, declared.contentType);
|
|
442
|
+
// Sent from the write's own buffer a slice at a time, never copied whole.
|
|
443
|
+
else
|
|
444
|
+
await client.uploadArtifactStream(upload, slicesOf(bytes), bytes.byteLength, declared.contentType);
|
|
438
445
|
}
|
|
439
446
|
catch {
|
|
440
447
|
// A lost staging acknowledgement or an immutable object already present can only be
|
|
@@ -447,6 +454,59 @@ async function publishArtifact(client, declared, bytes, label, timing) {
|
|
|
447
454
|
throw new Error(`${label} was not verified by Hue (${state})`);
|
|
448
455
|
return reserved.id;
|
|
449
456
|
}
|
|
457
|
+
/** How much of a case file given by path its check reads at once, to hash it. */
|
|
458
|
+
const CASE_FILE_SLICE = 1024 * 1024;
|
|
459
|
+
/** Bytes as a stream of 1 MiB views of them, made as the request reads them. */
|
|
460
|
+
function slicesOf(bytes) {
|
|
461
|
+
let offset = 0;
|
|
462
|
+
return new ReadableStream({
|
|
463
|
+
pull(controller) {
|
|
464
|
+
if (offset >= bytes.byteLength)
|
|
465
|
+
return controller.close();
|
|
466
|
+
const end = Math.min(bytes.byteLength, offset + CASE_FILE_SLICE);
|
|
467
|
+
controller.enqueue(bytes.subarray(offset, end));
|
|
468
|
+
offset = end;
|
|
469
|
+
},
|
|
470
|
+
}, { highWaterMark: 0 });
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The bytes of a checked case file, read whole when it is uploaded: case files are uploaded one
|
|
474
|
+
* at a time, into one buffer the write reuses, so no more than one is held in memory. Only bytes
|
|
475
|
+
* with the checked size and SHA-256 are returned, so the bytes sent are the bytes the check
|
|
476
|
+
* hashed; a file that changed after its check, or can no longer be read, is refused with a
|
|
477
|
+
* `TypeError` before anything is sent. The bytes are valid until the next file is read.
|
|
478
|
+
*/
|
|
479
|
+
async function checkedBytes(file) {
|
|
480
|
+
const changed = () => new TypeError(`${file.label} changed after it was checked`);
|
|
481
|
+
const unreadable = (error) => new TypeError(`${file.label} could not be read`, { cause: error });
|
|
482
|
+
const handle = await open(file.path, "r").catch((error) => {
|
|
483
|
+
throw unreadable(error);
|
|
484
|
+
});
|
|
485
|
+
try {
|
|
486
|
+
const read = (buffer, offset) => handle
|
|
487
|
+
.read(buffer, offset, buffer.byteLength - offset, null)
|
|
488
|
+
.then(({ bytesRead }) => bytesRead)
|
|
489
|
+
.catch((error) => {
|
|
490
|
+
throw unreadable(error);
|
|
491
|
+
});
|
|
492
|
+
// Never more than its checked size and one byte, the byte that shows it grew.
|
|
493
|
+
if ((file.scratch.buffer?.byteLength ?? 0) < file.byteSize)
|
|
494
|
+
file.scratch.buffer = Buffer.allocUnsafe(file.byteSize);
|
|
495
|
+
const bytes = file.scratch.buffer.subarray(0, file.byteSize);
|
|
496
|
+
for (let offset = 0; offset < bytes.byteLength;) {
|
|
497
|
+
const bytesRead = await read(bytes, offset);
|
|
498
|
+
if (!bytesRead)
|
|
499
|
+
throw changed();
|
|
500
|
+
offset += bytesRead;
|
|
501
|
+
}
|
|
502
|
+
if ((await read(Buffer.alloc(1), 0)) || sha256(bytes) !== file.sha256)
|
|
503
|
+
throw changed();
|
|
504
|
+
return bytes;
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
await handle.close().catch(() => undefined);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
450
510
|
/** Roles a pinned case input can carry. */
|
|
451
511
|
const caseFileRoles = [
|
|
452
512
|
...targetFileRoles,
|
|
@@ -466,8 +526,9 @@ function caseFileName(name) {
|
|
|
466
526
|
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
527
|
return name;
|
|
468
528
|
}
|
|
469
|
-
/**
|
|
470
|
-
|
|
529
|
+
/** A case file given by path, checked: a regular file within the size limit, hashed a slice at a
|
|
530
|
+
* time so it is never held whole. Its emptiness is the caller's check, as for bytes. */
|
|
531
|
+
async function checkedFile(path, label, scratch) {
|
|
471
532
|
if (typeof path !== "string" || !path)
|
|
472
533
|
throw new TypeError(`${label} needs bytes or a path`);
|
|
473
534
|
// A path with no file behind it, or one that cannot be read, is refused as any invalid file.
|
|
@@ -479,9 +540,32 @@ async function caseFileBytes(path, label) {
|
|
|
479
540
|
throw new TypeError(`${label} is not a regular file`);
|
|
480
541
|
if (info.size > outputFileLimits.bytes)
|
|
481
542
|
throw new RangeError(`${label} exceeds 25 MiB`);
|
|
482
|
-
|
|
543
|
+
const handle = await open(path, "r").catch((error) => {
|
|
483
544
|
throw unreadable(error);
|
|
484
545
|
});
|
|
546
|
+
try {
|
|
547
|
+
const hash = createHash("sha256");
|
|
548
|
+
const slice = Buffer.allocUnsafe(CASE_FILE_SLICE);
|
|
549
|
+
let byteSize = 0;
|
|
550
|
+
for (;;) {
|
|
551
|
+
const { bytesRead } = await handle
|
|
552
|
+
.read(slice, 0, slice.byteLength, null)
|
|
553
|
+
.catch((error) => {
|
|
554
|
+
throw unreadable(error);
|
|
555
|
+
});
|
|
556
|
+
if (!bytesRead)
|
|
557
|
+
break;
|
|
558
|
+
byteSize += bytesRead;
|
|
559
|
+
// A file that grew past the limit since its size was read is refused, not read whole.
|
|
560
|
+
if (byteSize > outputFileLimits.bytes)
|
|
561
|
+
throw new RangeError(`${label} exceeds 25 MiB`);
|
|
562
|
+
hash.update(slice.subarray(0, bytesRead));
|
|
563
|
+
}
|
|
564
|
+
return { path, label, byteSize, sha256: hash.digest("hex"), scratch };
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
await handle.close().catch(() => undefined);
|
|
568
|
+
}
|
|
485
569
|
}
|
|
486
570
|
/** A stand-in for an artifact a case file is uploaded as, the length of a real ID. */
|
|
487
571
|
const STAND_IN_ID = "00000000-0000-4000-8000-000000000000";
|
|
@@ -502,6 +586,7 @@ export async function caseFileReferences(client, versionId, input, beforeUpload
|
|
|
502
586
|
throw new RangeError(`A case pins at most ${MAX_CASE_FILES} files`);
|
|
503
587
|
const prepared = [];
|
|
504
588
|
const seen = new Set();
|
|
589
|
+
const scratch = {};
|
|
505
590
|
for (const [index, file] of files.entries()) {
|
|
506
591
|
const label = `Case file ${index + 1}`;
|
|
507
592
|
if (!file || typeof file !== "object")
|
|
@@ -530,18 +615,21 @@ export async function caseFileReferences(client, versionId, input, beforeUpload
|
|
|
530
615
|
throw new TypeError(`${label} names bytes and a path; give one`);
|
|
531
616
|
if (file.bytes !== undefined && !(file.bytes instanceof Uint8Array))
|
|
532
617
|
throw new TypeError(`${label} bytes must be a Uint8Array`);
|
|
533
|
-
|
|
534
|
-
|
|
618
|
+
// Bytes are kept as given; a path is kept as its size and digest, and read again to upload.
|
|
619
|
+
const body = file.bytes ?? (await checkedFile(file.path, label, scratch));
|
|
620
|
+
const byteSize = body instanceof Uint8Array ? body.byteLength : body.byteSize;
|
|
621
|
+
if (!byteSize)
|
|
535
622
|
throw new RangeError(`${label} is empty`);
|
|
536
|
-
if (
|
|
623
|
+
if (byteSize > outputFileLimits.bytes)
|
|
537
624
|
throw new RangeError(`${label} exceeds 25 MiB`);
|
|
538
|
-
const digest = sha256(
|
|
625
|
+
const digest = body instanceof Uint8Array ? sha256(body) : body.sha256;
|
|
539
626
|
prepared.push({
|
|
540
627
|
role: file.role,
|
|
541
628
|
filename,
|
|
542
629
|
contentType: file.contentType,
|
|
543
|
-
|
|
630
|
+
byteSize,
|
|
544
631
|
sha256: digest,
|
|
632
|
+
body,
|
|
545
633
|
});
|
|
546
634
|
identity = `file:${digest}:${file.contentType}:${filename}`;
|
|
547
635
|
}
|
|
@@ -574,9 +662,9 @@ export async function caseFileReferences(client, versionId, input, beforeUpload
|
|
|
574
662
|
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
663
|
filename: item.filename,
|
|
576
664
|
contentType: item.contentType,
|
|
577
|
-
byteSize: item.
|
|
665
|
+
byteSize: item.byteSize,
|
|
578
666
|
sha256: item.sha256,
|
|
579
|
-
}, item.
|
|
667
|
+
}, item.body, `Case file ${item.filename}`, timing);
|
|
580
668
|
pin({ artifactId, role: item.role });
|
|
581
669
|
}
|
|
582
670
|
return references;
|
package/dist/evals/media.d.ts
CHANGED
|
@@ -38,8 +38,12 @@ export declare class MediaFetchError extends Error {
|
|
|
38
38
|
/** HTTP status of the answer that failed, when there was one. */
|
|
39
39
|
status?: number | undefined,
|
|
40
40
|
/** Seconds Hue asked the caller to wait, on `unavailable`, when it said. */
|
|
41
|
-
retryAfterSeconds?: number | undefined,
|
|
41
|
+
retryAfterSeconds?: number | undefined,
|
|
42
|
+
/** `cause`, the error behind a failure; `snapshot`, set when a snapshot's copy was read, so a
|
|
43
|
+
* `forbidden` message names the Read and write key it needs. */
|
|
44
|
+
options?: {
|
|
42
45
|
cause?: unknown;
|
|
46
|
+
snapshot?: boolean;
|
|
43
47
|
});
|
|
44
48
|
}
|
|
45
49
|
/** One recorded file, downloaded and checked against its digest. */
|
package/dist/evals/media.js
CHANGED
|
@@ -11,6 +11,8 @@ const messages = {
|
|
|
11
11
|
digest_mismatch: "The downloaded bytes do not match the requested SHA-256",
|
|
12
12
|
failed: "The media request failed",
|
|
13
13
|
};
|
|
14
|
+
/** A snapshot's copies take only a Read and write key, where a trace's files take a Read key too. */
|
|
15
|
+
const snapshotForbidden = "The key cannot read a snapshot's files; use a Read and write key";
|
|
14
16
|
/** Thrown by {@link MediaClient.fetch}. The message is fixed and never includes response text. */
|
|
15
17
|
export class MediaFetchError extends Error {
|
|
16
18
|
reason;
|
|
@@ -22,8 +24,16 @@ export class MediaFetchError extends Error {
|
|
|
22
24
|
/** HTTP status of the answer that failed, when there was one. */
|
|
23
25
|
status,
|
|
24
26
|
/** Seconds Hue asked the caller to wait, on `unavailable`, when it said. */
|
|
25
|
-
retryAfterSeconds,
|
|
26
|
-
|
|
27
|
+
retryAfterSeconds,
|
|
28
|
+
/** `cause`, the error behind a failure; `snapshot`, set when a snapshot's copy was read, so a
|
|
29
|
+
* `forbidden` message names the Read and write key it needs. */
|
|
30
|
+
options) {
|
|
31
|
+
const { snapshot, ...rest } = options ?? {};
|
|
32
|
+
super(reason === "failed" && status
|
|
33
|
+
? `${messages.failed} (HTTP ${status})`
|
|
34
|
+
: reason === "forbidden" && snapshot
|
|
35
|
+
? snapshotForbidden
|
|
36
|
+
: messages[reason], rest);
|
|
27
37
|
this.reason = reason;
|
|
28
38
|
this.status = status;
|
|
29
39
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
@@ -354,7 +364,9 @@ export class MediaClient {
|
|
|
354
364
|
}
|
|
355
365
|
if (status === 401 || status === 403) {
|
|
356
366
|
await response.body?.cancel().catch(() => undefined);
|
|
357
|
-
throw new MediaFetchError("forbidden", status
|
|
367
|
+
throw new MediaFetchError("forbidden", status, undefined, {
|
|
368
|
+
snapshot: path.startsWith("/trace-snapshots/"),
|
|
369
|
+
});
|
|
358
370
|
}
|
|
359
371
|
if (!response.ok) {
|
|
360
372
|
await response.body?.cancel().catch(() => undefined);
|
package/dist/evals/types.d.ts
CHANGED
|
@@ -186,9 +186,10 @@ export interface CaseFileReference {
|
|
|
186
186
|
}
|
|
187
187
|
/**
|
|
188
188
|
* A pinned input file for {@link CaseWrite.files}: a {@link CaseFileReference}, or a file the
|
|
189
|
-
* client uploads as a verified artifact, from `bytes` or read
|
|
190
|
-
*
|
|
191
|
-
*
|
|
189
|
+
* client uploads as a verified artifact, from `bytes` or read from `path`: hashed a slice at a
|
|
190
|
+
* time when the case is checked, then read again as it is uploaded, one file at a time. An
|
|
191
|
+
* uploaded file must have one of `outputContentTypes`, a well-formed name of 1–255 UTF-16 code
|
|
192
|
+
* units without path separators or control characters, and 1 byte to 25 MiB.
|
|
192
193
|
*/
|
|
193
194
|
export type CaseFileWrite = (CaseFileReference & {
|
|
194
195
|
/** Not set on an artifact reference: the artifact keeps its own name. */
|
package/dist/inline-files.d.ts
CHANGED
|
@@ -27,6 +27,9 @@ export interface InlineFileBytes {
|
|
|
27
27
|
bytes: Buffer;
|
|
28
28
|
decoded: boolean;
|
|
29
29
|
urlMediaType?: string;
|
|
30
|
+
/** The bytes were decoded from text that reads as base64 by its alphabet alone, not from a
|
|
31
|
+
* `data:` URL or a field its convention declares base64. */
|
|
32
|
+
bareBase64?: boolean;
|
|
30
33
|
}
|
|
31
34
|
/**
|
|
32
35
|
* The bytes an inline file part carries, the file's own bytes whatever its media type: a `data:`
|
|
@@ -134,14 +137,21 @@ export declare function placedMediaText(media: MediaValue): string;
|
|
|
134
137
|
/** Each JSON-encoded string a placed file sits in, written again, innermost first: the string as
|
|
135
138
|
* recorded with only its placed files changed. */
|
|
136
139
|
export declare function reencodeMedia(media: MediaValue): void;
|
|
137
|
-
/** Whether a found file has bytes to place: nonempty, and a whole string's `data:`
|
|
138
|
-
|
|
140
|
+
/** Whether a found file has bytes to place: nonempty (`size` bytes), and a whole string's `data:`
|
|
141
|
+
* URL decodes. */
|
|
142
|
+
export declare function placeable(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded">, size: number): boolean;
|
|
139
143
|
/** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
|
|
140
|
-
* provider part, whose entry names the content) rather than its digest. A whole string
|
|
141
|
-
*
|
|
142
|
-
|
|
144
|
+
* provider part, whose entry names the content) rather than its digest. A whole string, a file
|
|
145
|
+
* inside a JSON-encoded string, and a text file a part holds as bare base64 (a GenAI `blob`,
|
|
146
|
+
* Vertex `media` or AI SDK part, whose content is not the text itself) take the digest form. */
|
|
147
|
+
export declare function keepsPrefix(file: InlineFilePart, textual: boolean, decoded: Pick<InlineFileBytes, "bareBase64">): boolean;
|
|
148
|
+
/** A file's bytes: a field its convention declares base64 decoded, any other content read as
|
|
149
|
+
* Hue's reader reads it, whatever the part's media type (`inlineFileBytes`): a `data:` URL by its
|
|
150
|
+
* own encoding, text that reads as base64 decoded (what the GenAI conventions, and
|
|
151
|
+
* `client.media.resolve`, write in a `blob` or `media` part for every media type), anything else
|
|
152
|
+
* as its UTF-8. */
|
|
143
153
|
export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
|
|
144
|
-
export declare function mediaContentType(file: InlineFilePart, decoded: InlineFileBytes): string;
|
|
154
|
+
export declare function mediaContentType(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded" | "urlMediaType">): string;
|
|
145
155
|
export interface FileDigest {
|
|
146
156
|
sha256: string;
|
|
147
157
|
size: number;
|
package/dist/inline-files.js
CHANGED
|
@@ -140,6 +140,7 @@ export function inlineFileBytes(content) {
|
|
|
140
140
|
bytes: decoded ?? Buffer.from(content, "utf8"),
|
|
141
141
|
decoded: decoded !== undefined,
|
|
142
142
|
...(mediaType ? { urlMediaType: mediaType } : {}),
|
|
143
|
+
...(decoded !== undefined && !url ? { bareBase64: true } : {}),
|
|
143
144
|
};
|
|
144
145
|
}
|
|
145
146
|
const audioTypes = {
|
|
@@ -706,23 +707,26 @@ export function reencodeMedia(media) {
|
|
|
706
707
|
});
|
|
707
708
|
}
|
|
708
709
|
}
|
|
709
|
-
/** Whether a found file has bytes to place: nonempty, and a whole string's `data:`
|
|
710
|
-
|
|
711
|
-
|
|
710
|
+
/** Whether a found file has bytes to place: nonempty (`size` bytes), and a whole string's `data:`
|
|
711
|
+
* URL decodes. */
|
|
712
|
+
export function placeable(file, decoded, size) {
|
|
713
|
+
return size > 0 && (!file.whole || decoded.decoded);
|
|
712
714
|
}
|
|
713
715
|
/** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
|
|
714
|
-
* provider part, whose entry names the content) rather than its digest. A whole string
|
|
715
|
-
*
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
716
|
+
* provider part, whose entry names the content) rather than its digest. A whole string, a file
|
|
717
|
+
* inside a JSON-encoded string, and a text file a part holds as bare base64 (a GenAI `blob`,
|
|
718
|
+
* Vertex `media` or AI SDK part, whose content is not the text itself) take the digest form. */
|
|
719
|
+
export function keepsPrefix(file, textual, decoded) {
|
|
720
|
+
return textual && !file.whole && !file.encoded?.length && !decoded.bareBase64;
|
|
721
|
+
}
|
|
722
|
+
/** A file's bytes: a field its convention declares base64 decoded, any other content read as
|
|
723
|
+
* Hue's reader reads it, whatever the part's media type (`inlineFileBytes`): a `data:` URL by its
|
|
724
|
+
* own encoding, text that reads as base64 decoded (what the GenAI conventions, and
|
|
725
|
+
* `client.media.resolve`, write in a `blob` or `media` part for every media type), anything else
|
|
726
|
+
* as its UTF-8. */
|
|
719
727
|
export function mediaBytes(file) {
|
|
720
728
|
if (file.base64)
|
|
721
729
|
return { bytes: Buffer.from(file.content, "base64"), decoded: true };
|
|
722
|
-
if (isMediaContentType(file.contentType) &&
|
|
723
|
-
isTextMediaType(file.contentType) &&
|
|
724
|
-
!file.content.startsWith("data:"))
|
|
725
|
-
return { bytes: Buffer.from(file.content, "utf8"), decoded: false };
|
|
726
730
|
return inlineFileBytes(file.content);
|
|
727
731
|
}
|
|
728
732
|
export function mediaContentType(file, decoded) {
|
|
@@ -838,7 +842,7 @@ export function digestMedia(media, nested, count, memo, traceId = "") {
|
|
|
838
842
|
let changed = false;
|
|
839
843
|
for (const file of media.files) {
|
|
840
844
|
const decoded = mediaBytes(file);
|
|
841
|
-
if (!placeable(file, decoded))
|
|
845
|
+
if (!placeable(file, decoded, decoded.bytes.byteLength))
|
|
842
846
|
continue;
|
|
843
847
|
if ((nested || isTextMediaType(mediaContentType(file, decoded))) &&
|
|
844
848
|
decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
|
@@ -872,7 +876,7 @@ export function digestFormBytes(value) {
|
|
|
872
876
|
continue;
|
|
873
877
|
inspected += units;
|
|
874
878
|
const decoded = mediaBytes(file);
|
|
875
|
-
if (!placeable(file, decoded))
|
|
879
|
+
if (!placeable(file, decoded, decoded.bytes.byteLength))
|
|
876
880
|
continue;
|
|
877
881
|
if (isTextMediaType(mediaContentType(file, decoded)) &&
|
|
878
882
|
decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
package/dist/json-edits.js
CHANGED
|
@@ -28,6 +28,11 @@ function locate(text, wanted) {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
const stringEnd = (index) => {
|
|
31
|
+
// A string without escapes ends at the next quote, found natively: a long file's content
|
|
32
|
+
// is passed over without a step per character.
|
|
33
|
+
const quote = text.indexOf('"', index + 1);
|
|
34
|
+
if (quote !== -1 && !text.slice(index + 1, quote).includes("\\"))
|
|
35
|
+
return quote + 1;
|
|
31
36
|
let at = index + 1;
|
|
32
37
|
for (;;) {
|
|
33
38
|
const code = text.charCodeAt(at);
|
|
@@ -150,32 +155,46 @@ export function applyJsonEdits(text, edits) {
|
|
|
150
155
|
start: span.start,
|
|
151
156
|
end: span.end,
|
|
152
157
|
render: () => {
|
|
153
|
-
const kept = members.filter((member) => !edit.remove.has(member.key));
|
|
154
158
|
const first = members[0]?.start ?? span.end - 1;
|
|
155
159
|
const close = members.length ? members.at(-1).end : span.end - 1;
|
|
156
160
|
let out = text.slice(span.start, first);
|
|
157
|
-
kept
|
|
158
|
-
|
|
161
|
+
let kept = 0;
|
|
162
|
+
members.forEach((member, index) => {
|
|
163
|
+
if (edit.remove.has(member.key))
|
|
164
|
+
return;
|
|
159
165
|
// The separator that preceded this member, comma included, unless it now comes first.
|
|
160
|
-
if (
|
|
166
|
+
if (kept++ > 0)
|
|
161
167
|
out += text.slice(members[index - 1].end, member.start);
|
|
162
168
|
out += render(member.start, member.end);
|
|
163
169
|
});
|
|
164
170
|
let appended = "";
|
|
165
171
|
for (const [key, value] of edit.set)
|
|
166
172
|
if (!lastOf.has(key) || edit.remove.has(key))
|
|
167
|
-
appended += `${kept
|
|
173
|
+
appended += `${kept || appended ? "," : ""}${JSON.stringify(key)}:${value}`;
|
|
168
174
|
return out + appended + text.slice(close, span.end);
|
|
169
175
|
},
|
|
170
176
|
});
|
|
171
177
|
}
|
|
172
178
|
pieces.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
173
179
|
/** The text from `start` to `end` with the pieces within it rendered; a piece inside another
|
|
174
|
-
* is rendered by the outer one. */
|
|
180
|
+
* is rendered by the outer one. Only the pieces that start within the range are visited. */
|
|
175
181
|
const render = (start, end) => {
|
|
176
182
|
let out = "";
|
|
177
183
|
let at = start;
|
|
178
|
-
|
|
184
|
+
// The first piece starting at or after `start`, found by halving.
|
|
185
|
+
let low = 0;
|
|
186
|
+
let high = pieces.length;
|
|
187
|
+
while (low < high) {
|
|
188
|
+
const middle = (low + high) >> 1;
|
|
189
|
+
if (pieces[middle].start < start)
|
|
190
|
+
low = middle + 1;
|
|
191
|
+
else
|
|
192
|
+
high = middle;
|
|
193
|
+
}
|
|
194
|
+
for (let index = low; index < pieces.length; index++) {
|
|
195
|
+
const piece = pieces[index];
|
|
196
|
+
if (piece.start >= end)
|
|
197
|
+
break;
|
|
179
198
|
if (piece.start < at || piece.end > end)
|
|
180
199
|
continue;
|
|
181
200
|
out += text.slice(at, piece.start) + piece.render();
|