@hue-run/sdk 0.18.1 → 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/dist/blobs.js CHANGED
@@ -3,6 +3,7 @@ import { context } from "@opentelemetry/api";
3
3
  import { suppressTracing } from "@opentelemetry/core";
4
4
  import { isLoopbackHost } from "./config.js";
5
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 { isByteSource, JoinedText, longFileBytes, longHead, placedLargeText, readLargeMediaText, } from "./large-media.js";
6
7
  import { capLogBody, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
7
8
  import { truncateUtf8 } from "./safety.js";
8
9
  /**
@@ -99,40 +100,53 @@ export function blobPrefix(value) {
99
100
  return Buffer.from(truncateUtf8(value, BLOB_PREFIX_BYTES), "utf8").toString("utf8");
100
101
  }
101
102
  /** A value's size in bytes as it is uploaded: a string's UTF-8 encoding, counted, not made. */
102
- function sizeOf(value) {
103
- return typeof value === "string" ? Buffer.byteLength(value, "utf8") : value.byteLength;
103
+ export function sizeOf(value) {
104
+ if (typeof value === "string")
105
+ return Buffer.byteLength(value, "utf8");
106
+ return isByteSource(value) ? value.size : value.byteLength;
107
+ }
108
+ /** The bytes a value holds in memory: none for a source read as it is uploaded. */
109
+ export function heldBytesOf(value) {
110
+ return isByteSource(value) ? 0 : value.byteLength;
104
111
  }
105
112
  /** A string's UTF-8 encoding a slice at a time, never splitting a surrogate pair. */
106
113
  function* utf8Chunks(text) {
114
+ // One buffer, reused: a reader is done with each slice before it asks for the next.
115
+ const buffer = Buffer.allocUnsafe(TEXT_CHUNK_UNITS * 3);
107
116
  for (let start = 0; start < text.length;) {
108
117
  let end = Math.min(text.length, start + TEXT_CHUNK_UNITS);
109
118
  if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]))
110
119
  end--;
111
- yield Buffer.from(text.slice(start, end), "utf8");
120
+ yield buffer.subarray(0, buffer.write(text.slice(start, end), "utf8"));
112
121
  start = end;
113
122
  }
114
123
  }
115
124
  /** The bytes an upload carries, a slice at a time: no second copy of a large value is made. */
116
- function* uploadChunks(value) {
117
- if (typeof value === "string") {
118
- yield* utf8Chunks(value);
119
- return;
120
- }
121
- for (let start = 0; start < value.byteLength; start += TEXT_CHUNK_UNITS)
122
- yield value.subarray(start, Math.min(value.byteLength, start + TEXT_CHUNK_UNITS));
125
+ function uploadChunks(value) {
126
+ if (isByteSource(value))
127
+ return value.chunks();
128
+ return (function* () {
129
+ if (typeof value === "string") {
130
+ yield* utf8Chunks(value);
131
+ return;
132
+ }
133
+ for (let start = 0; start < value.byteLength; start += TEXT_CHUNK_UNITS)
134
+ yield value.subarray(start, Math.min(value.byteLength, start + TEXT_CHUNK_UNITS));
135
+ })();
123
136
  }
124
137
  const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
125
138
  /** The SHA-256 (lowercase hex) and byte size of what an upload of `value` carries, yielding to
126
- * the event loop between slices so a large value does not stall the application. */
127
- export async function digestOf(value) {
139
+ * the event loop between slices so a large value does not stall the application. Past
140
+ * `deadline`, it throws rather than read on. */
141
+ export async function digestOf(value, deadline = Infinity) {
128
142
  const hash = createHash("sha256");
129
143
  let size = 0;
130
- let slices = 0;
131
- for (const chunk of uploadChunks(value)) {
144
+ for await (const chunk of uploadChunks(value)) {
132
145
  hash.update(chunk);
133
146
  size += chunk.byteLength;
134
- if (++slices % 4 === 0)
135
- await nextTurn();
147
+ await nextTurn();
148
+ if (Date.now() >= deadline)
149
+ throw new Error("The upload ran out of time");
136
150
  }
137
151
  return { sha256: hash.digest("hex"), size };
138
152
  }
@@ -258,9 +272,12 @@ export class BlobUploader {
258
272
  if (digest.size > MAX_BLOB_BYTES)
259
273
  return { ok: false, reason: "tooLarge" };
260
274
  const identity = `${traceId}:${digest.sha256}`;
261
- const known = this.outcomes.get(identity);
262
- if (known)
263
- return known.promise;
275
+ for (let known = this.outcomes.get(identity); known; known = this.outcomes.get(identity)) {
276
+ const shared = await known.promise;
277
+ // Another reference's file changed since it was recorded: this one tries its own bytes.
278
+ if (shared.ok || !shared.changed)
279
+ return shared;
280
+ }
264
281
  if (this.outcomes.size >= 4096) {
265
282
  const oldest = [...this.outcomes].find(([, item]) => item.settled)?.[0];
266
283
  if (oldest === undefined)
@@ -308,6 +325,8 @@ export class BlobUploader {
308
325
  const put = await this.put(grant, value, digest.size, upload.deadline);
309
326
  if (put === "stored")
310
327
  break;
328
+ if (put === "changed")
329
+ return { ok: false, reason: "failed", changed: true };
311
330
  if (this.closed)
312
331
  return { ok: false, reason: "failed" };
313
332
  const expired = put === "expired" || Date.now() >= grant.expiresAt;
@@ -464,14 +483,21 @@ export class BlobUploader {
464
483
  grant: { url, headers, expiresAt: Number.isFinite(expiresAt) ? expiresAt : Infinity },
465
484
  };
466
485
  }
467
- /** The PUT of the value's bytes, with exactly the headers Hue signed. */
486
+ /** The PUT of the value's bytes, with exactly the headers Hue signed. A source that no longer
487
+ * holds its bytes (a file changed since it was recorded) ends the request before its body is
488
+ * complete, and fails the value without a retry. */
468
489
  async put(grant, value, size, deadline) {
469
490
  const reply = await this.request("PUT", grant.url, grant.headers, value, deadline, MAX_ERROR_RESPONSE_BYTES, size);
491
+ if (isByteSource(value) && value.changed)
492
+ return "changed";
470
493
  if (!reply)
471
494
  return "transient";
472
- // 412: the object exists already (another upload of the same value won).
473
- if ((reply.status >= 200 && reply.status < 300) || reply.status === 412)
495
+ // 412: the object exists already (another upload of the same value won). A success answered
496
+ // before the whole body was written stores nothing that was sent.
497
+ if (reply.status === 412)
474
498
  return "stored";
499
+ if (reply.status >= 200 && reply.status < 300)
500
+ return reply.complete ? "stored" : "failed";
475
501
  if (reply.status === 403)
476
502
  return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
477
503
  if (reply.status >= 500 || reply.status === 408 || reply.status === 429)
@@ -501,6 +527,8 @@ export class BlobUploader {
501
527
  return undefined;
502
528
  return new Promise((resolve) => {
503
529
  let settled = false;
530
+ // Whether the whole body was handed to the request.
531
+ let ended = false;
504
532
  let request;
505
533
  const finish = (reply) => {
506
534
  if (settled)
@@ -509,16 +537,20 @@ export class BlobUploader {
509
537
  clearTimeout(timer);
510
538
  if (request) {
511
539
  this.requests.delete(request);
512
- if (!reply)
540
+ // An answer before the whole body was handed over ends the request: a store that
541
+ // neither reads nor closes would otherwise hold it, and its source, open.
542
+ if (!reply || !ended)
513
543
  request.destroy();
514
544
  }
515
545
  resolve(reply);
516
546
  };
517
547
  const timer = setTimeout(() => finish(), Math.max(1, deadline - Date.now()));
548
+ // The bytes handed to the request: its body is complete once they are its length.
549
+ let sentBytes = 0;
550
+ const written = () => length === undefined || sentBytes === length;
518
551
  const requestHeaders = { ...headers };
519
552
  if (length === undefined)
520
- requestHeaders["Content-Length"] =
521
- typeof body === "string" ? Buffer.byteLength(body, "utf8") : body.byteLength;
553
+ requestHeaders["Content-Length"] = sizeOf(body);
522
554
  try {
523
555
  // Uploads are the SDK's own export traffic: an http instrumentation must not trace them.
524
556
  request = context.with(suppressTracing(context.active()), () => http.request(url, { method, headers: requestHeaders, agent: false }, (response) => {
@@ -528,6 +560,7 @@ export class BlobUploader {
528
560
  status: response.statusCode ?? 0,
529
561
  headers: response.headers,
530
562
  body: Buffer.concat(chunks),
563
+ complete: written(),
531
564
  });
532
565
  response.on("data", (chunk) => {
533
566
  size += chunk.byteLength;
@@ -552,26 +585,41 @@ export class BlobUploader {
552
585
  sent.on("error", () => finish());
553
586
  sent.setTimeout(this.options.timeoutMillis, () => finish());
554
587
  void (async () => {
555
- if (length === undefined) {
588
+ if (length === undefined && !isByteSource(body)) {
589
+ ended = true;
556
590
  sent.end(body);
557
591
  return;
558
592
  }
559
- for (const chunk of uploadChunks(body)) {
593
+ // A source or a text may reuse a slice's memory for the next: each slice is written out
594
+ // before the next is read. Bytes are views of memory that does not change.
595
+ const reused = !(body instanceof Uint8Array);
596
+ for await (const chunk of uploadChunks(body)) {
560
597
  if (settled)
561
598
  return;
562
- if (!sent.write(chunk))
563
- await new Promise((drained) => {
564
- const done = () => {
565
- sent.off("drain", done);
566
- sent.off("close", done);
567
- drained();
568
- };
599
+ sentBytes += chunk.byteLength;
600
+ await new Promise((proceed) => {
601
+ const done = () => {
602
+ sent.off("drain", done);
603
+ sent.off("close", done);
604
+ proceed();
605
+ };
606
+ sent.on("close", done);
607
+ if (reused) {
608
+ sent.write(chunk, () => done());
609
+ return;
610
+ }
611
+ if (sent.write(chunk))
612
+ done();
613
+ else
569
614
  sent.on("drain", done);
570
- sent.on("close", done);
571
- });
615
+ });
616
+ if (settled)
617
+ return;
572
618
  }
573
- if (!settled)
574
- sent.end();
619
+ if (settled)
620
+ return;
621
+ ended = true;
622
+ sent.end();
575
623
  })().catch(() => finish());
576
624
  });
577
625
  }
@@ -615,30 +663,46 @@ export async function offloadRecord(attributes, collector, options) {
615
663
  * uploaded keeps its bytes inline, except in a recorded message: a span's own message keeps its
616
664
  * digest, and a log record's keeps the digest of a file over the inline limit, as nested
617
665
  * messages kept them before log records uploaded. */
618
- const placeFiles = async (index, key, files) => {
666
+ const placeFiles = async (index, key, files, long) => {
619
667
  const message = isMessageKey(key);
620
668
  let changed = false;
621
669
  await Promise.all(files.map((file, position) => {
622
- const decoded = mediaBytes(file);
623
- if (!placeable(file, decoded))
670
+ // A file whose content is a long string is read from its place in the recorded text, a
671
+ // slice at a time: never decoded or copied whole.
672
+ const inPlace = long?.get(file.content);
673
+ const decoded = inPlace ? longFileBytes(file, inPlace) : mediaBytes(file);
674
+ const bytes = "source" in decoded ? decoded.source : decoded.bytes;
675
+ const size = sizeOf(bytes);
676
+ if (!placeable(file, decoded, size))
624
677
  return Promise.resolve();
625
678
  const contentType = mediaContentType(file, decoded);
626
679
  const textual = isTextMediaType(contentType);
627
- if (textual && decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
680
+ if (textual && size <= INLINE_FILE_LIMIT)
628
681
  return Promise.resolve();
629
682
  const prefix = keepsPrefix(file, textual, decoded);
630
683
  const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
631
684
  const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
632
685
  return (async () => {
633
- const digest = options.memo
634
- ? options.memo.digest(file, decoded, traceId)
635
- : await digestOf(decoded.bytes);
686
+ // Hashing a long file takes an upload place, as hashing a large value does; one the
687
+ // export's upload budget cannot hash stays as recorded and is not uploaded.
688
+ let digest;
689
+ try {
690
+ digest = isByteSource(bytes)
691
+ ? await uploader.limit(() => digestOf(bytes, upload.deadline))
692
+ : options.memo
693
+ ? options.memo.digest(file, decoded, traceId)
694
+ : await digestOf(bytes);
695
+ }
696
+ catch {
697
+ fallback("failed");
698
+ return;
699
+ }
636
700
  const outcome = slot
637
- ? await uploader.store(traceId, partKey, decoded.bytes, contentType, digest, upload)
701
+ ? await uploader.store(traceId, partKey, bytes, contentType, digest, upload)
638
702
  : { ok: false, reason: "budget" };
639
703
  if (outcome.ok) {
640
704
  if (prefix)
641
- prefixPart(file, blobPrefix(file.content));
705
+ prefixPart(file, blobPrefix(inPlace ? longHead(inPlace, BLOB_PREFIX_BYTES + 1) : file.content));
642
706
  else
643
707
  digestPart(file, digest);
644
708
  changed = true;
@@ -649,7 +713,7 @@ export async function offloadRecord(attributes, collector, options) {
649
713
  tally.uploaded++;
650
714
  }
651
715
  else {
652
- if (message && (!collector.log || decoded.bytes.byteLength > INLINE_FILE_LIMIT)) {
716
+ if (message && (!collector.log || size > INLINE_FILE_LIMIT)) {
653
717
  digestPart(file, digest);
654
718
  changed = true;
655
719
  }
@@ -661,30 +725,59 @@ export async function offloadRecord(attributes, collector, options) {
661
725
  };
662
726
  const literal = literalKeys(attributes);
663
727
  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))
728
+ // Literal text holds no file.
729
+ if (!mayInlineFiles(key, text) || literal.has(key))
666
730
  return text;
667
- if (collector.inspected) {
668
- // A value admission skipped stays as recorded.
669
- if (!collector.inspected.has(key))
670
- return text;
731
+ let media;
732
+ let large;
733
+ if (collector.large?.has(key)) {
734
+ // Held whole past the inspection bounds: its long strings were read in place, the JSON
735
+ // around them within the per-attribute bound.
736
+ media = large = largeReads.get(index);
671
737
  }
672
738
  else {
673
- if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
739
+ // Both bounds count UTF-16 code units, as admission does.
740
+ if (text.length > MAX_INLINE_FILE_TEXT)
674
741
  return text;
675
- inspected += text.length;
742
+ if (collector.inspected) {
743
+ // A value admission skipped stays as recorded.
744
+ if (!collector.inspected.has(key))
745
+ return text;
746
+ }
747
+ else {
748
+ if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
749
+ return text;
750
+ inspected += text.length;
751
+ }
752
+ try {
753
+ media = readMediaText(key, text);
754
+ }
755
+ catch {
756
+ return text;
757
+ }
676
758
  }
677
- let media;
759
+ if (!media?.files.length)
760
+ return text;
761
+ if (!(await placeFiles(index, key, media.files, large?.long)))
762
+ return text;
763
+ return large ? await placedLargeText(large) : placedMediaText(media);
764
+ };
765
+ // The values read in place are scanned first, in candidate order, between turns of the event
766
+ // loop: places in `hue.blobs` are then taken in candidate order, as for any value.
767
+ const largeReads = new Map();
768
+ for (const [index, { key, value }] of collector.candidates.entries()) {
769
+ if (typeof value !== "string" ||
770
+ !collector.large?.has(key) ||
771
+ !mayInlineFiles(key, value) ||
772
+ literal.has(key))
773
+ continue;
678
774
  try {
679
- media = readMediaText(key, text);
775
+ largeReads.set(index, await readLargeMediaText(key, value, options.longUnits));
680
776
  }
681
777
  catch {
682
- return text;
778
+ // JSON that does not parse holds no file: the value stays as recorded.
683
779
  }
684
- if (!media?.files.length)
685
- return text;
686
- return (await placeFiles(index, key, media.files)) ? placedMediaText(media) : text;
687
- };
780
+ }
688
781
  /** A log record's body with its files placed. Each file's content counts toward the record's
689
782
  * inspection bounds, as an attribute's text does. */
690
783
  const placeBody = async (body) => {
@@ -710,14 +803,22 @@ export async function offloadRecord(attributes, collector, options) {
710
803
  return media.box.value;
711
804
  };
712
805
  const place = async (index, { key, value }) => {
713
- const placed = typeof value === "string" ? await parts(index, key, value) : value;
714
- const size = sizeOf(placed);
806
+ const read = typeof value === "string" ? await parts(index, key, value) : value;
807
+ // Text joined from pieces of the recorded text is made one string only when it is kept
808
+ // inline; otherwise it is measured, hashed, uploaded and cut from its pieces.
809
+ const joined = read instanceof JoinedText ? read : undefined;
810
+ const size = joined ? joined.utf8Size() : sizeOf(read);
715
811
  if (size <= valueBytes) {
716
- result[key] = placed;
812
+ result[key] = joined ? joined.text() : read;
717
813
  return;
718
814
  }
815
+ const placed = joined ? joined.source(size) : read;
719
816
  listed.push({ order: index, key });
720
- const cut = () => typeof placed === "string" ? truncateUtf8(placed, valueBytes) : truncatedMarker(size);
817
+ const cut = () => joined
818
+ ? truncateUtf8(joined.head(valueBytes + 1), valueBytes)
819
+ : typeof placed === "string"
820
+ ? truncateUtf8(placed, valueBytes)
821
+ : truncatedMarker(size);
721
822
  // A log record's value is cut, as no log reader pages an uploaded text value.
722
823
  if (collector.log) {
723
824
  result[key] = cut();
@@ -733,13 +834,27 @@ export async function offloadRecord(attributes, collector, options) {
733
834
  if (size > MAX_BLOB_BYTES)
734
835
  return { ok: false, reason: "tooLarge" };
735
836
  // Hashing a large value takes a slot as its upload does; the upload takes its own.
736
- const digest = await uploader.limit(() => digestOf(placed));
737
- const contentType = typeof placed === "string" ? textContentType(placed) : "application/octet-stream";
837
+ let digest;
838
+ try {
839
+ digest = await uploader.limit(() => digestOf(placed, upload.deadline));
840
+ }
841
+ catch {
842
+ return { ok: false, reason: "failed" };
843
+ }
844
+ const contentType = joined
845
+ ? textContentType(joined.head(256) + joined.tail(256))
846
+ : typeof placed === "string"
847
+ ? textContentType(placed)
848
+ : "application/octet-stream";
738
849
  const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
739
850
  return outcome.ok ? { ...outcome, digest } : outcome;
740
851
  })();
741
852
  if (stored.ok) {
742
- result[key] = typeof placed === "string" ? blobPrefix(placed) : blobPrefix(placed);
853
+ result[key] = joined
854
+ ? blobPrefix(joined.head(BLOB_PREFIX_BYTES + 1))
855
+ : typeof placed === "string"
856
+ ? blobPrefix(placed)
857
+ : blobPrefix(placed);
743
858
  entries.push({
744
859
  order: [index, Infinity],
745
860
  entry: blobEntry(key, stored.digest.sha256, stored.digest.size, stored.contentType),
@@ -754,7 +869,7 @@ export async function offloadRecord(attributes, collector, options) {
754
869
  const body = collector.body ? placeBody(collector.body.value) : undefined;
755
870
  await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
756
871
  await Promise.all((collector.attachments ?? []).map((file, position) => {
757
- if (!file.bytes.byteLength)
872
+ if (!sizeOf(file.bytes))
758
873
  return Promise.resolve();
759
874
  const slot = slots > 0 ? (slots--, true) : false;
760
875
  return (async () => {
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` is hashed and measured locally and
170
- * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
171
- * record, or one without an active span, is omitted and counted, never thrown; an invalid name
172
- * alone is omitted and counted while the rest is recorded.
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 || !state.bytes || !state.sha256) {
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.bytes.byteLength)
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
- bytes: state.bytes,
628
- digest: { sha256: state.sha256, size: state.bytes.byteLength },
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` is hashed and measured locally and
792
- * uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
793
- * record, or one without an active span, is omitted and counted, never thrown; an invalid name
794
- * alone is omitted and counted while the rest is recorded.
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
- if (data !== undefined) {
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
- if (!this.transport.stageMedia(span, bytes && eventTime
921
+ const upload = bytes ?? (onDisk ? fileSource(onDisk) : undefined);
922
+ if (!this.transport.stageMedia(span, upload && eventTime
892
923
  ? {
893
- bytes,
894
- digest: { sha256, size: bytes.byteLength },
924
+ bytes: upload,
925
+ digest: { sha256, size: sizeOf(upload) },
895
926
  contentType: mediaType,
896
927
  eventTime,
897
928
  ...(isMediaName(name) ? { name } : {}),
@@ -137,20 +137,21 @@ export declare function placedMediaText(media: MediaValue): string;
137
137
  /** Each JSON-encoded string a placed file sits in, written again, innermost first: the string as
138
138
  * recorded with only its placed files changed. */
139
139
  export declare function reencodeMedia(media: MediaValue): void;
140
- /** Whether a found file has bytes to place: nonempty, and a whole string's `data:` URL decodes. */
141
- export declare function placeable(file: InlineFilePart, decoded: InlineFileBytes): boolean;
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;
142
143
  /** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
143
144
  * provider part, whose entry names the content) rather than its digest. A whole string, a file
144
145
  * inside a JSON-encoded string, and a text file a part holds as bare base64 (a GenAI `blob`,
145
146
  * Vertex `media` or AI SDK part, whose content is not the text itself) take the digest form. */
146
- export declare function keepsPrefix(file: InlineFilePart, textual: boolean, decoded: InlineFileBytes): boolean;
147
+ export declare function keepsPrefix(file: InlineFilePart, textual: boolean, decoded: Pick<InlineFileBytes, "bareBase64">): boolean;
147
148
  /** A file's bytes: a field its convention declares base64 decoded, any other content read as
148
149
  * Hue's reader reads it, whatever the part's media type (`inlineFileBytes`): a `data:` URL by its
149
150
  * own encoding, text that reads as base64 decoded (what the GenAI conventions, and
150
151
  * `client.media.resolve`, write in a `blob` or `media` part for every media type), anything else
151
152
  * as its UTF-8. */
152
153
  export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
153
- export declare function mediaContentType(file: InlineFilePart, decoded: InlineFileBytes): string;
154
+ export declare function mediaContentType(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded" | "urlMediaType">): string;
154
155
  export interface FileDigest {
155
156
  sha256: string;
156
157
  size: number;
@@ -707,9 +707,10 @@ export function reencodeMedia(media) {
707
707
  });
708
708
  }
709
709
  }
710
- /** Whether a found file has bytes to place: nonempty, and a whole string's `data:` URL decodes. */
711
- export function placeable(file, decoded) {
712
- return decoded.bytes.byteLength > 0 && (!file.whole || decoded.decoded);
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);
713
714
  }
714
715
  /** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
715
716
  * provider part, whose entry names the content) rather than its digest. A whole string, a file
@@ -841,7 +842,7 @@ export function digestMedia(media, nested, count, memo, traceId = "") {
841
842
  let changed = false;
842
843
  for (const file of media.files) {
843
844
  const decoded = mediaBytes(file);
844
- if (!placeable(file, decoded))
845
+ if (!placeable(file, decoded, decoded.bytes.byteLength))
845
846
  continue;
846
847
  if ((nested || isTextMediaType(mediaContentType(file, decoded))) &&
847
848
  decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
@@ -875,7 +876,7 @@ export function digestFormBytes(value) {
875
876
  continue;
876
877
  inspected += units;
877
878
  const decoded = mediaBytes(file);
878
- if (!placeable(file, decoded))
879
+ if (!placeable(file, decoded, decoded.bytes.byteLength))
879
880
  continue;
880
881
  if (isTextMediaType(mediaContentType(file, decoded)) &&
881
882
  decoded.bytes.byteLength <= INLINE_FILE_LIMIT)