@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.
- package/CLI.md +18 -11
- package/EVALUATIONS.md +53 -2
- package/README.md +295 -45
- package/dist/attachment.d.ts +82 -0
- package/dist/attachment.js +225 -0
- package/dist/blobs.d.ts +73 -17
- package/dist/blobs.js +247 -96
- package/dist/cli/eval-direct.d.ts +3 -2
- package/dist/cli/eval-direct.js +49 -6
- package/dist/client.d.ts +21 -1
- package/dist/client.js +217 -32
- package/dist/evals/client.d.ts +14 -1
- package/dist/evals/client.js +73 -9
- package/dist/evals/files.d.ts +11 -1
- package/dist/evals/files.js +172 -20
- package/dist/evals/media.d.ts +217 -0
- package/dist/evals/media.js +907 -0
- package/dist/evals/runner.js +130 -106
- package/dist/evals/types.d.ts +48 -0
- package/dist/evals.d.ts +2 -0
- package/dist/evals.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inline-files.d.ts +160 -28
- package/dist/inline-files.js +809 -86
- package/dist/json-edits.d.ts +24 -0
- package/dist/json-edits.js +187 -0
- package/dist/privacy.d.ts +14 -1
- package/dist/privacy.js +86 -25
- package/dist/safety.d.ts +7 -2
- package/dist/safety.js +22 -4
- package/dist/snapshot.d.ts +7 -4
- package/dist/snapshot.js +177 -38
- package/dist/transport.d.ts +18 -0
- package/dist/transport.js +544 -38
- package/dist/types.d.ts +28 -7
- 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,
|
|
6
|
-
import { TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
|
|
5
|
+
import { digestPart, hashInlineFiles, hashNestedInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, isMessageKey, keepsPrefix, literalKeys, mediaBytes, mediaContentType, mediaIn, isTextMediaType, mayInlineFiles, isMediaContentType, MAX_INLINE_FILE_TEXT, placeable, placedMediaText, prefixPart, readMediaText, reencodeMedia, } from "./inline-files.js";
|
|
6
|
+
import { capLogBody, 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,28 @@ 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
|
};
|
|
56
|
+
/** The `hue.blobs` key of a staged file: its part's for an attachment placed in a value, else
|
|
57
|
+
* its `hue.file` event's. */
|
|
58
|
+
export function mediaKey(file) {
|
|
59
|
+
return file.key ?? `event:hue.file:${file.digest.sha256}`;
|
|
60
|
+
}
|
|
58
61
|
export function newTally() {
|
|
59
62
|
return { uploaded: 0, fallbacks: new Map() };
|
|
60
63
|
}
|
|
61
|
-
function countFallback(tally, reason, traceId) {
|
|
64
|
+
export function countFallback(tally, reason, traceId) {
|
|
62
65
|
const entry = tally.fallbacks.get(reason) ?? { count: 0, traceIds: new Set() };
|
|
63
66
|
entry.count++;
|
|
64
67
|
entry.traceIds.add(traceId);
|
|
65
68
|
tally.fallbacks.set(reason, entry);
|
|
66
69
|
}
|
|
67
|
-
/** A media type
|
|
68
|
-
* printable ASCII characters. */
|
|
69
|
-
const CONTENT_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+(?: *;[ -~]*)?$/;
|
|
70
|
+
/** A media type accepted by the upload contract. */
|
|
70
71
|
export function isBlobContentType(value) {
|
|
71
|
-
return (
|
|
72
|
-
value.length >= 3 &&
|
|
73
|
-
value.length <= 255 &&
|
|
74
|
-
CONTENT_TYPE.test(value));
|
|
72
|
+
return isMediaContentType(value);
|
|
75
73
|
}
|
|
76
74
|
function isBlobKey(key) {
|
|
77
75
|
if (key.length < 1 || key.length > MAX_BLOB_KEY_LENGTH)
|
|
@@ -93,16 +91,6 @@ export function textContentType(text) {
|
|
|
93
91
|
? "application/json"
|
|
94
92
|
: "text/plain; charset=utf-8";
|
|
95
93
|
}
|
|
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
94
|
export function blobPrefix(value) {
|
|
107
95
|
if (typeof value !== "string")
|
|
108
96
|
return Uint8Array.prototype.slice.call(value, 0, BLOB_PREFIX_BYTES);
|
|
@@ -149,8 +137,14 @@ export async function digestOf(value) {
|
|
|
149
137
|
return { sha256: hash.digest("hex"), size };
|
|
150
138
|
}
|
|
151
139
|
/** The `hue.blobs` element for one uploaded value. */
|
|
152
|
-
function blobEntry(key, sha256, size, contentType) {
|
|
153
|
-
return JSON.stringify({
|
|
140
|
+
export function blobEntry(key, sha256, size, contentType, name) {
|
|
141
|
+
return JSON.stringify({
|
|
142
|
+
key,
|
|
143
|
+
sha256,
|
|
144
|
+
size,
|
|
145
|
+
content_type: contentType,
|
|
146
|
+
...(name ? { name } : {}),
|
|
147
|
+
});
|
|
154
148
|
}
|
|
155
149
|
/** HTTP header names (tokens) and values without line breaks or NUL. */
|
|
156
150
|
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
@@ -189,6 +183,7 @@ export class BlobUploader {
|
|
|
189
183
|
running = 0;
|
|
190
184
|
waiting = [];
|
|
191
185
|
requests = new Set();
|
|
186
|
+
outcomes = new Map();
|
|
192
187
|
constructor(options) {
|
|
193
188
|
this.options = options;
|
|
194
189
|
}
|
|
@@ -206,6 +201,7 @@ export class BlobUploader {
|
|
|
206
201
|
for (const request of this.requests)
|
|
207
202
|
request.destroy();
|
|
208
203
|
this.requests.clear();
|
|
204
|
+
this.outcomes.clear();
|
|
209
205
|
for (const run of this.waiting.splice(0)) {
|
|
210
206
|
this.running++;
|
|
211
207
|
run();
|
|
@@ -244,9 +240,49 @@ export class BlobUploader {
|
|
|
244
240
|
}
|
|
245
241
|
/**
|
|
246
242
|
* Stores `value` under `key` of the trace: reserved, uploaded unless Hue already holds it, and
|
|
247
|
-
* completed.
|
|
243
|
+
* completed. Only a fresh upload takes a {@link limit} slot: another reference to bytes the
|
|
244
|
+
* trace is already storing shares that upload's outcome without one, so repeated files never
|
|
245
|
+
* hold the slots new files need. The caller must not hold a slot.
|
|
248
246
|
*/
|
|
249
247
|
async store(traceId, key, value, contentType, digest, upload) {
|
|
248
|
+
// Every occurrence is validated even when a previous occurrence already uploaded the bytes.
|
|
249
|
+
if (!isBlobKey(key) ||
|
|
250
|
+
!isBlobContentType(contentType) ||
|
|
251
|
+
!/^[0-9a-f]{32}$/.test(traceId) ||
|
|
252
|
+
/^0+$/.test(traceId) ||
|
|
253
|
+
!/^[0-9a-f]{64}$/.test(digest.sha256) ||
|
|
254
|
+
!Number.isSafeInteger(digest.size) ||
|
|
255
|
+
digest.size <= 0 ||
|
|
256
|
+
digest.size !== sizeOf(value))
|
|
257
|
+
return { ok: false, reason: "failed" };
|
|
258
|
+
if (digest.size > MAX_BLOB_BYTES)
|
|
259
|
+
return { ok: false, reason: "tooLarge" };
|
|
260
|
+
const identity = `${traceId}:${digest.sha256}`;
|
|
261
|
+
const known = this.outcomes.get(identity);
|
|
262
|
+
if (known)
|
|
263
|
+
return known.promise;
|
|
264
|
+
if (this.outcomes.size >= 4096) {
|
|
265
|
+
const oldest = [...this.outcomes].find(([, item]) => item.settled)?.[0];
|
|
266
|
+
if (oldest === undefined)
|
|
267
|
+
return { ok: false, reason: "budget" };
|
|
268
|
+
this.outcomes.delete(oldest);
|
|
269
|
+
}
|
|
270
|
+
const item = {
|
|
271
|
+
promise: Promise.resolve({ ok: false, reason: "failed" }),
|
|
272
|
+
settled: false,
|
|
273
|
+
};
|
|
274
|
+
this.outcomes.set(identity, item);
|
|
275
|
+
item.promise = this.limit(() => this.storeFresh(traceId, key, value, contentType, digest, upload))
|
|
276
|
+
.catch(() => ({ ok: false, reason: "failed" }))
|
|
277
|
+
.then((outcome) => {
|
|
278
|
+
item.settled = true;
|
|
279
|
+
if (!outcome.ok && this.outcomes.get(identity) === item)
|
|
280
|
+
this.outcomes.delete(identity);
|
|
281
|
+
return outcome;
|
|
282
|
+
});
|
|
283
|
+
return item.promise;
|
|
284
|
+
}
|
|
285
|
+
async storeFresh(traceId, key, value, contentType, digest, upload) {
|
|
250
286
|
const refused = this.refusal();
|
|
251
287
|
if (refused)
|
|
252
288
|
return { ok: false, reason: refused };
|
|
@@ -295,10 +331,13 @@ export class BlobUploader {
|
|
|
295
331
|
}
|
|
296
332
|
if ("fallback" in reservation)
|
|
297
333
|
return { ok: false, reason: reservation.fallback };
|
|
298
|
-
if (!reservation.exists)
|
|
334
|
+
if (!reservation.exists) {
|
|
299
335
|
// For Hue's accounting only: a value is read whether or not it was completed, so a failed
|
|
300
336
|
// 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);
|
|
337
|
+
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);
|
|
338
|
+
if (!completion || completion.status >= 500 || completion.status === 429)
|
|
339
|
+
this.pause();
|
|
340
|
+
}
|
|
302
341
|
return { ok: true, contentType: reservation.contentType };
|
|
303
342
|
}
|
|
304
343
|
jsonHeaders() {
|
|
@@ -323,7 +362,7 @@ export class BlobUploader {
|
|
|
323
362
|
return { fallback: "failed" };
|
|
324
363
|
}
|
|
325
364
|
const { status } = reply;
|
|
326
|
-
const answer = status === 200 || status === 404 ? parseJson(reply.body) : undefined;
|
|
365
|
+
const answer = status === 200 || status === 404 || status === 409 ? parseJson(reply.body) : undefined;
|
|
327
366
|
// A receiver without the route answers its framework's not-found (Hue's own 404, an
|
|
328
367
|
// archived project, is JSON with an `error`), refuses the method, or answers something
|
|
329
368
|
// that is no reservation at all: it is not asked again for a while.
|
|
@@ -342,12 +381,15 @@ export class BlobUploader {
|
|
|
342
381
|
this.pause();
|
|
343
382
|
return reservation;
|
|
344
383
|
}
|
|
384
|
+
if (status === 409 && isObject(answer) && answer.status === "exists")
|
|
385
|
+
return this.reservation(answer, size);
|
|
345
386
|
if (status === 413)
|
|
346
387
|
return { fallback: "tooLarge" };
|
|
347
388
|
if (status === 429) {
|
|
348
389
|
const wait = retryAfterMillis(reply.headers) ?? RETRY_DELAY_MILLIS;
|
|
349
390
|
if (Date.now() + wait < upload.deadline && (await upload.wait(wait)))
|
|
350
391
|
continue;
|
|
392
|
+
this.pause();
|
|
351
393
|
return { fallback: "failed" };
|
|
352
394
|
}
|
|
353
395
|
if (status >= 500) {
|
|
@@ -357,13 +399,12 @@ export class BlobUploader {
|
|
|
357
399
|
this.pause();
|
|
358
400
|
return { fallback: "failed" };
|
|
359
401
|
}
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
// alike, so uploads pause; the export's own requests report a key problem.
|
|
363
|
-
if (status !== 409)
|
|
402
|
+
// Bad values and conflicts refuse only this value. Transient service failures pause uploads.
|
|
403
|
+
if (status !== 400 && status !== 409)
|
|
364
404
|
this.pause();
|
|
365
405
|
return { fallback: "failed" };
|
|
366
406
|
}
|
|
407
|
+
this.pause();
|
|
367
408
|
return { fallback: "failed" };
|
|
368
409
|
}
|
|
369
410
|
async delay(millis, upload) {
|
|
@@ -433,7 +474,7 @@ export class BlobUploader {
|
|
|
433
474
|
return "stored";
|
|
434
475
|
if (reply.status === 403)
|
|
435
476
|
return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
|
|
436
|
-
if (reply.status >= 500 || reply.status === 408)
|
|
477
|
+
if (reply.status >= 500 || reply.status === 408 || reply.status === 429)
|
|
437
478
|
return "transient";
|
|
438
479
|
return "failed";
|
|
439
480
|
}
|
|
@@ -536,13 +577,25 @@ export class BlobUploader {
|
|
|
536
577
|
}
|
|
537
578
|
}
|
|
538
579
|
/**
|
|
539
|
-
* A span's own attributes with each candidate placed:
|
|
540
|
-
*
|
|
541
|
-
*
|
|
580
|
+
* A span's own attributes with each candidate placed: binary media becomes a digest, while
|
|
581
|
+
* text media over 64 KiB retains a 16 KiB prefix after upload or a digest on failure.
|
|
582
|
+
* A value still over the inline limit is uploaded and replaced by its first
|
|
542
583
|
* 16 KiB (or cut to the limit), each upload listed under `hue.blobs`, each whole value cut or
|
|
543
584
|
* uploaded listed under `hue.truncated`. Every fallback is counted in the tally.
|
|
544
585
|
*/
|
|
545
586
|
export async function offloadAttributes(attributes, collector, options) {
|
|
587
|
+
return (await offloadRecord(attributes, collector, options)).attributes;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* A record's own attributes, and a log record's body, with each candidate placed (see
|
|
591
|
+
* {@link offloadAttributes}). A file is uploaded and placed in its digest form (a text file in a
|
|
592
|
+
* provider part keeps a 16 KiB prefix), its `hue.blobs` entry keyed `<attribute>#<pointer>` or, in
|
|
593
|
+
* a log record's body, `body#<pointer>`. A file that is not uploaded keeps its bytes inline, except
|
|
594
|
+
* in a span's own recorded messages, where it keeps its digest as before. A log record's values
|
|
595
|
+
* over the inline limit are cut, never uploaded whole, and a body still over it is replaced by the
|
|
596
|
+
* receiver's marker, as before.
|
|
597
|
+
*/
|
|
598
|
+
export async function offloadRecord(attributes, collector, options) {
|
|
546
599
|
const { uploader, traceId, valueBytes, upload, tally } = options;
|
|
547
600
|
const result = { ...attributes };
|
|
548
601
|
const existing = Array.isArray(attributes[BLOBS_KEY])
|
|
@@ -558,59 +611,103 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
558
611
|
for (let index = 0; index < collector.cut.length; index++)
|
|
559
612
|
fallback(uploader.unsupported() ? "unsupported" : "budget");
|
|
560
613
|
let inspected = 0;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
if (bytes <= INLINE_FILE_LIMIT || bytes > MAX_INLINE_FILE_TEXT)
|
|
568
|
-
return text;
|
|
569
|
-
if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
|
|
570
|
-
return text;
|
|
571
|
-
inspected += text.length;
|
|
572
|
-
let parsed;
|
|
573
|
-
let found;
|
|
574
|
-
try {
|
|
575
|
-
parsed = JSON.parse(text);
|
|
576
|
-
found = largeInlineFileParts(parsed);
|
|
577
|
-
}
|
|
578
|
-
catch {
|
|
579
|
-
return text;
|
|
580
|
-
}
|
|
581
|
-
if (!found.length)
|
|
582
|
-
return text;
|
|
614
|
+
/** Uploads each file of a value and places it; whether any was placed. A file that is not
|
|
615
|
+
* uploaded keeps its bytes inline, except in a recorded message: a span's own message keeps its
|
|
616
|
+
* digest, and a log record's keeps the digest of a file over the inline limit, as nested
|
|
617
|
+
* messages kept them before log records uploaded. */
|
|
618
|
+
const placeFiles = async (index, key, files) => {
|
|
619
|
+
const message = isMessageKey(key);
|
|
583
620
|
let changed = false;
|
|
584
|
-
await Promise.all(
|
|
585
|
-
const
|
|
621
|
+
await Promise.all(files.map((file, position) => {
|
|
622
|
+
const decoded = mediaBytes(file);
|
|
623
|
+
if (!placeable(file, decoded))
|
|
624
|
+
return Promise.resolve();
|
|
625
|
+
const contentType = mediaContentType(file, decoded);
|
|
626
|
+
const textual = isTextMediaType(contentType);
|
|
627
|
+
if (textual && decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
|
628
|
+
return Promise.resolve();
|
|
629
|
+
const prefix = keepsPrefix(file, textual);
|
|
630
|
+
const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
|
|
586
631
|
const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
|
|
587
|
-
return
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
slots++;
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
changed = true;
|
|
595
|
-
const digest = await digestOf(decoded.bytes);
|
|
632
|
+
return (async () => {
|
|
633
|
+
const digest = options.memo
|
|
634
|
+
? options.memo.digest(file, decoded, traceId)
|
|
635
|
+
: await digestOf(decoded.bytes);
|
|
596
636
|
const outcome = slot
|
|
597
|
-
? await uploader.store(traceId, partKey, decoded.bytes,
|
|
637
|
+
? await uploader.store(traceId, partKey, decoded.bytes, contentType, digest, upload)
|
|
598
638
|
: { ok: false, reason: "budget" };
|
|
599
639
|
if (outcome.ok) {
|
|
600
|
-
|
|
640
|
+
if (prefix)
|
|
641
|
+
prefixPart(file, blobPrefix(file.content));
|
|
642
|
+
else
|
|
643
|
+
digestPart(file, digest);
|
|
644
|
+
changed = true;
|
|
601
645
|
entries.push({
|
|
602
646
|
order: [index, position],
|
|
603
|
-
entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType),
|
|
647
|
+
entry: blobEntry(partKey, digest.sha256, digest.size, outcome.contentType, file.name),
|
|
604
648
|
});
|
|
605
649
|
tally.uploaded++;
|
|
606
650
|
}
|
|
607
651
|
else {
|
|
608
|
-
|
|
652
|
+
if (message && (!collector.log || decoded.bytes.byteLength > INLINE_FILE_LIMIT)) {
|
|
653
|
+
digestPart(file, digest);
|
|
654
|
+
changed = true;
|
|
655
|
+
}
|
|
609
656
|
fallback(outcome.reason);
|
|
610
657
|
}
|
|
611
|
-
});
|
|
658
|
+
})();
|
|
612
659
|
}));
|
|
613
|
-
return changed
|
|
660
|
+
return changed;
|
|
661
|
+
};
|
|
662
|
+
const literal = literalKeys(attributes);
|
|
663
|
+
const parts = async (index, key, text) => {
|
|
664
|
+
// Both bounds count UTF-16 code units, as admission does. Literal text holds no file.
|
|
665
|
+
if (!mayInlineFiles(key, text) || text.length > MAX_INLINE_FILE_TEXT || literal.has(key))
|
|
666
|
+
return text;
|
|
667
|
+
if (collector.inspected) {
|
|
668
|
+
// A value admission skipped stays as recorded.
|
|
669
|
+
if (!collector.inspected.has(key))
|
|
670
|
+
return text;
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
|
|
674
|
+
return text;
|
|
675
|
+
inspected += text.length;
|
|
676
|
+
}
|
|
677
|
+
let media;
|
|
678
|
+
try {
|
|
679
|
+
media = readMediaText(key, text);
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return text;
|
|
683
|
+
}
|
|
684
|
+
if (!media?.files.length)
|
|
685
|
+
return text;
|
|
686
|
+
return (await placeFiles(index, key, media.files)) ? placedMediaText(media) : text;
|
|
687
|
+
};
|
|
688
|
+
/** A log record's body with its files placed. Each file's content counts toward the record's
|
|
689
|
+
* inspection bounds, as an attribute's text does. */
|
|
690
|
+
const placeBody = async (body) => {
|
|
691
|
+
let media;
|
|
692
|
+
try {
|
|
693
|
+
media = mediaIn(body);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
return body;
|
|
697
|
+
}
|
|
698
|
+
const files = media.files.filter((file) => {
|
|
699
|
+
// A string the queue cut holds only the start of its file: it stays as cut.
|
|
700
|
+
if (collector.bodyCut?.has(file.content))
|
|
701
|
+
return false;
|
|
702
|
+
const units = file.content.length;
|
|
703
|
+
if (units > MAX_INLINE_FILE_TEXT || inspected + units > INLINE_FILE_TEXT_PER_RECORD)
|
|
704
|
+
return false;
|
|
705
|
+
inspected += units;
|
|
706
|
+
return true;
|
|
707
|
+
});
|
|
708
|
+
if (await placeFiles(-1, "body", files))
|
|
709
|
+
reencodeMedia(media);
|
|
710
|
+
return media.box.value;
|
|
614
711
|
};
|
|
615
712
|
const place = async (index, { key, value }) => {
|
|
616
713
|
const placed = typeof value === "string" ? await parts(index, key, value) : value;
|
|
@@ -621,23 +718,26 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
621
718
|
}
|
|
622
719
|
listed.push({ order: index, key });
|
|
623
720
|
const cut = () => typeof placed === "string" ? truncateUtf8(placed, valueBytes) : truncatedMarker(size);
|
|
721
|
+
// A log record's value is cut, as no log reader pages an uploaded text value.
|
|
722
|
+
if (collector.log) {
|
|
723
|
+
result[key] = cut();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
624
726
|
if (slots <= 0) {
|
|
625
727
|
result[key] = cut();
|
|
626
728
|
fallback("budget");
|
|
627
729
|
return;
|
|
628
730
|
}
|
|
629
731
|
slots--;
|
|
630
|
-
const stored = await
|
|
732
|
+
const stored = await (async () => {
|
|
631
733
|
if (size > MAX_BLOB_BYTES)
|
|
632
734
|
return { ok: false, reason: "tooLarge" };
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
return { ok: false, reason: refused };
|
|
636
|
-
const digest = await digestOf(placed);
|
|
735
|
+
// Hashing a large value takes a slot as its upload does; the upload takes its own.
|
|
736
|
+
const digest = await uploader.limit(() => digestOf(placed));
|
|
637
737
|
const contentType = typeof placed === "string" ? textContentType(placed) : "application/octet-stream";
|
|
638
738
|
const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
|
|
639
739
|
return outcome.ok ? { ...outcome, digest } : outcome;
|
|
640
|
-
});
|
|
740
|
+
})();
|
|
641
741
|
if (stored.ok) {
|
|
642
742
|
result[key] = typeof placed === "string" ? blobPrefix(placed) : blobPrefix(placed);
|
|
643
743
|
entries.push({
|
|
@@ -651,14 +751,42 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
651
751
|
fallback(stored.reason);
|
|
652
752
|
}
|
|
653
753
|
};
|
|
754
|
+
const body = collector.body ? placeBody(collector.body.value) : undefined;
|
|
654
755
|
await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
|
|
756
|
+
await Promise.all((collector.attachments ?? []).map((file, position) => {
|
|
757
|
+
if (!file.bytes.byteLength)
|
|
758
|
+
return Promise.resolve();
|
|
759
|
+
const slot = slots > 0 ? (slots--, true) : false;
|
|
760
|
+
return (async () => {
|
|
761
|
+
const key = mediaKey(file);
|
|
762
|
+
const outcome = slot
|
|
763
|
+
? await uploader.store(traceId, key, file.bytes, file.contentType, file.digest, upload)
|
|
764
|
+
: { ok: false, reason: "budget" };
|
|
765
|
+
if (outcome.ok) {
|
|
766
|
+
entries.push({
|
|
767
|
+
order: [collector.candidates.length, position],
|
|
768
|
+
entry: blobEntry(key, file.digest.sha256, file.digest.size, outcome.contentType, file.name),
|
|
769
|
+
});
|
|
770
|
+
tally.uploaded++;
|
|
771
|
+
}
|
|
772
|
+
else
|
|
773
|
+
fallback(outcome.reason);
|
|
774
|
+
})();
|
|
775
|
+
}));
|
|
776
|
+
let placedBody;
|
|
777
|
+
if (body) {
|
|
778
|
+
const capped = capLogBody(await body, valueBytes);
|
|
779
|
+
placedBody = capped.body;
|
|
780
|
+
if (capped.cut)
|
|
781
|
+
listed.push({ order: -1, key: "body" });
|
|
782
|
+
}
|
|
655
783
|
if (listed.length)
|
|
656
784
|
result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed.sort((a, b) => a.order - b.order).map(({ key }) => key));
|
|
657
785
|
if (entries.length) {
|
|
658
786
|
entries.sort((a, b) => a.order[0] - b.order[0] || a.order[1] - b.order[1]);
|
|
659
787
|
result[BLOBS_KEY] = [...existing, ...entries.map(({ entry }) => entry)];
|
|
660
788
|
}
|
|
661
|
-
return result;
|
|
789
|
+
return { attributes: result, ...(collector.body ? { body: placedBody } : {}) };
|
|
662
790
|
}
|
|
663
791
|
/**
|
|
664
792
|
* The span's own attributes with every candidate placed as it was before uploads existed, for an
|
|
@@ -666,9 +794,24 @@ export async function offloadAttributes(attributes, collector, options) {
|
|
|
666
794
|
* over the inline limit cut to it (bytes replaced by the receiver's marker) and listed under
|
|
667
795
|
* `hue.truncated`.
|
|
668
796
|
*/
|
|
669
|
-
export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId) {
|
|
797
|
+
export function fallbackAttributes(attributes, collector, valueBytes, tally, traceId, memo) {
|
|
798
|
+
return fallbackRecord(attributes, collector, valueBytes, tally, traceId, memo).attributes;
|
|
799
|
+
}
|
|
800
|
+
/** A record's attributes, and a log record's body, placed as {@link fallbackAttributes} places
|
|
801
|
+
* them: a log record's files keep their bytes inline, and its body over the inline limit is
|
|
802
|
+
* replaced by the receiver's marker, as before. */
|
|
803
|
+
export function fallbackRecord(attributes, collector, valueBytes, tally, traceId, memo) {
|
|
670
804
|
const result = { ...attributes };
|
|
671
805
|
const listed = [];
|
|
806
|
+
let body;
|
|
807
|
+
if (collector.body) {
|
|
808
|
+
const capped = capLogBody(collector.body.value, valueBytes);
|
|
809
|
+
body = capped.body;
|
|
810
|
+
if (capped.cut)
|
|
811
|
+
listed.push("body");
|
|
812
|
+
}
|
|
813
|
+
for (const _file of collector.attachments ?? [])
|
|
814
|
+
countFallback(tally, "failed", traceId);
|
|
672
815
|
for (let index = 0; index < collector.cut.length; index++)
|
|
673
816
|
countFallback(tally, "failed", traceId);
|
|
674
817
|
for (const { key, value } of collector.candidates) {
|
|
@@ -676,28 +819,36 @@ export function fallbackAttributes(attributes, collector, valueBytes, tally, tra
|
|
|
676
819
|
// Each large inline file is reported as a value not uploaded, as the upload step reports it.
|
|
677
820
|
const count = { files: 0 };
|
|
678
821
|
try {
|
|
679
|
-
|
|
822
|
+
// A log record's files stay inline, its messages' files over the inline limit as their
|
|
823
|
+
// digest, as nested messages kept them before log records uploaded.
|
|
824
|
+
if (collector.log)
|
|
825
|
+
placed = hashNestedInlineFiles(key, value, count);
|
|
826
|
+
else if (!collector.inspected || collector.inspected.has(key))
|
|
827
|
+
placed = hashInlineFiles(key, value, count, memo, traceId);
|
|
680
828
|
}
|
|
681
829
|
catch {
|
|
682
830
|
// Left as it is: cut below when over the limit.
|
|
683
831
|
}
|
|
684
832
|
for (let file = 0; file < count.files; file++)
|
|
685
833
|
countFallback(tally, "failed", traceId);
|
|
834
|
+
// A log record's value over the limit is cut whether or not uploads work.
|
|
686
835
|
if (typeof placed === "string") {
|
|
687
836
|
if (Buffer.byteLength(placed, "utf8") > valueBytes) {
|
|
688
837
|
placed = truncateUtf8(placed, valueBytes);
|
|
689
838
|
listed.push(key);
|
|
690
|
-
|
|
839
|
+
if (!collector.log)
|
|
840
|
+
countFallback(tally, "failed", traceId);
|
|
691
841
|
}
|
|
692
842
|
}
|
|
693
843
|
else if (placed instanceof Uint8Array && placed.byteLength > valueBytes) {
|
|
694
844
|
placed = truncatedMarker(placed.byteLength);
|
|
695
845
|
listed.push(key);
|
|
696
|
-
|
|
846
|
+
if (!collector.log)
|
|
847
|
+
countFallback(tally, "failed", traceId);
|
|
697
848
|
}
|
|
698
849
|
result[key] = placed;
|
|
699
850
|
}
|
|
700
851
|
if (listed.length)
|
|
701
852
|
result[TRUNCATED_KEY] = withTruncatedKeys(result[TRUNCATED_KEY], listed);
|
|
702
|
-
return result;
|
|
853
|
+
return { attributes: result, ...(collector.body ? { body } : {}) };
|
|
703
854
|
}
|
|
@@ -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") {
|