@hue-run/sdk 0.16.0 → 0.17.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.
- package/CLI.md +18 -11
- package/README.md +68 -43
- package/dist/blobs.d.ts +35 -17
- package/dist/blobs.js +141 -75
- package/dist/cli/eval-direct.d.ts +3 -2
- package/dist/cli/eval-direct.js +49 -6
- package/dist/client.d.ts +3 -1
- package/dist/client.js +70 -20
- package/dist/evals/files.js +12 -0
- package/dist/inline-files.d.ts +38 -29
- package/dist/inline-files.js +288 -83
- package/dist/privacy.d.ts +1 -1
- package/dist/privacy.js +4 -10
- package/dist/safety.d.ts +1 -0
- package/dist/safety.js +7 -1
- package/dist/snapshot.d.ts +6 -3
- package/dist/snapshot.js +70 -25
- package/dist/transport.d.ts +11 -0
- package/dist/transport.js +296 -20
- package/dist/types.d.ts +7 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/blobs.js
CHANGED
|
@@ -2,19 +2,17 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { context } from "@opentelemetry/api";
|
|
3
3
|
import { suppressTracing } from "@opentelemetry/core";
|
|
4
4
|
import { isLoopbackHost } from "./config.js";
|
|
5
|
-
import { digestPart, hashInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD,
|
|
5
|
+
import { digestPart, hashInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, mediaBytes, mediaContentType, isTextMediaType, mayInlineFiles, isMediaContentType, largeInlineFileParts, MAX_INLINE_FILE_TEXT, } from "./inline-files.js";
|
|
6
6
|
import { TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
|
|
7
7
|
import { truncateUtf8 } from "./safety.js";
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* as it was before uploads existed: cut to the inline limit and listed under `hue.truncated` (an
|
|
14
|
-
* inline file as its digest), reported as a warning and counted.
|
|
9
|
+
* Large values and binary media travel apart from their span through reservation, presigned PUT,
|
|
10
|
+
* and completion. Text keeps a 16 KiB prefix. Binary media keeps digest metadata on its part.
|
|
11
|
+
* Successful uploads are listed in `hue.blobs`; failures keep inline fallback or file metadata,
|
|
12
|
+
* are reported as warnings, and are counted.
|
|
15
13
|
*/
|
|
16
14
|
/** The span attribute listing the values the SDK uploaded apart from the span: one compact JSON
|
|
17
|
-
* object per value, `{"key", "sha256", "size", "content_type"}`,
|
|
15
|
+
* object per value, `{"key", "sha256", "size", "content_type"}`, with an optional `name`. */
|
|
18
16
|
export const BLOBS_KEY = "hue.blobs";
|
|
19
17
|
/** The largest value one upload may hold (`Hue-Max-Blob-Bytes`): a larger value is cut inline. */
|
|
20
18
|
export const MAX_BLOB_BYTES = 1_000_000_000;
|
|
@@ -50,28 +48,23 @@ export function uploadBudgetMillis(timeoutMillis) {
|
|
|
50
48
|
}
|
|
51
49
|
/** The fixed description of each fallback, as an export issue reports it. */
|
|
52
50
|
export const FALLBACK_MESSAGES = {
|
|
53
|
-
unsupported: "This Hue server does not accept uploaded values; values
|
|
54
|
-
failed: "
|
|
55
|
-
tooLarge: "
|
|
56
|
-
budget: "
|
|
51
|
+
unsupported: "This Hue server does not accept uploaded values; large values were cut inline and media kept digest metadata",
|
|
52
|
+
failed: "Content could not be uploaded; large values were cut inline and media kept digest metadata",
|
|
53
|
+
tooLarge: "Content over its upload limit kept inline fallback or file metadata",
|
|
54
|
+
budget: "Content did not fit the SDK's upload budget; large values were cut inline and media kept digest metadata",
|
|
57
55
|
};
|
|
58
56
|
export function newTally() {
|
|
59
57
|
return { uploaded: 0, fallbacks: new Map() };
|
|
60
58
|
}
|
|
61
|
-
function countFallback(tally, reason, traceId) {
|
|
59
|
+
export function countFallback(tally, reason, traceId) {
|
|
62
60
|
const entry = tally.fallbacks.get(reason) ?? { count: 0, traceIds: new Set() };
|
|
63
61
|
entry.count++;
|
|
64
62
|
entry.traceIds.add(traceId);
|
|
65
63
|
tally.fallbacks.set(reason, entry);
|
|
66
64
|
}
|
|
67
|
-
/** A media type
|
|
68
|
-
* printable ASCII characters. */
|
|
69
|
-
const CONTENT_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+(?: *;[ -~]*)?$/;
|
|
65
|
+
/** A media type accepted by the upload contract. */
|
|
70
66
|
export function isBlobContentType(value) {
|
|
71
|
-
return (
|
|
72
|
-
value.length >= 3 &&
|
|
73
|
-
value.length <= 255 &&
|
|
74
|
-
CONTENT_TYPE.test(value));
|
|
67
|
+
return isMediaContentType(value);
|
|
75
68
|
}
|
|
76
69
|
function isBlobKey(key) {
|
|
77
70
|
if (key.length < 1 || key.length > MAX_BLOB_KEY_LENGTH)
|
|
@@ -93,16 +86,6 @@ export function textContentType(text) {
|
|
|
93
86
|
? "application/json"
|
|
94
87
|
: "text/plain; charset=utf-8";
|
|
95
88
|
}
|
|
96
|
-
/** The media type an inline file is stored under: the part's own (`mime_type`, `mediaType`), its
|
|
97
|
-
* `data:` URL's, or else bytes, or UTF-8 text for content that was not encoded. */
|
|
98
|
-
function fileContentType(part, file) {
|
|
99
|
-
for (const declared of [part.mime_type, part.mimeType, part.mediaType, part.media_type])
|
|
100
|
-
if (isBlobContentType(declared))
|
|
101
|
-
return declared;
|
|
102
|
-
if (isBlobContentType(file.urlMediaType))
|
|
103
|
-
return file.urlMediaType;
|
|
104
|
-
return file.decoded ? "application/octet-stream" : "text/plain; charset=utf-8";
|
|
105
|
-
}
|
|
106
89
|
export function blobPrefix(value) {
|
|
107
90
|
if (typeof value !== "string")
|
|
108
91
|
return Uint8Array.prototype.slice.call(value, 0, BLOB_PREFIX_BYTES);
|
|
@@ -149,8 +132,14 @@ export async function digestOf(value) {
|
|
|
149
132
|
return { sha256: hash.digest("hex"), size };
|
|
150
133
|
}
|
|
151
134
|
/** The `hue.blobs` element for one uploaded value. */
|
|
152
|
-
function blobEntry(key, sha256, size, contentType) {
|
|
153
|
-
return JSON.stringify({
|
|
135
|
+
export function blobEntry(key, sha256, size, contentType, name) {
|
|
136
|
+
return JSON.stringify({
|
|
137
|
+
key,
|
|
138
|
+
sha256,
|
|
139
|
+
size,
|
|
140
|
+
content_type: contentType,
|
|
141
|
+
...(name ? { name } : {}),
|
|
142
|
+
});
|
|
154
143
|
}
|
|
155
144
|
/** HTTP header names (tokens) and values without line breaks or NUL. */
|
|
156
145
|
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
@@ -189,6 +178,7 @@ export class BlobUploader {
|
|
|
189
178
|
running = 0;
|
|
190
179
|
waiting = [];
|
|
191
180
|
requests = new Set();
|
|
181
|
+
outcomes = new Map();
|
|
192
182
|
constructor(options) {
|
|
193
183
|
this.options = options;
|
|
194
184
|
}
|
|
@@ -206,6 +196,7 @@ export class BlobUploader {
|
|
|
206
196
|
for (const request of this.requests)
|
|
207
197
|
request.destroy();
|
|
208
198
|
this.requests.clear();
|
|
199
|
+
this.outcomes.clear();
|
|
209
200
|
for (const run of this.waiting.splice(0)) {
|
|
210
201
|
this.running++;
|
|
211
202
|
run();
|
|
@@ -244,9 +235,49 @@ export class BlobUploader {
|
|
|
244
235
|
}
|
|
245
236
|
/**
|
|
246
237
|
* Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
|
|
247
|
-
* completed.
|
|
238
|
+
* completed. Only a fresh upload takes a {@link limit} slot: another reference to bytes the
|
|
239
|
+
* trace is already storing shares that upload's outcome without one, so repeated files never
|
|
240
|
+
* hold the slots new files need. The caller must not hold a slot.
|
|
248
241
|
*/
|
|
249
242
|
async store(traceId, key, value, contentType, digest, upload) {
|
|
243
|
+
// Every occurrence is validated even when a previous occurrence already uploaded the bytes.
|
|
244
|
+
if (!isBlobKey(key) ||
|
|
245
|
+
!isBlobContentType(contentType) ||
|
|
246
|
+
!/^[0-9a-f]{32}$/.test(traceId) ||
|
|
247
|
+
/^0+$/.test(traceId) ||
|
|
248
|
+
!/^[0-9a-f]{64}$/.test(digest.sha256) ||
|
|
249
|
+
!Number.isSafeInteger(digest.size) ||
|
|
250
|
+
digest.size <= 0 ||
|
|
251
|
+
digest.size !== sizeOf(value))
|
|
252
|
+
return { ok: false, reason: "failed" };
|
|
253
|
+
if (digest.size > MAX_BLOB_BYTES)
|
|
254
|
+
return { ok: false, reason: "tooLarge" };
|
|
255
|
+
const identity = `${traceId}:${digest.sha256}`;
|
|
256
|
+
const known = this.outcomes.get(identity);
|
|
257
|
+
if (known)
|
|
258
|
+
return known.promise;
|
|
259
|
+
if (this.outcomes.size >= 4096) {
|
|
260
|
+
const oldest = [...this.outcomes].find(([, item]) => item.settled)?.[0];
|
|
261
|
+
if (oldest === undefined)
|
|
262
|
+
return { ok: false, reason: "budget" };
|
|
263
|
+
this.outcomes.delete(oldest);
|
|
264
|
+
}
|
|
265
|
+
const item = {
|
|
266
|
+
promise: Promise.resolve({ ok: false, reason: "failed" }),
|
|
267
|
+
settled: false,
|
|
268
|
+
};
|
|
269
|
+
this.outcomes.set(identity, item);
|
|
270
|
+
item.promise = this.limit(() => this.storeFresh(traceId, key, value, contentType, digest, upload))
|
|
271
|
+
.catch(() => ({ ok: false, reason: "failed" }))
|
|
272
|
+
.then((outcome) => {
|
|
273
|
+
item.settled = true;
|
|
274
|
+
if (!outcome.ok && this.outcomes.get(identity) === item)
|
|
275
|
+
this.outcomes.delete(identity);
|
|
276
|
+
return outcome;
|
|
277
|
+
});
|
|
278
|
+
return item.promise;
|
|
279
|
+
}
|
|
280
|
+
async storeFresh(traceId, key, value, contentType, digest, upload) {
|
|
250
281
|
const refused = this.refusal();
|
|
251
282
|
if (refused)
|
|
252
283
|
return { ok: false, reason: refused };
|
|
@@ -295,10 +326,13 @@ export class BlobUploader {
|
|
|
295
326
|
}
|
|
296
327
|
if ("fallback" in reservation)
|
|
297
328
|
return { ok: false, reason: reservation.fallback };
|
|
298
|
-
if (!reservation.exists)
|
|
329
|
+
if (!reservation.exists) {
|
|
299
330
|
// For Hue's accounting only: a value is read whether or not it was completed, so a failed
|
|
300
331
|
// completion is not retried.
|
|
301
|
-
await this.request("POST", new URL("/api/v1/otlp/blobs/complete", this.options.baseUrl), this.jsonHeaders(), JSON.stringify({ traceId, sha256: digest.sha256 }), Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
|
|
332
|
+
const completion = await this.request("POST", new URL("/api/v1/otlp/blobs/complete", this.options.baseUrl), this.jsonHeaders(), JSON.stringify({ traceId, sha256: digest.sha256 }), Math.min(upload.deadline, Date.now() + this.options.timeoutMillis), MAX_JSON_RESPONSE_BYTES);
|
|
333
|
+
if (!completion || completion.status >= 500 || completion.status === 429)
|
|
334
|
+
this.pause();
|
|
335
|
+
}
|
|
302
336
|
return { ok: true, contentType: reservation.contentType };
|
|
303
337
|
}
|
|
304
338
|
jsonHeaders() {
|
|
@@ -323,7 +357,7 @@ export class BlobUploader {
|
|
|
323
357
|
return { fallback: "failed" };
|
|
324
358
|
}
|
|
325
359
|
const { status } = reply;
|
|
326
|
-
const answer = status === 200 || status === 404 ? parseJson(reply.body) : undefined;
|
|
360
|
+
const answer = status === 200 || status === 404 || status === 409 ? parseJson(reply.body) : undefined;
|
|
327
361
|
// A receiver without the route answers its framework's not-found (Hue's own 404, an
|
|
328
362
|
// archived project, is JSON with an `error`), refuses the method, or answers something
|
|
329
363
|
// that is no reservation at all: it is not asked again for a while.
|
|
@@ -342,12 +376,15 @@ export class BlobUploader {
|
|
|
342
376
|
this.pause();
|
|
343
377
|
return reservation;
|
|
344
378
|
}
|
|
379
|
+
if (status === 409 && isObject(answer) && answer.status === "exists")
|
|
380
|
+
return this.reservation(answer, size);
|
|
345
381
|
if (status === 413)
|
|
346
382
|
return { fallback: "tooLarge" };
|
|
347
383
|
if (status === 429) {
|
|
348
384
|
const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
|
|
349
385
|
if (Date.now() + wait < upload.deadline && (await upload.wait(wait)))
|
|
350
386
|
continue;
|
|
387
|
+
this.pause();
|
|
351
388
|
return { fallback: "failed" };
|
|
352
389
|
}
|
|
353
390
|
if (status >= 500) {
|
|
@@ -357,13 +394,12 @@ export class BlobUploader {
|
|
|
357
394
|
this.pause();
|
|
358
395
|
return { fallback: "failed" };
|
|
359
396
|
}
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
// alike, so uploads pause; the export's own requests report a key problem.
|
|
363
|
-
if (status !== 409)
|
|
397
|
+
// Bad values and conflicts refuse only this value. Transient service failures pause uploads.
|
|
398
|
+
if (status !== 400 && status !== 409)
|
|
364
399
|
this.pause();
|
|
365
400
|
return { fallback: "failed" };
|
|
366
401
|
}
|
|
402
|
+
this.pause();
|
|
367
403
|
return { fallback: "failed" };
|
|
368
404
|
}
|
|
369
405
|
async delay(millis, upload) {
|
|
@@ -433,7 +469,7 @@ export class BlobUploader {
|
|
|
433
469
|
return "stored";
|
|
434
470
|
if (reply.status === 403)
|
|
435
471
|
return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
|
|
436
|
-
if (reply.status >= 500 || reply.status === 408)
|
|
472
|
+
if (reply.status >= 500 || reply.status === 408 || reply.status === 429)
|
|
437
473
|
return "transient";
|
|
438
474
|
return "failed";
|
|
439
475
|
}
|
|
@@ -536,9 +572,9 @@ export class BlobUploader {
|
|
|
536
572
|
}
|
|
537
573
|
}
|
|
538
574
|
/**
|
|
539
|
-
* A span's own attributes with each candidate placed:
|
|
540
|
-
*
|
|
541
|
-
*
|
|
575
|
+
* A span's own attributes with each candidate placed: binary media becomes a digest, while
|
|
576
|
+
* text media over 64 KiB retains a 16 KiB prefix after upload or a digest on failure.
|
|
577
|
+
* A value still over the inline limit is uploaded and replaced by its first
|
|
542
578
|
* 16 KiB (or cut to the limit), each upload listed under `hue.blobs`, each whole value cut or
|
|
543
579
|
* uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
|
|
544
580
|
*/
|
|
@@ -559,16 +595,19 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
559
595
|
fallback(uploader.unsupported() ? "unsupported" : "budget");
|
|
560
596
|
let inspected = 0;
|
|
561
597
|
const parts = async (index, key, text) => {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
!(text.includes('"blob"') || text.includes('"file"')))
|
|
565
|
-
return text;
|
|
566
|
-
const bytes = Buffer.byteLength(text, "utf8");
|
|
567
|
-
if (bytes <= INLINE_FILE_LIMIT || bytes > MAX_INLINE_FILE_TEXT)
|
|
598
|
+
// Both bounds count UTF-16 code units, as admission does.
|
|
599
|
+
if (!mayInlineFiles(key, text) || text.length > MAX_INLINE_FILE_TEXT)
|
|
568
600
|
return text;
|
|
569
|
-
if (inspected
|
|
570
|
-
|
|
571
|
-
|
|
601
|
+
if (collector.inspected) {
|
|
602
|
+
// A message admission skipped stays as recorded.
|
|
603
|
+
if (!collector.inspected.has(key))
|
|
604
|
+
return text;
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
|
|
608
|
+
return text;
|
|
609
|
+
inspected += text.length;
|
|
610
|
+
}
|
|
572
611
|
let parsed;
|
|
573
612
|
let found;
|
|
574
613
|
try {
|
|
@@ -582,33 +621,39 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
582
621
|
return text;
|
|
583
622
|
let changed = false;
|
|
584
623
|
await Promise.all(found.map((file, position) => {
|
|
585
|
-
const
|
|
624
|
+
const decoded = mediaBytes(file);
|
|
625
|
+
if (!decoded.bytes.byteLength)
|
|
626
|
+
return Promise.resolve();
|
|
627
|
+
const contentType = mediaContentType(file, decoded);
|
|
628
|
+
const textual = isTextMediaType(contentType);
|
|
629
|
+
if (textual && decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
|
630
|
+
return Promise.resolve();
|
|
631
|
+
const partKey = `${key}#${textual ? file.pointer : file.partPointer}`;
|
|
586
632
|
const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
|
|
587
|
-
return
|
|
588
|
-
const decoded = inlineFileBytes(file.content);
|
|
589
|
-
if (decoded.bytes.byteLength <= INLINE_FILE_LIMIT) {
|
|
590
|
-
if (slot)
|
|
591
|
-
slots++;
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
633
|
+
return (async () => {
|
|
594
634
|
changed = true;
|
|
595
|
-
const digest =
|
|
635
|
+
const digest = options.memo
|
|
636
|
+
? options.memo.digest(file, decoded, traceId)
|
|
637
|
+
: await digestOf(decoded.bytes);
|
|
596
638
|
const outcome = slot
|
|
597
|
-
? await uploader.store(traceId, partKey, decoded.bytes,
|
|
639
|
+
? await uploader.store(traceId, partKey, decoded.bytes, contentType, digest, upload)
|
|
598
640
|
: { ok: false, reason: "budget" };
|
|
599
641
|
if (outcome.ok) {
|
|
600
|
-
|
|
642
|
+
if (textual)
|
|
643
|
+
file.owner[file.key] = blobPrefix(file.content);
|
|
644
|
+
else
|
|
645
|
+
digestPart(file, digest);
|
|
601
646
|
entries.push({
|
|
602
647
|
order: [index, position],
|
|
603
|
-
entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType),
|
|
648
|
+
entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType, file.name),
|
|
604
649
|
});
|
|
605
650
|
tally.uploaded++;
|
|
606
651
|
}
|
|
607
652
|
else {
|
|
608
|
-
digestPart(file
|
|
653
|
+
digestPart(file, digest);
|
|
609
654
|
fallback(outcome.reason);
|
|
610
655
|
}
|
|
611
|
-
});
|
|
656
|
+
})();
|
|
612
657
|
}));
|
|
613
658
|
return changed ? JSON.stringify(parsed) : text;
|
|
614
659
|
};
|
|
@@ -627,17 +672,15 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
627
672
|
return;
|
|
628
673
|
}
|
|
629
674
|
slots--;
|
|
630
|
-
const stored = await
|
|
675
|
+
const stored = await (async () => {
|
|
631
676
|
if (size > MAX_BLOB_BYTES)
|
|
632
677
|
return { ok: false, reason: "tooLarge" };
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
return { ok: false, reason: refused };
|
|
636
|
-
const digest = await digestOf(placed);
|
|
678
|
+
// Hashing a large value takes a slot as its upload does; the upload takes its own.
|
|
679
|
+
const digest = await uploader.limit(() => digestOf(placed));
|
|
637
680
|
const contentType = typeof placed === "string" ? textContentType(placed) : "application/octet-stream";
|
|
638
681
|
const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
|
|
639
682
|
return outcome.ok ? { ...outcome, digest } : outcome;
|
|
640
|
-
});
|
|
683
|
+
})();
|
|
641
684
|
if (stored.ok) {
|
|
642
685
|
result[key] = typeof placed === "string" ? blobPrefix(placed) : blobPrefix(placed);
|
|
643
686
|
entries.push({
|
|
@@ -652,6 +695,26 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
652
695
|
}
|
|
653
696
|
};
|
|
654
697
|
await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
|
|
698
|
+
await Promise.all((collector.attachments ?? []).map((file, position) => {
|
|
699
|
+
if (!file.bytes.byteLength)
|
|
700
|
+
return Promise.resolve();
|
|
701
|
+
const slot = slots > 0 ? (slots--, true) : false;
|
|
702
|
+
return (async () => {
|
|
703
|
+
const key = `event:hue.file:${file.digest.sha256}`;
|
|
704
|
+
const outcome = slot
|
|
705
|
+
? await uploader.store(traceId, key, file.bytes, file.contentType, file.digest, upload)
|
|
706
|
+
: { ok: false, reason: "budget" };
|
|
707
|
+
if (outcome.ok) {
|
|
708
|
+
entries.push({
|
|
709
|
+
order: [collector.candidates.length, position],
|
|
710
|
+
entry: blobEntry(key, file.digest.sha256, file.digest.size, outcome.contentType, file.name),
|
|
711
|
+
});
|
|
712
|
+
tally.uploaded++;
|
|
713
|
+
}
|
|
714
|
+
else
|
|
715
|
+
fallback(outcome.reason);
|
|
716
|
+
})();
|
|
717
|
+
}));
|
|
655
718
|
if (listed.length)
|
|
656
719
|
result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed.sort((a, b) => a.order - b.order).map(({ key }) => key));
|
|
657
720
|
if (entries.length) {
|
|
@@ -666,9 +729,11 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
666
729
|
* over the inline limit cut to it (bytes replaced by the receiver's marker) and listed under
|
|
667
730
|
* `hue.truncated`.
|
|
668
731
|
*/
|
|
669
|
-
export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId) {
|
|
732
|
+
export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId, memo) {
|
|
670
733
|
const result = { ...attributes };
|
|
671
734
|
const listed = [];
|
|
735
|
+
for (const _file of collector.attachments ?? [])
|
|
736
|
+
countFallback(tally, "failed", traceId);
|
|
672
737
|
for (let index = 0; index < collector.cut.length; index++)
|
|
673
738
|
countFallback(tally, "failed", traceId);
|
|
674
739
|
for (const { key, value } of collector.candidates) {
|
|
@@ -676,7 +741,8 @@ export function fallbackAttributes(attributes, collector, valueBytes, tally, tra
|
|
|
676
741
|
// Each large inline file is reported as a value not uploaded, as the upload step reports it.
|
|
677
742
|
const count = { files: 0 };
|
|
678
743
|
try {
|
|
679
|
-
|
|
744
|
+
if (!collector.inspected || collector.inspected.has(key))
|
|
745
|
+
placed = hashInlineFiles(key, value, count, memo, traceId);
|
|
680
746
|
}
|
|
681
747
|
catch {
|
|
682
748
|
// Left as it is: cut below when over the limit.
|
|
@@ -11,8 +11,9 @@ import type { JsonValue } from "../types.js";
|
|
|
11
11
|
* <case dir>/output/ where the command writes what it produced
|
|
12
12
|
*
|
|
13
13
|
* Everything under `output/` becomes the execution's generated files. Optional helpers there:
|
|
14
|
-
* `manifest.json` (`{ "primary": "<filename>", "output": <json> }`),
|
|
15
|
-
* output) and `summary.txt` / `summary.md` (recorded as `{ "summary": "…" }`).
|
|
14
|
+
* `manifest.json` (`{ "primary": "<filename>", "output": <json>, "contentTypes": { "<path>": "<MIME>" } }`),
|
|
15
|
+
* `result.json` (the JSON output) and `summary.txt` / `summary.md` (recorded as `{ "summary": "…" }`).
|
|
16
|
+
* MP4 and WebM outputs require a contentTypes declaration because either container can hold audio or video.
|
|
16
17
|
*/
|
|
17
18
|
export declare const directCaseEnvironment: readonly ["HUE_CASE_DIR", "HUE_CASE_INPUTS", "HUE_CASE_OUTPUT_DIR", "HUE_CASE_ID", "HUE_CASE_KEY", "HUE_EXECUTION_ID"];
|
|
18
19
|
/** Generated-file extensions Hue accepts and the content type recorded for each. */
|
package/dist/cli/eval-direct.js
CHANGED
|
@@ -14,8 +14,9 @@ import { withFiles } from "../evals/types.js";
|
|
|
14
14
|
* <case dir>/output/ where the command writes what it produced
|
|
15
15
|
*
|
|
16
16
|
* Everything under `output/` becomes the execution's generated files. Optional helpers there:
|
|
17
|
-
* `manifest.json` (`{ "primary": "<filename>", "output": <json> }`),
|
|
18
|
-
* output) and `summary.txt` / `summary.md` (recorded as `{ "summary": "…" }`).
|
|
17
|
+
* `manifest.json` (`{ "primary": "<filename>", "output": <json>, "contentTypes": { "<path>": "<MIME>" } }`),
|
|
18
|
+
* `result.json` (the JSON output) and `summary.txt` / `summary.md` (recorded as `{ "summary": "…" }`).
|
|
19
|
+
* MP4 and WebM outputs require a contentTypes declaration because either container can hold audio or video.
|
|
19
20
|
*/
|
|
20
21
|
export const directCaseEnvironment = [
|
|
21
22
|
"HUE_CASE_DIR",
|
|
@@ -38,8 +39,25 @@ export const outputExtensions = {
|
|
|
38
39
|
".jpg": "image/jpeg",
|
|
39
40
|
".jpeg": "image/jpeg",
|
|
40
41
|
".webp": "image/webp",
|
|
42
|
+
".gif": "image/gif",
|
|
43
|
+
".avif": "image/avif",
|
|
44
|
+
".mp3": "audio/mpeg",
|
|
45
|
+
".mpeg": "audio/mpeg",
|
|
46
|
+
".wav": "audio/wav",
|
|
47
|
+
".ogg": "audio/ogg",
|
|
48
|
+
".m4a": "audio/mp4",
|
|
49
|
+
".flac": "audio/flac",
|
|
50
|
+
".opus": "audio/opus",
|
|
51
|
+
".aac": "audio/aac",
|
|
41
52
|
};
|
|
42
|
-
|
|
53
|
+
const ambiguousOutputExtensions = {
|
|
54
|
+
".mp4": ["audio/mp4", "video/mp4"],
|
|
55
|
+
".webm": ["audio/webm", "video/webm"],
|
|
56
|
+
};
|
|
57
|
+
for (const type of [
|
|
58
|
+
...Object.values(outputExtensions),
|
|
59
|
+
...Object.values(ambiguousOutputExtensions).flat(),
|
|
60
|
+
])
|
|
43
61
|
if (!outputContentTypes.includes(type))
|
|
44
62
|
throw new Error(`outputExtensions maps to a content type Hue does not accept: ${type}`);
|
|
45
63
|
const HELPER_FILES = new Set(["manifest.json", "result.json", "output.json"]);
|
|
@@ -251,18 +269,43 @@ export async function collectDirectOutputs(outputDirectory, fallbackOutput, seam
|
|
|
251
269
|
const entries = listed.filter((entry) => !HELPER_FILES.has(entry.name) && !SUMMARY_FILES.includes(entry.name));
|
|
252
270
|
// Directory order differs between filesystems; keep uploads stable.
|
|
253
271
|
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
254
|
-
const
|
|
272
|
+
const declaredTypes = manifest && typeof manifest === "object" && !Array.isArray(manifest)
|
|
273
|
+
? manifest.contentTypes
|
|
274
|
+
: undefined;
|
|
275
|
+
if (declaredTypes !== undefined &&
|
|
276
|
+
(!declaredTypes || typeof declaredTypes !== "object" || Array.isArray(declaredTypes)))
|
|
277
|
+
throw new Error("manifest.json contentTypes must map output paths to MIME type strings");
|
|
278
|
+
for (const [name, type] of Object.entries(declaredTypes ?? {})) {
|
|
279
|
+
if (typeof type !== "string" || !outputContentTypes.includes(type))
|
|
280
|
+
throw new Error(`manifest.json declares an unsupported content type for ${name}`);
|
|
281
|
+
if (!entries.some((entry) => entry.name === name))
|
|
282
|
+
throw new Error(`manifest.json declares a content type for a file that was not written: ${name}`);
|
|
283
|
+
}
|
|
284
|
+
const unsupported = entries.filter((entry) => {
|
|
285
|
+
const extension = extname(entry.name).toLowerCase();
|
|
286
|
+
return !outputExtensions[extension] && !ambiguousOutputExtensions[extension];
|
|
287
|
+
});
|
|
255
288
|
if (unsupported.length)
|
|
256
|
-
throw new Error(`The agent wrote files Hue does not accept as generated documents: ${unsupported.map((entry) => entry.name).join(", ")} (accepted: ${Object.keys(outputExtensions).join(" ")})`);
|
|
289
|
+
throw new Error(`The agent wrote files Hue does not accept as generated documents: ${unsupported.map((entry) => entry.name).join(", ")} (accepted: ${[...Object.keys(outputExtensions), ...Object.keys(ambiguousOutputExtensions)].join(" ")})`);
|
|
257
290
|
const files = [];
|
|
258
291
|
for (const entry of entries) {
|
|
292
|
+
const extension = extname(entry.name).toLowerCase();
|
|
293
|
+
const declared = declaredTypes && Object.hasOwn(declaredTypes, entry.name)
|
|
294
|
+
? declaredTypes[entry.name]
|
|
295
|
+
: undefined;
|
|
296
|
+
const contentType = declared ?? outputExtensions[extension];
|
|
297
|
+
const acceptedTypes = ambiguousOutputExtensions[extension] ?? [outputExtensions[extension]];
|
|
298
|
+
if (contentType === undefined)
|
|
299
|
+
throw new Error(`Declare ${entry.name} as ${acceptedTypes.join(" or ")} in manifest.json contentTypes`);
|
|
300
|
+
if (typeof contentType !== "string" || !acceptedTypes.includes(contentType))
|
|
301
|
+
throw new Error(`manifest.json content type for ${entry.name} must be ${acceptedTypes.join(" or ")}`);
|
|
259
302
|
const bytes = (await readAgentFile(entry.path, `Generated file ${entry.name}`, outputFileLimits.bytes, entry.identity));
|
|
260
303
|
if (!bytes.byteLength)
|
|
261
304
|
throw new Error(`The agent wrote an empty file: ${entry.name}`);
|
|
262
305
|
files.push({
|
|
263
306
|
bytes,
|
|
264
307
|
filename: artifactFilename(entry.name),
|
|
265
|
-
contentType
|
|
308
|
+
contentType,
|
|
266
309
|
});
|
|
267
310
|
}
|
|
268
311
|
if (typeof declaredPrimary === "string") {
|
package/dist/client.d.ts
CHANGED
|
@@ -149,13 +149,15 @@ export declare class HueClient {
|
|
|
149
149
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
150
150
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
151
151
|
* and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
|
|
152
|
-
*
|
|
152
|
+
* uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
|
|
153
153
|
* record, or one without an active span, is omitted and counted, never thrown; an invalid name
|
|
154
154
|
* alone is omitted and counted while the rest is recorded.
|
|
155
155
|
*/
|
|
156
156
|
recordFile(file: FileRecord, explicitContext?: Context): void;
|
|
157
157
|
/** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
|
|
158
158
|
private truncatedKeys;
|
|
159
|
+
/** Distinguishes requests whose helper events otherwise have identical metadata. */
|
|
160
|
+
private fileEventTimes;
|
|
159
161
|
/** A content value's bounds: Hue's value cap as the receiver last advertised it. */
|
|
160
162
|
private contentLimits;
|
|
161
163
|
/**
|