@hue-run/sdk 0.17.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 CHANGED
@@ -42,7 +42,7 @@ project manifest are refused because managers can update ancestor locks; Python
42
42
  The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
43
43
  `capture_content=False`. For a supported application, setup installs the dependency and adds the
44
44
  managed import and middleware registration to the existing entrypoint; an unreferenced helper is
45
- not a completed integration. TypeScript uses `@hue-run/sdk@0.17.0`, `@opentelemetry/api@1.9.1` and
45
+ not a completed integration. TypeScript uses `@hue-run/sdk@0.18.0`, `@opentelemetry/api@1.9.1` and
46
46
  `@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
47
47
  Content capture requires an ordinary account-managed key and a later explicit application decision.
48
48
 
@@ -469,7 +469,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
469
469
  # Set project to an existing supported fixture; use the same directory on resume.
470
470
  project=/absolute/path/to/supported-fixture
471
471
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
472
- --archive .artifacts/typescript/hue-run-sdk-0.17.0.tgz \
472
+ --archive .artifacts/typescript/hue-run-sdk-0.18.0.tgz \
473
473
  --origin https://STAGING_ORIGIN \
474
474
  --project "$project" --command setup \
475
475
  --evidence .context/setup-staging-before-claim.json
@@ -480,7 +480,7 @@ the private local handoff and finish the real browser claim, then reconcile the
480
480
 
481
481
  ```sh
482
482
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
483
- --archive .artifacts/typescript/hue-run-sdk-0.17.0.tgz \
483
+ --archive .artifacts/typescript/hue-run-sdk-0.18.0.tgz \
484
484
  --origin https://STAGING_ORIGIN \
485
485
  --project "$project" --command claim \
486
486
  --evidence .context/setup-staging-after-claim.json
@@ -522,10 +522,10 @@ otherwise. Never hand the agent Hue-native tools (`context.tools`, `context.mcp`
522
522
  production agent lacks. Pin the SDK, so the agent's repository gains no dependency:
523
523
 
524
524
  ```sh
525
- npx -y @hue-run/sdk@0.17.0 eval --case "Refund an eligible charge" --command "node agent.js"
526
- npx -y @hue-run/sdk@0.17.0 eval --case https://app.hue.run/projects/demo/scenarios/<id> --command "python agent.py" --timeout 120 --baseline <run id>
527
- npx -y @hue-run/sdk@0.17.0 eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
528
- npx -y @hue-run/sdk@0.17.0 eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision>
525
+ npx -y @hue-run/sdk@0.18.0 eval --case "Refund an eligible charge" --command "node agent.js"
526
+ npx -y @hue-run/sdk@0.18.0 eval --case https://app.hue.run/projects/demo/scenarios/<id> --command "python agent.py" --timeout 120 --baseline <run id>
527
+ npx -y @hue-run/sdk@0.18.0 eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
528
+ npx -y @hue-run/sdk@0.18.0 eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision>
529
529
  ```
530
530
 
531
531
  One invocation creates one run holding every selected case; it prints the run URL
@@ -563,7 +563,7 @@ export default function runMyAgent(inputs: JsonValue, context: SimulationTargetC
563
563
  ```
564
564
 
565
565
  ```sh
566
- npx -y @hue-run/sdk@0.17.0 eval --case "Refund an eligible charge" ./hue-agent.ts --content
566
+ npx -y @hue-run/sdk@0.18.0 eval --case "Refund an eligible charge" ./hue-agent.ts --content
567
567
  ```
568
568
 
569
569
  `--scenario` remains an alias for `--case` for existing scripts. Pass one selection flag.
package/EVALUATIONS.md CHANGED
@@ -295,13 +295,63 @@ an `error` of type `TargetTimeout` and the world is sealed abandoned. Public pac
295
295
  proves this lifecycle against local fixtures; exact installed-registry-package to hosted-facade
296
296
  acceptance remains a post-publication Fern gate.
297
297
 
298
+ ## Case files
299
+
300
+ `addCase` and `addEvalSetCase` pin up to 32 input files with `files`. Name an artifact the
301
+ project already holds as `{ artifactId, role }`, or give the file, from `bytes` or a `path`, and
302
+ the client uploads it as a verified artifact first:
303
+
304
+ ```ts
305
+ await client.addEvalSetCase(versionId, {
306
+ expectedRevision,
307
+ externalKey: "chart-question",
308
+ inputs: { messages },
309
+ files: [
310
+ { role: "source", filename: "chart.png", contentType: "image/png", bytes: chart },
311
+ { role: "evaluator_reference", filename: "answers.csv", contentType: "text/csv", path: "answers.csv" },
312
+ ],
313
+ });
314
+ ```
315
+
316
+ An uploaded file needs one of `outputContentTypes`, a well-formed name of 1–255 UTF-16 code units
317
+ (as JavaScript counts a string's `length`) without path separators or control characters, and 1
318
+ byte to 25 MiB. Every file, and the case itself as it will be sent (its revision, key, values and
319
+ size), is checked before the first upload; a file outside that policy, a `path` that cannot be
320
+ read, or a case Hue would refuse throws `TypeError` or `RangeError` without a request. The
321
+ reservation key names the version, the case's `externalKey`, the role, the bytes, the name and the
322
+ content type, so sending the same case again reuses its artifacts and a corrected name or type
323
+ reserves a new one; a reference to the artifact a file uploads as pins it once. `source`, `attached_template`, `attached_reference` and `original` files reach
324
+ the target as `context.files`; `org_template` and `evaluator_reference` files reach only scorers.
325
+
326
+ Recorded messages copied into a case's inputs keep their large files as digest parts. Pin each file
327
+ to the case, and the target can send the recorded model call again with the real image, document
328
+ or audio:
329
+
330
+ ```ts
331
+ import { rehydrateMedia, runExperiment } from "@hue-run/sdk/evals";
332
+
333
+ await runExperiment({
334
+ // ...client, hue, experimentId, checkpointDirectory and the other options
335
+ target: async (inputs, { files }) => {
336
+ const { value } = await rehydrateMedia(inputs, files);
337
+ return callModel(value);
338
+ },
339
+ });
340
+ ```
341
+
342
+ `rehydrateMedia(value, files, { as })` matches digest parts to files by SHA-256, checks each
343
+ file's bytes against the digest and writes them as `client.media.resolve` does (see the
344
+ [README](README.md#read-recorded-media-back)); `files` may also hold `{ sha256, contentType, bytes }`
345
+ entries. A part without a matching file stays as it is and is listed in `failures`.
346
+
298
347
  ## Direct cases and files
299
348
 
300
349
  Cases without a simulated world — answer-only tasks, or document workflows with pinned files —
301
350
  run on the same runner under the same checkpoint rules. They shipped in `@hue-run/sdk` `0.5.0`
302
351
  and require a Hue deployment that serves case `inputFiles` on experiment
303
352
  items, subject `files`, and the artifact reservation, upload, completion and download APIs. The
304
- Python SDK has no equivalent.
353
+ Python SDK's `run_experiment` and `rescore` hand pinned input files to targets and scorers;
354
+ generated files and the outbound worker are TypeScript-only.
305
355
 
306
356
  Since TypeScript 0.13.2, the `hue eval --worker --command "…"` wrapper supplies both callbacks, so one
307
357
  command worker accepts direct and world cases, including mixed eval sets. It keeps the launch
@@ -380,7 +430,8 @@ evaluator material must stay out of its reach. Verified copies live under `files
380
430
  `<checkpointDirectory>/files`, created mode 0700), evaluator-only files apart from the agent's, and
381
431
  each case gets its own private `context.outputDirectory` to write into. The files of a case pinned
382
432
  to a world are removed when it ends, except staged outputs an interrupted upload resumes from;
383
- those of a direct case stay for inspection.
433
+ those of a direct case stay for inspection, except its evaluator-only files, which are removed when
434
+ it ends so no later case's agent finds them.
384
435
 
385
436
  Return generated documents by wrapping the output in `withFiles(output, files)`. Each entry names a
386
437
  `path` or in-memory `bytes`, a `filename`, a Hue-accepted `contentType` and at most one `primary`.
package/README.md CHANGED
@@ -202,7 +202,172 @@ helpers (`setInput`, `setOutput`, `tool` arguments and results, `recordMessages`
202
202
  that is not JSON, such as a `Date` or a class instance, is omitted with an instrumentation failure
203
203
  while the callback result is returned unchanged.
204
204
 
205
- ## Files
205
+ ## Images, documents and audio
206
+
207
+ Hue stores the bytes of images, documents and audio apart from the spans and log records that
208
+ mention them, under the record's trace and named by SHA-256. The record keeps the file's digest in
209
+ its place and lists the upload in its `hue.blobs` attribute. Files reach Hue in four ways:
210
+
211
+ | You have | Use | The record carries | `hue.blobs` key |
212
+ | --- | --- | --- | --- |
213
+ | Files inline in the messages and values your code or framework already records | Nothing: [automatic capture](#automatically-captured-media) | The part, in digest form | `<attribute>#<JSON pointer>` |
214
+ | A file to place at a position in a value you record | [`Attachment`](#attach-a-file-anywhere) | A GenAI `blob` part, in digest form | `<attribute>#<JSON pointer>`, or `body#<JSON pointer>` in a `recordMessages()` log record |
215
+ | A file the work read, received or produced, outside any recorded value | [`hue.recordFile`](#record-a-file) | A `hue.file` event | `event:hue.file:<sha256>` |
216
+ | Files a case's agent and scorers receive in an evaluation | [`CaseWrite.files`](#files-in-evaluation-cases) | An input file pinned on the case | None: case files are artifacts |
217
+
218
+ [`client.media`](#read-recorded-media-back) reads a recorded file back. Every upload needs
219
+ `captureContent: true` and a project key (never a setup credential, `hue_setup_…`) and follows
220
+ `redact`: nothing the redactor removes is uploaded, a value it rewrites uploads as rewritten, and an
221
+ attachment part it rewrites uploads nothing. Uploads share the export's upload budget and the 128
222
+ MiB held for values awaiting upload, and fall back without failing the export; a file used more than
223
+ once in a trace is stored once. See [Values over the inline limit](#values-over-the-inline-limit)
224
+ for the upload protocol, budgets and fallbacks.
225
+
226
+ ### Automatically captured media
227
+
228
+ With `captureContent: true` and a project key, the SDK finds the files inline in the attributes Hue
229
+ reads files from, uploads each file's bytes under the record's trace and leaves the file's digest in
230
+ its place. On a span's own attributes:
231
+
232
+ - Messages and values: `gen_ai.input.messages`, `gen_ai.output.messages`, `ai.prompt.messages`,
233
+ the AI SDK's `ai.prompt`, `input.value` and `output.value` (what `withSpan` and OpenInference
234
+ record), `traceloop.entity.input` and `traceloop.entity.output`, and Langfuse's
235
+ `langfuse.observation.input`, `langfuse.observation.output`, `langfuse.trace.input` and
236
+ `langfuse.trace.output`.
237
+ - Tool arguments and results: `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result`,
238
+ `ai.toolCall.args`, `ai.toolCall.result`, `tool.parameters`, `tool_arguments` and
239
+ `tool_response`.
240
+ - Metadata: `metadata`, `langfuse.observation.metadata` and `langfuse.trace.metadata` (whole, or
241
+ one `.<key>` per attribute) and `ai.telemetry.metadata.<key>`. A metadata value is read as JSON
242
+ when it is an object or array; otherwise it is a file only when the whole value is a `data:` URL.
243
+ - Indexed messages: OpenLLMetry's `gen_ai.prompt.<n>.content` and `gen_ai.completion.<n>.content`
244
+ when the value is the JSON of a parts list (any other content stays text), and OpenInference's
245
+ flattened `llm.input_messages.<n>.message.contents.<m>.message_content.image.image.url` (and
246
+ `llm.output_messages.…`), whose value is the image's URL.
247
+
248
+ Inside those values a file is found by its shape: GenAI `blob`, AI SDK `file` and `image`, OpenAI
249
+ Chat image, file and audio parts and an assistant message's `audio`, Responses `input_image`,
250
+ `input_file` and `computer_screenshot`, Anthropic base64 sources, Gemini `inlineData` and
251
+ `inline_data`, Vertex `media`, Bedrock image and document sources, and any whole string that is a
252
+ `data:` URL declaring a media type. A tool call's `arguments` and a tool result (a `tool` message's
253
+ `content`, `tool_result`, `function_call_output`, `tool_call_response`) are JSON in a string by
254
+ convention, so the files inside them are uploaded too; a message's own text is never parsed as JSON.
255
+ Hue's reader looks for files no deeper than ten levels below a value's root (an encoded string's
256
+ JSON counting as a level), and the SDK inspects no deeper. Hue keeps attribute JSON nested past 32
257
+ levels, or holding an integer past 2^53, as text, and an OpenInference `input.value` or
258
+ `output.value` whose record declares `input.mime_type` or `output.mime_type` as `text/plain` is
259
+ literal text: the SDK leaves such values as recorded. Remote URLs, provider file IDs, S3 references
260
+ and Langfuse media tokens stay unchanged, and Hue never fetches them.
261
+
262
+ Each uploaded file is listed in the record's `hue.blobs`, keyed `<attribute>#<JSON pointer>`:
263
+
264
+ - A provider part loses its byte field and carries `sha256` and `size`; the key names the part,
265
+ for example `gen_ai.input.messages#/0/parts/1`.
266
+ - An OpenAI assistant message's `audio` loses `audio.data` and the message carries `sha256` and
267
+ `size` (`gen_ai.output.messages#/0`). Its media type comes from `audio.format`, else from the
268
+ bytes' WAV, MP3, AAC, FLAC or Ogg signature, else `application/octet-stream`.
269
+ - A whole `data:` URL string becomes a GenAI `blob` part,
270
+ `{"type":"blob","mime_type":"image/png","modality":"image","sha256":"…","size":68}`, keyed at
271
+ the string (`input.value#/image`). A value that is entirely one, such as a metadata attribute,
272
+ becomes that part's JSON text, keyed `<attribute>#`.
273
+ - A file inside a JSON-encoded tool string takes the digest form inside the string. Its key goes on
274
+ through the string, which no JSON pointer can address, so Hue matches it by its digest.
275
+ - Text media over 64 KiB of decoded bytes in a provider part keeps a UTF-8-safe 16 KiB prefix in
276
+ its content field instead, and its key names that field
277
+ (`gen_ai.input.messages#/0/parts/1/content`). Smaller text and empty fields stay inline.
278
+
279
+ Only the file's own value, or the members of the part that held it, change: every other byte of the
280
+ recorded text, its key order, number spellings, escapes and spacing included, is kept, in an encoded
281
+ string as in the attribute itself, so both SDKs write the same bytes. A record has 64 `hue.blobs`
282
+ places: the media inline in its values take them first, in reading order, then its attachments and
283
+ the files recorded with `upload`.
284
+
285
+ Entries use the recorded media type and, when valid, the filename, name or title. A valid name
286
+ is nonblank after trimming, at most 256 UTF-16 code units and well-formed Unicode, with no C0
287
+ characters (U+0000–U+001F) or DEL (U+007F). Invalid names are omitted from the entry without
288
+ rewriting the original field. Data URLs and fields explicitly encoded as base64 by the provider
289
+ contract are decoded before hashing. Other non-data strings with a valid declared textual media
290
+ type remain raw UTF-8: `YWJj` hashes as those four characters. The digest matches
291
+ `hue.recordFile`'s digest for the same bytes.
292
+
293
+ A file that is not uploaded (the upload failed, the receiver has no upload route, or a budget ran
294
+ out) keeps its digest without an entry in the recorded messages (`gen_ai.input.messages`,
295
+ `gen_ai.output.messages`, `ai.prompt.messages`); in every other attribute it keeps its bytes
296
+ inline under the ordinary value limit.
297
+
298
+ Log records upload their media the same way when they are correlated with a span:
299
+ `recordMessages`'s `gen_ai.client.inference.operation.details` record and any `gen_ai.*` event
300
+ with a structured body. The bytes upload under the log record's own trace and the log record lists
301
+ them in its own `hue.blobs`, keyed `body#<JSON pointer>` into the body
302
+ (`body#/gen_ai.input.messages/0/parts/1`, or `body#/content/1` for a deprecated
303
+ `gen_ai.user.message` event) or `<attribute>#<JSON pointer>` for the attributes above. One step
304
+ places a log record's files, the attachments in its body included. A body recorded as JSON text
305
+ stays as it is, and no log value is uploaded whole. A log file that is not uploaded is placed as
306
+ log records were before they uploaded: a file over 64 KiB in a recorded message attribute becomes
307
+ its digest and any other file keeps its bytes; text still over the value limit is cut inline and
308
+ listed under `hue.truncated`. While uploads are available, `recordMessages` records a body over
309
+ the value limit when it fits the limit with its files as their digest.
310
+
311
+ Inspection is bounded to 8 Mi UTF-16 code units of text per attribute and 16 Mi per record, a log
312
+ body's file contents included; a longer value uses the ordinary value limit. Inspection parses the
313
+ value and decodes each file while the record is queued and again when it is exported, holding
314
+ several copies of the value at once; larger files belong in an `Attachment` or in
315
+ `hue.recordFile` with `upload` set, whose bytes are not parsed out of text. Resources, links and
316
+ span events never upload inline media: in their recorded messages, media up to 64 KiB stays
317
+ verbatim and larger media is replaced by its digest, as in 0.16, and log records that cannot
318
+ upload (not correlated with a span, or while uploads are unavailable) keep the same rule. With
319
+ `captureContent: false`, nothing is inspected or uploaded.
320
+
321
+ ### Attach a file anywhere
322
+
323
+ Wrap a file in an `Attachment` and put it anywhere in a value Hue records: a `withSpan()` input or
324
+ output, `model()` messages, a tool's arguments or result, `recordMessages()` messages or a span
325
+ attribute passed to `withSpan()`.
326
+
327
+ ```ts
328
+ import { readFile } from "node:fs/promises";
329
+ import { Attachment } from "@hue-run/sdk";
330
+
331
+ const png = await readFile("scan.png");
332
+ await hue.withSpan(
333
+ "review contract",
334
+ (span) => {
335
+ span.setInput({
336
+ question: "Which clauses changed?",
337
+ contract: new Attachment({ path: "contract.pdf", contentType: "application/pdf" }),
338
+ });
339
+ },
340
+ { attributes: { metadata: { scan: new Attachment({ data: png, contentType: "image/png" }) } } },
341
+ );
342
+ ```
343
+
344
+ Give exactly one of `data` (a `Buffer`, typed array, `ArrayBuffer`, or a base64 string or `data:`
345
+ URL) and `path` (read from the file system when the attachment is created), a `contentType` and
346
+ optionally a `name`. The bytes are read and hashed when the attachment is created and are limited
347
+ to 25 MiB, the `recordFile` limit: they are held in memory from then until export, within the
348
+ 128 MiB budget that queued spans share for values awaiting upload.
349
+
350
+ At its position the attachment is recorded as a GenAI `blob` part that names the file by digest,
351
+ `{"type":"blob","mime_type":"application/pdf","modality":"document","sha256":"<64 hex>","size":48213,"name":"contract.pdf"}`,
352
+ with `modality` read from the content type (`image`, `audio`, `video`, `document` or `file`).
353
+ Export uploads the bytes and lists them under the record's `hue.blobs`, keyed
354
+ `<attribute>#<JSON pointer to the part>`: `input.value#/contract` above, `metadata#/scan` for the
355
+ attribute, and `body#/gen_ai.input.messages/0/parts/1` for a part in a `recordMessages()` log
356
+ record, which uploads with the other media in the body. An attribute value that is an attachment,
357
+ a JSON object or an array OpenTelemetry cannot hold (not of one primitive type), with or without
358
+ attachments, is recorded as JSON text; put files under `metadata` for Hue to list them as the
359
+ span's metadata files.
360
+
361
+ With `captureContent: false` or a setup credential no bytes leave the process: a metadata
362
+ attribute still records the part, without its name, and content values are not recorded at all. A
363
+ part the redactor removes or rewrites is not uploaded. A part that cannot be uploaded keeps its
364
+ digest and is counted in `report.uploadFallbacks`, as is one nested more than 10 levels deep in its
365
+ value, where Hue does not read parts, or one in a value cut to the value limit. Creating an
366
+ attachment never throws: one without exactly one of `data` and `path`, with unreadable or larger
367
+ data or an invalid content type is recorded as `null` and counted as an instrumentation failure;
368
+ an invalid name alone is omitted and counted.
369
+
370
+ ### Record a file
206
371
 
207
372
  Record a file the work read, received or produced with `hue.recordFile`. It adds a `hue.file`
208
373
  event to the active span, or to the span of an explicit context passed second. The event identifies
@@ -223,7 +388,9 @@ await hue.withSpan("review contract", async () => {
223
388
  `data` is limited to 25 MiB. Larger data is omitted and counted as an instrumentation failure;
224
389
  pass `sha256` and `byteSize` when you already have them. Upload requires supplied bytes, content
225
390
  capture and an ordinary project key. It runs during export and adds a `hue.blobs` entry to the
226
- same span, keyed `event:hue.file:<sha256>`, while preserving the event.
391
+ same span, keyed `event:hue.file:<sha256>`, while preserving the event. An export fits every
392
+ span's events and entries before it uploads any of these files, then uploads the files of all its
393
+ spans together, four at a time; a file whose event was redacted or shed is not uploaded.
227
394
 
228
395
  The event carries `hue.file.sha256`, `hue.file.role` (`input`, `attachment` or `output`),
229
396
  `hue.file.media_type` and `hue.file.size` in both capture modes. `hue.file.name` requires
@@ -233,6 +400,88 @@ Tabs, newlines, DEL and lone surrogates make a name invalid. An invalid name is
233
400
  counted as an instrumentation failure; the otherwise valid event remains. Invalid records and
234
401
  records without an active span count as instrumentation failures.
235
402
 
403
+ ### Read recorded media back
404
+
405
+ The evaluation client's `media` reads a recorded file back by its digest, with a key that may read
406
+ recorded content (**Read** or **Read and write**; a trace snapshot's copy needs **Read and write**):
407
+
408
+ ```ts
409
+ import { createEvaluationClient } from "@hue-run/sdk/evals";
410
+
411
+ const client = createEvaluationClient({ apiKey: process.env.HUE_API_KEY! });
412
+
413
+ // One file by digest; pass the span that recorded it when you know it.
414
+ const file = await client.media.fetch({ traceId, spanId, sha256 });
415
+ console.log(file.contentType, file.fileName, file.size, file.bytes);
416
+
417
+ // Every digest part of a recorded value, written back in its own field.
418
+ const recorded = JSON.parse(attributes["gen_ai.input.messages"]);
419
+ const { value: messages, failures } = await client.media.resolve(recorded, { traceId, spanId });
420
+ ```
421
+
422
+ `fetch` asks Hue for a five-minute download link (`GET /api/v1/traces/{traceId}/blobs/{sha256}`),
423
+ downloads it without the project key and refuses bytes whose SHA-256 is not the digest. It throws
424
+ `MediaFetchError`, whose `reason` is `not_found` (Hue holds no such file for the trace),
425
+ `missing` (the trace lists it, but it was never uploaded or has been erased), `unavailable` (still
426
+ unavailable after one retry that waits out a `Retry-After` of up to 10 seconds), `forbidden` (the
427
+ key cannot read recorded content, or reads a snapshot without **Read and write**), `too_large`
428
+ (over `maxBytes`, 64 MiB by default, checked before downloading), `digest_mismatch` or `failed` (a
429
+ connection failure, a timeout, a redirect or another unexpected answer). Hue is asked for the link
430
+ once more after a connection failure or a timeout, never after an unexpected answer; a download
431
+ ends once no byte has arrived for the client's timeout, at least two minutes, and is not retried.
432
+ `{ snapshotId, sha256 }` reads a trace snapshot's pinned copy instead
433
+ (`GET /api/v1/trace-snapshots/{snapshotId}/blobs/{sha256}`), which carries no media type or name.
434
+
435
+ `resolve` downloads each distinct digest once, four at a time (`concurrency`, up to 16), and returns
436
+ a new value; the input is not changed. With `as: "dataUrl"` (the default) each part receives a
437
+ `data:` URL where its field takes one (OpenAI `image_url` and `file_data`, Responses `input_image`,
438
+ `input_file` and `computer_screenshot`, AI SDK `image` and `file`) and bare base64 where its
439
+ convention requires it (GenAI `blob`, OpenAI `input_audio` and assistant `audio`, Anthropic
440
+ `source.data`, Gemini `inlineData`, Vertex `media`, Bedrock `source.bytes`), so the messages can be
441
+ sent to the provider again. A GenAI `blob` or Vertex `media` part receives base64 whatever its media
442
+ type, as the GenAI conventions define its content and as Hue reads content that reads as base64;
443
+ `as: "bytes"` writes a `Uint8Array`. A resolved part no longer carries `sha256` and `size`. A part
444
+ whose file cannot be read stays as it is and is listed in `failures` with its JSON pointer and
445
+ reason. Parts inside JSON-encoded strings, such as tool-call arguments, and Langfuse media tokens,
446
+ which name files Langfuse holds, are left as they are; `fetch` reads the former by the digest the
447
+ part carries.
448
+
449
+ A snapshot's copies carry no media type, so a part that declares none would get a `data:` URL of
450
+ `application/octet-stream`. Pass the record's `hue.blobs` entries in any form Hue reads (the JSON
451
+ strings the attribute holds, objects, or one string holding the JSON array), and such a part takes
452
+ the type of the entry with its SHA-256. Text over 64 KiB that a record keeps as its first 16 KiB
453
+ is never written back: a part whose content field an entry names, holding only that prefix, stays
454
+ as it is and is listed in `failures` as `prefix_only` with the entry's SHA-256, and
455
+ `fetch({ traceId, sha256 })` returns the whole file. An entry names a pointer, not a value, so the
456
+ entries of another attribute with a file at the same pointer are listed too; pass only the entries
457
+ of the value's own attribute to list its own file.
458
+
459
+ ```ts
460
+ const { value } = await client.media.resolve(recorded, {
461
+ snapshotId,
462
+ blobs: attributes["hue.blobs"],
463
+ });
464
+ ```
465
+
466
+ ### Files in evaluation cases
467
+
468
+ `addCase` and `addEvalSetCase` pin up to 32 input files on a case with `files`: artifacts the
469
+ project already holds, or bytes and paths the client checks and uploads first. A case's target
470
+ receives its agent-visible files as `context.files`, and `rehydrateMedia(inputs, files)` writes
471
+ them back into the digest parts of recorded messages copied into the case, so the target can send
472
+ the recorded model call again with the real image, document or audio. See
473
+ [Case files](EVALUATIONS.md#case-files).
474
+
475
+ ### Coming from Langfuse or Braintrust
476
+
477
+ | Langfuse | Braintrust | Hue |
478
+ | --- | --- | --- |
479
+ | Base64 data URIs in an observation's input, output or metadata | | [Automatic capture](#automatically-captured-media), for the attributes and shapes listed there |
480
+ | `LangfuseMedia` | `Attachment` | [`Attachment`](#attach-a-file-anywhere), at the same position |
481
+ | `resolveMediaReferences({ obj, resolveWith: "base64DataUri" })` | | `client.media.resolve(obj, { traceId })` |
482
+ | `GET /api/public/media/{mediaId}` | `ReadonlyAttachment.data()` | `client.media.fetch({ traceId, sha256 })` |
483
+ | Media tokens (`@@@langfuseMedia:…@@@`) | `ExternalAttachment` | Left as recorded: Hue never fetches a remote URL or a file another service holds |
484
+
236
485
  ## Vercel AI SDK 6
237
486
 
238
487
  AI SDK 6 accepts a per-call tracer through `experimental_telemetry`. Pass
@@ -376,34 +625,8 @@ definition list. The digest is the same in both SDKs and does not change when a
376
625
  rotates. Only definitions another integration recorded can be summarized: `hueTelemetry(hue)` with
377
626
  `captureContent: false` records none, whereas an application whose AI SDK integration records
378
627
  inputs and exports through Hue's attached processors gets the summary.
379
- Recorded messages can contain images, documents and audio. Hue inspects
380
- `gen_ai.input.messages`, `gen_ai.output.messages` and `ai.prompt.messages`, including `parts`
381
- and OpenAI `content` arrays. Supported byte shapes include GenAI `blob`, AI SDK `file` and
382
- `image`, OpenAI Chat and Responses image, file and audio parts, Anthropic base64 sources, Gemini
383
- `inlineData` and `inline_data`, Vertex `media`, and Bedrock image and document sources.
384
- Remote URLs, provider file IDs and S3 references stay unchanged. Hue never fetches them.
385
-
386
- Nonempty binary media in a span's own message attributes is eligible for upload at every size
387
- within the inspection limits. Empty byte fields stay recorded and do not upload. The byte field is
388
- removed and the part carries `sha256` and `size`; the span's `hue.blobs` entry points to the part,
389
- for example `gen_ai.input.messages#/0/parts/1`. Text media over 64 KiB of decoded bytes uploads
390
- with a UTF-8-safe 16 KiB prefix in its content field. Its entry points to that field, for example
391
- `gen_ai.input.messages#/0/parts/1/content`. Smaller text stays inline. Hue reads both these forms
392
- and earlier binary prefixes. If upload fails, the part keeps its digest with no blob entry.
393
- In resources, events, links and log records, inline media up to 64 KiB stays verbatim; larger
394
- inline media is replaced by its digest, as events, links and log records were in 0.16. These
395
- nested values never upload. Ordinary hold, cut or drop rules still apply to the resulting record.
396
-
397
- Entries use the recorded media type and, when valid, the filename, name or title. A valid name
398
- is nonblank after trimming, at most 256 UTF-16 code units and well-formed Unicode, with no C0
399
- characters (U+0000–U+001F) or DEL (U+007F). Invalid names are omitted
400
- from the entry without rewriting the original field. Data URLs and fields explicitly encoded as
401
- base64 by the provider contract are decoded before hashing. Other non-data strings with a valid
402
- declared textual media type remain raw UTF-8: `YWJj` hashes as those four characters. The digest
403
- matches `hue.recordFile`'s digest for the same bytes. Inspection is bounded to 8 Mi UTF-16
404
- code units of text per message attribute and 16 Mi per record. Longer attributes use the ordinary value limit.
405
- With `captureContent: false`, messages are removed without inspection or uploads. See
406
- [Values over the inline limit](#values-over-the-inline-limit).
628
+ Recorded content can carry images, documents and audio: see
629
+ [Images, documents and audio](#images-documents-and-audio) for what the SDK uploads and how.
407
630
 
408
631
  Manual helpers encode JSON values without converting null into absence. Unknown
409
632
  outputs and usage remain absent. This SDK does not estimate tokens or cost. A thrown
@@ -543,8 +766,9 @@ dropped event and link counts, rather than being dropped whole.
543
766
 
544
767
  Hue keeps a value inline up to its value limit (1 MiB unless the receiver advertises another in
545
768
  `Hue-Max-Value-Bytes`). A span's own string attribute larger than that (helper content such as
546
- `input.value`, `output.value` and tool results, or another instrumentation's attribute), and eligible media in a span's own recorded messages, are uploaded to Hue apart from the span,
547
- up to Hue's upload limit of 1,000,000,000 bytes:
769
+ `input.value`, `output.value` and tool results, or another instrumentation's attribute), and the
770
+ files described in [Images, documents and audio](#images-documents-and-audio), are uploaded to Hue
771
+ apart from their record, up to Hue's upload limit of 1,000,000,000 bytes:
548
772
 
549
773
  1. Export computes the SHA-256 of the value's bytes (a string as UTF-8, a file as its own bytes)
550
774
  and reserves it with `POST /api/v1/otlp/blobs`, authenticated with the project key. Hue answers
@@ -556,7 +780,7 @@ up to Hue's upload limit of 1,000,000,000 bytes:
556
780
  the upload is confirmed with `POST /api/v1/otlp/blobs/complete`. A PUT the store answers with
557
781
  412 (the same value uploaded at the same time by another span) counts as stored.
558
782
  3. Whole string values and large text media keep a UTF-8-safe 16 KiB prefix. Binary media keeps
559
- its digest on the part. The span lists stored values in `hue.blobs`, at most 64 compact JSON
783
+ its digest on the part. The record lists stored values in `hue.blobs`, at most 64 compact JSON
560
784
  strings with fields ordered `key`, `sha256`, `size`, `content_type` and optional `name`:
561
785
  `{"key":"output.value","sha256":"<64 hex>","size":5242880,"content_type":"application/json"}`.
562
786
  A whole value is listed under `hue.truncated` as well, since its inline text is a prefix.
@@ -575,8 +799,8 @@ media parts keep their digest without a blob entry; failed opt-in `recordFile` u
575
799
  the file event. The explicit helper limit is 25 MiB; whole attributes retain their 1 GB limit. Each
576
800
  export reports such values in a `warning` issue per cause, whose `count` is the values and
577
801
  `traceIds` their traces, and counts them in `report.uploadFallbacks`; successful uploads are counted
578
- in `report.uploadedValues`. Both counters include whole attributes, eligible media and opt-in
579
- `recordFile` uploads. A warning never makes `flush()` throw. After a transient failure, or
802
+ in `report.uploadedValues`. Both counters include whole attributes, eligible media, attachments
803
+ and opt-in `recordFile` uploads. A warning never makes `flush()` throw. After a transient failure, or
580
804
  a refusal that is not about the value alone, uploads pause for 30 seconds, and a receiver without
581
805
  the upload route (a 404 that is not Hue's, a 405 or 501, or a 200 that is no reservation) is not
582
806
  asked again for 10 minutes, so an unavailable store or another OTLP receiver meets no retry storm.
@@ -592,8 +816,9 @@ is held while nothing else is. While values can be uploaded, helpers encode cont
592
816
  upload limit and 1,048,576 values, rather than cutting it to the value limit as they record it.
593
817
 
594
818
  Nothing is uploaded with `captureContent: false` or a setup credential (`hue_setup_…`), nor from
595
- log records (Hue does not read a log record's uploaded values yet), links, resources
596
- or in-progress placeholders. Span events upload only through explicit `recordFile({ upload: true, ... })`;
819
+ links, resources, log records not correlated with a span or in-progress placeholders; a log record
820
+ uploads only its files and the attachments in its body (see
821
+ [Automatically captured media](#automatically-captured-media)). Span events upload only through explicit `recordFile({ upload: true, ... })`;
597
822
  other event values are cut as before. `redact` and the credential scrub of
598
823
  tool definitions and recorded requests run on the whole value before it is hashed, so a value they
599
824
  rewrite is uploaded as rewritten.
@@ -0,0 +1,82 @@
1
+ /** How deep in a value an attachment's part may sit and still be uploaded: Hue reads media parts
2
+ * within ten levels of the value's root. */
3
+ export declare const MAX_ATTACHMENT_DEPTH = 10;
4
+ /** Options for {@link Attachment}. Give exactly one of `data` and `path`. */
5
+ export interface AttachmentOptions {
6
+ /** The file's bytes (a `Buffer`, typed array or `ArrayBuffer`), or a string holding them as
7
+ * base64 or a `data:` URL. */
8
+ data?: ArrayBufferView | ArrayBuffer | string;
9
+ /** A file to read when the attachment is created (Node.js file system). */
10
+ path?: string;
11
+ /** The file's media type, such as `image/png` or `application/pdf`. */
12
+ contentType: string;
13
+ /** The file name, recorded when `captureContent` is true. */
14
+ name?: string;
15
+ }
16
+ /** What an attachment holds, read by the client and transport only. */
17
+ export interface AttachmentState {
18
+ /** The file's bytes; undefined for an attachment that could not be read or is invalid. */
19
+ bytes?: Uint8Array;
20
+ sha256?: string;
21
+ contentType: string;
22
+ /** The name, when it is a valid media name. */
23
+ name?: string;
24
+ /** A name was given and is not a valid media name: it is omitted and counted. */
25
+ invalidName: boolean;
26
+ }
27
+ /**
28
+ * A file placed in a recorded value, the equivalent of Langfuse's `LangfuseMedia` and
29
+ * Braintrust's `Attachment`. Put it anywhere in a `withSpan()` input or output, in `model()`
30
+ * messages, in a tool's arguments or result, in `recordMessages()` messages or in a span
31
+ * attribute passed to `withSpan()`. Hue records it at that position as a GenAI `blob` part that
32
+ * names the file by digest (`{ type: "blob", mime_type, modality, sha256, size, name }`) and
33
+ * uploads its bytes during export, listed under the record's `hue.blobs`.
34
+ *
35
+ * The bytes are read and hashed when the attachment is created; `data` and `path` are limited to
36
+ * 25 MiB. Creating one never throws: an attachment without exactly one of `data` and `path`, with
37
+ * unreadable data, larger data or an invalid `contentType` records `null` where it is placed and
38
+ * counts an instrumentation failure; an invalid `name` alone is omitted and counted.
39
+ */
40
+ export declare class Attachment {
41
+ /** The media type given. */
42
+ readonly contentType: string;
43
+ /** The name given, when it is a valid media name. */
44
+ readonly name: string | undefined;
45
+ /** Lowercase hex SHA-256 of the bytes; undefined for an invalid attachment. */
46
+ readonly sha256: string | undefined;
47
+ /** Size of the bytes; undefined for an invalid attachment. */
48
+ readonly size: number | undefined;
49
+ constructor(options: AttachmentOptions);
50
+ }
51
+ /** The modality a media type implies, as Hue reads a part's media type. */
52
+ export declare function modalityOf(contentType: string): string;
53
+ /** An attachment's digest-form GenAI part, the name only with content capture; undefined for an
54
+ * invalid attachment. */
55
+ export declare function attachmentPart(state: AttachmentState, captureContent: boolean): Record<string, unknown> | undefined;
56
+ /** One attachment as a value is encoded: its state, its part (undefined when invalid), and the
57
+ * JSON pointer and depth of its position. */
58
+ export interface PlacedAttachment {
59
+ state: AttachmentState;
60
+ part?: Record<string, unknown>;
61
+ pointer: string;
62
+ depth: number;
63
+ }
64
+ /** Whether a value holds an attachment anywhere, within the encoder's bounds. */
65
+ export declare function holdsAttachment(value: unknown): boolean;
66
+ /** A JSON pointer's reference token. */
67
+ export declare function pointerSegment(segment: string): string;
68
+ /** The digest parts in `value` (within {@link MAX_ATTACHMENT_DEPTH}) that are the `files`' own
69
+ * parts: an object with `type` `blob`, no `content`, and the file's `sha256`, `size` and
70
+ * `mime_type`. Each found part takes one file of that digest, in the value's order; a part's
71
+ * `name`, when valid, is the one it carries. */
72
+ export declare function locateParts<T extends {
73
+ digest: {
74
+ sha256: string;
75
+ size: number;
76
+ };
77
+ contentType: string;
78
+ }>(value: unknown, files: readonly T[]): {
79
+ file: T;
80
+ pointer: string;
81
+ name?: string;
82
+ }[];