@hue-run/sdk 0.18.0 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLI.md 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.18.0`, `@opentelemetry/api@1.9.1` and
45
+ not a completed integration. TypeScript uses `@hue-run/sdk@0.18.2`, `@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
 
@@ -114,6 +114,7 @@ hue setup --origin http://127.0.0.1:PORT # isolated loopback tests only
114
114
  hue login # validate keys created in Hue and store them in ./.env.hue
115
115
  hue mcp install --client claude-code # write the Hue MCP configuration for a coding agent
116
116
  hue listen --subscription <id> --forward-to http://localhost:3000/slack/events # deliver events to a local bot
117
+ hue import langfuse --from 2026-01-01 --dry-run # count a Langfuse project's traces, then import them
117
118
  ```
118
119
 
119
120
  Hosted setup accepts HTTPS origins only. HTTP is accepted solely for `localhost`, `127.0.0.1` and
@@ -365,6 +366,127 @@ codes: `0` stopped, `1` Hue refused the credential, the subscription (unknown, r
365
366
  delivers to a request URL) or the pull itself (a redirect, another refusal or an answer without
366
367
  deliveries), `2` usage error, `130` interrupted twice.
367
368
 
369
+ ## Import traces from Langfuse
370
+
371
+ `hue import langfuse` copies a Langfuse project's traces into Hue, for a team moving from Langfuse.
372
+ It runs on your machine: it reads Langfuse's public API with the project's own keys and sends the
373
+ spans with your Hue project key through the SDK's exporter, so the Langfuse keys go only to Langfuse
374
+ and Hue never receives or stores them. The command is part of the `hue` executable and runs with
375
+ `npx` whatever language the application is written in; the Python SDK has no counterpart.
376
+
377
+ ```sh
378
+ # .env.langfuse, kept out of version control:
379
+ # LANGFUSE_PUBLIC_KEY=pk-lf-...
380
+ # LANGFUSE_SECRET_KEY=sk-lf-...
381
+ # LANGFUSE_BASE_URL=https://cloud.langfuse.com
382
+ npx -y @hue-run/sdk@0.18.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
383
+ npx -y @hue-run/sdk@0.18.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
384
+ ```
385
+
386
+ `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` come from the environment or `--env-path`
387
+ (`--env-file` also works when the file exists), loaded first; a variable already set keeps its
388
+ value. `--langfuse-public-key` replaces the public key on the command line, but the secret key is
389
+ never taken from it, where other local processes and shell history can read it. The host is
390
+ `--langfuse-host`, then `LANGFUSE_BASE_URL`, then `LANGFUSE_HOST`, then `https://cloud.langfuse.com`:
391
+ use your region's host or your self-hosted URL, over HTTPS (plain HTTP only on this machine). A
392
+ request carrying the keys follows no redirect. `HUE_API_KEY` is the Hue project key the spans are
393
+ sent with, from the environment, the env file, or `./.env.hue` (where `hue login` stores it) when
394
+ neither sets it (a dry run, which needs no Hue key, reads no `./.env.hue` beside a named file); `--origin` (default `HUE_BASE_URL`, then `https://app.hue.run`) is checked with
395
+ `GET /api/v1/projects/current` before Langfuse is read. Keys are never printed.
396
+
397
+ **What is read.** The command first asks for `GET /api/public/v2/observations`, which Langfuse Cloud
398
+ and self-hosted Langfuse v4 answer. The traces are then the root observations
399
+ (`isRootObservation=true`) that started in the window, listed newest first in cursor pages, and each
400
+ trace's observations are read 100 a page with the field groups
401
+ `core,basic,time,io,metadata,model,usage,prompt,trace_context`; a page whose metadata values Langfuse
402
+ cut at 200 characters is read again with those keys expanded. A deployment that answers `404` there
403
+ (self-hosted Langfuse v3) is read through `GET /api/public/traces` (`fromTimestamp`, `toTimestamp`,
404
+ `orderBy=timestamp.desc`, `fields=core,io`) and `GET /api/public/observations?traceId=…`, in numbered
405
+ pages. An answer over 32 MiB, or a `413`, is asked for again in smaller pages.
406
+
407
+ **Selection.** `--from` and `--to` take an ISO 8601 date (midnight UTC) or a date and time with a time
408
+ zone. A trace is selected by when it started, its root observation's start time or a v1 trace's
409
+ timestamp, `--from` inclusive and `--to` exclusive. Without `--to` the window ends an hour before the
410
+ command starts, so a trace still running, or still arriving in Langfuse, is left for a later run
411
+ rather than sent half recorded. `--limit N` imports the N
412
+ newest traces; `--trace-id ID` (repeatable, up to 1,000) imports those traces only and is not combined
413
+ with the window or the limit.
414
+
415
+ **What Hue receives.** Each observation becomes a completed span as a Langfuse OpenTelemetry SDK
416
+ records it: its name, start, end (an observation without an end lasts no time) and parent, with
417
+ `langfuse.observation.type` (lower case), `langfuse.observation.input` and `.output` (a string as
418
+ recorded, any other value as its JSON), one `langfuse.observation.metadata.<key>` per metadata key,
419
+ `.level`, `.status_message`, `.model.name`, `.model.parameters`, `.usage_details`, `.cost_details`,
420
+ `.completion_start_time`, `.prompt.name` and `.prompt.version`, and the trace's
421
+ `langfuse.trace.name`, `langfuse.trace.tags`, `langfuse.user.id`, `langfuse.session.id`,
422
+ `langfuse.release`, `langfuse.version` and `langfuse.environment`. The model is also
423
+ `gen_ai.request.model`, and Langfuse's `input` and `output` usage counts are
424
+ `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens`. An observation at level `ERROR` is an
425
+ error span with its status message. A v1 trace is also a span of its own, named as the trace and
426
+ carrying `langfuse.trace.input`, `.output`, `.metadata.<key>`, `.tags` and `.public`; its observations
427
+ without a parent are its children, and it lasts from the earliest of its timestamp and their starts
428
+ to their last end. Spans carry `service.name` `langfuse` (`--service-name` sets another) and the
429
+ instrumentation scope `@hue-run/sdk/import/langfuse`.
430
+
431
+ **Ids and resuming.** A Langfuse id that already is an OpenTelemetry id (32 lowercase hex digits for
432
+ a trace, 16 for an observation, as Langfuse's OpenTelemetry SDKs write them) is kept; any other id,
433
+ such as an older SDK's UUID, becomes one derived from it with SHA-256. The original ids are kept as
434
+ `langfuse.trace.id` and `langfuse.observation.id`. Running the same command again sends the same
435
+ spans, and Hue keeps one copy of an exact replay of a completed span, so an interrupted or partly
436
+ failed import is resumed by running it again. Hue refuses a different payload for a span it already
437
+ holds: keep `--media`, `--no-content` and `--service-name` the same when resuming, and expect a span
438
+ whose Langfuse data changed after it was imported to be refused. For the same reason the import
439
+ sends nothing that depends on the run: a trace's root never carries `hue.sdk.dropped_records`, and
440
+ files and large values are stored before their span is sent. Leave out of the window any traces that
441
+ already reached Hue through OpenTelemetry.
442
+
443
+ **Export and content.** Spans go through the SDK's own exporter, as `createHue` sends them: batched,
444
+ compressed, retried and within the limits Hue advertises. Before a span is sent, its content is
445
+ stored as that exporter stores it, with the exporter's own placement: a file inline in an attribute
446
+ Hue reads files from (a `data:` URL, or the bytes of a provider part, that Langfuse kept in place) is
447
+ uploaded and placed in its digest form, and a text value still over Hue's inline limit (1 MiB) is
448
+ uploaded whole and replaced by its first 16 KiB, each listed in `hue.blobs` (a whole value also in
449
+ `hue.truncated`). Doing this first means a file or value that cannot be stored right now holds its
450
+ span back for the next run instead of being kept inline or cut; one over a limit (Hue's size limit,
451
+ or a span's 64 entries) is placed the same way on every run and counted in the summary. Content is
452
+ captured, the SDK's default; `--no-content` sends metadata only, as `captureContent: false` does (no
453
+ inputs, outputs, status messages or model parameters; metadata, model names and usage stay, and
454
+ nothing is uploaded). The import waits for its queue to be sent every 1,024 spans or 32 MiB, so its
455
+ memory stays bounded whatever the project's size.
456
+
457
+ **Media.** Langfuse's SDKs replace a file in an input, output or metadata value with a reference,
458
+ `@@@langfuseMedia:type=<media type>|id=<id>|source=<source>@@@`. Without `--media`, references are
459
+ sent as recorded, and Hue shows them as files held in Langfuse. With `--media`, each reference where
460
+ Hue reads a file (a whole string, or the content of a provider part such as an OpenAI `image_url` or
461
+ `input_audio`, an Anthropic `source` or a GenAI `blob`, no deeper than ten levels) is looked up with
462
+ `GET /api/public/media/{id}`. The file is downloaded from the link Langfuse answers (following its
463
+ store's redirects, without the Langfuse keys), uploaded under the span's trace through Hue's upload route (reservation, presigned
464
+ `PUT`, completion), and placed in the digest form the SDK writes for media it records: a whole string
465
+ becomes `{"type":"blob","mime_type":…,"modality":…,"sha256":…,"size":…}` and a provider part loses its
466
+ bytes and gains `sha256` and `size`, every other byte of the value's text kept, each file listed in
467
+ `hue.blobs` under `<attribute>#<JSON pointer>`. Each file is downloaded once per trace. A reference
468
+ stays as recorded when Langfuse holds no bytes for it, its file is over 25 MiB, it sits inside longer
469
+ text, or its span already lists 64 files. A file that cannot be downloaded or stored right now holds
470
+ its span back: the trace is reported, and the next run sends it.
471
+
472
+ **Rate limits and failures.** A `429` from Langfuse is waited out for as long as its `Retry-After`
473
+ asks, up to 15 minutes and ten times in a row; connection failures, timeouts and `5xx` answers are
474
+ retried after 1, 2, 4, 8 and 16 seconds. Langfuse Cloud limits each organization's requests per
475
+ minute by plan, so a large project imports at that pace: a request per page of traces and per page
476
+ of each trace's observations, and one per media file. A refusal of the Langfuse keys (`401` or `403`)
477
+ or of `HUE_API_KEY` stops the import before anything is sent; a trace Langfuse cannot answer for is
478
+ reported and the import goes on.
479
+
480
+ **Output and exit codes.** `--dry-run` reads and counts traces, spans and media references, needs no
481
+ Hue key and sends nothing. Without `--yes` the command asks for confirmation in a terminal (declining
482
+ exits `1`) and refuses to start without one. Progress is printed every 100 traces or 10 seconds, then
483
+ a summary of the traces imported, the spans sent and accepted, and the media files uploaded and left
484
+ in Langfuse; each trace not fully imported is named with the reason. Ctrl+C or `SIGTERM` stops
485
+ reading, sends what was read and exits `130`; a second Ctrl+C a second or more later exits `130` at
486
+ once, abandoning exports still in flight for the next run to send. Exit codes: `0` every selected
487
+ trace was imported, `1` a trace, span or file was not (or a key was refused), `2` usage error, `130`
488
+ stopped.
489
+
368
490
  ## Local state and conflicts
369
491
 
370
492
  Before its first network write, setup creates a lowercase UUIDv4 and a 32-byte random installation
@@ -469,7 +591,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
469
591
  # Set project to an existing supported fixture; use the same directory on resume.
470
592
  project=/absolute/path/to/supported-fixture
471
593
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
472
- --archive .artifacts/typescript/hue-run-sdk-0.18.0.tgz \
594
+ --archive .artifacts/typescript/hue-run-sdk-0.18.2.tgz \
473
595
  --origin https://STAGING_ORIGIN \
474
596
  --project "$project" --command setup \
475
597
  --evidence .context/setup-staging-before-claim.json
@@ -480,7 +602,7 @@ the private local handoff and finish the real browser claim, then reconcile the
480
602
 
481
603
  ```sh
482
604
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
483
- --archive .artifacts/typescript/hue-run-sdk-0.18.0.tgz \
605
+ --archive .artifacts/typescript/hue-run-sdk-0.18.2.tgz \
484
606
  --origin https://STAGING_ORIGIN \
485
607
  --project "$project" --command claim \
486
608
  --evidence .context/setup-staging-after-claim.json
@@ -522,10 +644,10 @@ otherwise. Never hand the agent Hue-native tools (`context.tools`, `context.mcp`
522
644
  production agent lacks. Pin the SDK, so the agent's repository gains no dependency:
523
645
 
524
646
  ```sh
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>
647
+ npx -y @hue-run/sdk@0.18.2 eval --case "Refund an eligible charge" --command "node agent.js"
648
+ npx -y @hue-run/sdk@0.18.2 eval --case https://app.hue.run/projects/demo/scenarios/<id> --command "python agent.py" --timeout 120 --baseline <run id>
649
+ npx -y @hue-run/sdk@0.18.2 eval --set "Billing regressions" --scorer-version <id> --command "node agent.js" --save-version
650
+ npx -y @hue-run/sdk@0.18.2 eval --worker --command "node agent.js" --agent-key support-agent --revision <new-agent-revision>
529
651
  ```
530
652
 
531
653
  One invocation creates one run holding every selected case; it prints the run URL
@@ -563,7 +685,7 @@ export default function runMyAgent(inputs: JsonValue, context: SimulationTargetC
563
685
  ```
564
686
 
565
687
  ```sh
566
- npx -y @hue-run/sdk@0.18.0 eval --case "Refund an eligible charge" ./hue-agent.ts --content
688
+ npx -y @hue-run/sdk@0.18.2 eval --case "Refund an eligible charge" ./hue-agent.ts --content
567
689
  ```
568
690
 
569
691
  `--scenario` remains an alias for `--case` for existing scripts. Pass one selection flag.
package/EVALUATIONS.md CHANGED
@@ -317,10 +317,13 @@ An uploaded file needs one of `outputContentTypes`, a well-formed name of 1–25
317
317
  (as JavaScript counts a string's `length`) without path separators or control characters, and 1
318
318
  byte to 25 MiB. Every file, and the case itself as it will be sent (its revision, key, values and
319
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
320
+ read, or a case Hue would refuse throws `TypeError` or `RangeError` without a request. A `path`
321
+ is hashed a slice at a time when it is checked and read again when it is uploaded, one file at a
322
+ time, so the write holds at most one file in memory and sends only the bytes its check hashed. A
323
+ file that changed in between, or can no longer be read, sends nothing: the write throws `TypeError`
324
+ and the case is not written. The reservation key names the version, the case's
325
+ `externalKey`, the role, the bytes, the name and the content type, so sending the same case again
326
+ reuses its artifacts and a corrected name or type 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
327
  the target as `context.files`; `org_template` and `evaluator_reference` files reach only scorers.
325
328
 
326
329
  Recorded messages copied into a case's inputs keep their large files as digest parts. Pin each file
package/README.md CHANGED
@@ -274,7 +274,8 @@ Each uploaded file is listed in the record's `hue.blobs`, keyed `<attribute>#<JS
274
274
  through the string, which no JSON pointer can address, so Hue matches it by its digest.
275
275
  - Text media over 64 KiB of decoded bytes in a provider part keeps a UTF-8-safe 16 KiB prefix in
276
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.
277
+ (`gen_ai.input.messages#/0/parts/1/content`). Smaller text and empty fields stay inline. A text
278
+ file a GenAI `blob`, Vertex `media` or AI SDK part holds as bare base64 takes the digest form.
278
279
 
279
280
  Only the file's own value, or the members of the part that held it, change: every other byte of the
280
281
  recorded text, its key order, number spellings, escapes and spacing included, is kept, in an encoded
@@ -286,9 +287,11 @@ Entries use the recorded media type and, when valid, the filename, name or title
286
287
  is nonblank after trimming, at most 256 UTF-16 code units and well-formed Unicode, with no C0
287
288
  characters (U+0000–U+001F) or DEL (U+007F). Invalid names are omitted from the entry without
288
289
  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.
290
+ contract are decoded before hashing. Other content is read as Hue's reader reads it, whatever its
291
+ media type: content that reads as base64 (a length divisible by four, base64 characters only, as
292
+ the GenAI conventions and `client.media.resolve` write a `blob` or `media` part's content) is the
293
+ file's base64 and is decoded, so `YWJj` hashes as `abc`; anything else is the file's own UTF-8
294
+ text. The digest matches `hue.recordFile`'s digest for the same bytes.
292
295
 
293
296
  A file that is not uploaded (the upload failed, the receiver has no upload route, or a budget ran
294
297
  out) keeps its digest without an entry in the recorded messages (`gen_ai.input.messages`,
@@ -309,13 +312,23 @@ listed under `hue.truncated`. While uploads are available, `recordMessages` reco
309
312
  the value limit when it fits the limit with its files as their digest.
310
313
 
311
314
  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
315
+ body's file contents included. Within those bounds, inspection parses the value and decodes each
316
+ file while the record is queued and again when it is exported. A span's own attribute past either
317
+ bound that is held whole for upload (it is over the value limit) has its media read in place when
318
+ it is exported, as a voice agent's audio usually needs: each string in it longer than 16 Ki code
319
+ units and written without escapes as strict base64 or a base64 `data:` URL is decoded, hashed and
320
+ uploaded a 1 MiB window at a time from the recorded text, never parsed or copied out of it, and a
321
+ file may be up to Hue's 1 GB upload limit. The scan that finds those strings also reads the value
322
+ a window at a time, between turns of the event loop, so no step of it stalls the application for
323
+ long; a percent-escaped `data:` URL is parsed with the JSON around it. The value is placed exactly as one within the bounds: the same
324
+ digest form, entries and fallbacks. The JSON around those strings, a file written with escapes or
325
+ inside a JSON-encoded string included, is parsed within the 8 Mi bound; a value past it uses the
326
+ ordinary value limit (it uploads whole). Each such value is read within that bound on its own, apart
327
+ from the record's 16 Mi allowance, while it stays held within the 128 MiB for values awaiting
328
+ upload. Log records keep the bounds. Resources, links and span
329
+ events never upload inline media: in their recorded messages, media up to 64 KiB stays verbatim
330
+ and larger media is replaced by its digest, as in 0.16, and log records that cannot upload (not
331
+ correlated with a span, or while uploads are unavailable) keep the same rule. With
319
332
  `captureContent: false`, nothing is inspected or uploaded.
320
333
 
321
334
  ### Attach a file anywhere
@@ -342,10 +355,19 @@ await hue.withSpan(
342
355
  ```
343
356
 
344
357
  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.
358
+ URL) and `path` (a file on the file system), a `contentType` and optionally a `name`. `data` is
359
+ read and hashed when the attachment is created and is limited to 25 MiB, the `recordFile` limit:
360
+ it is held in memory from then until export, within the 128 MiB budget that queued spans share for
361
+ values awaiting upload. A `path` names a regular file: anything else, such as a named pipe, is
362
+ refused without waiting on it. A `path` up to 25 MiB is read the same way. A larger regular file, up to
363
+ Hue's 1 GB upload limit, is not held: it is hashed a 1 MiB slice at a time when the attachment is
364
+ created and read again from disk when it is uploaded, each slice hashed as it is sent and the last
365
+ sent only once the file ended at the size and SHA-256 it was recorded with. Keep such a file in
366
+ place and unchanged until the client flushes: one that changed, is gone or is no longer a regular
367
+ file is not uploaded (the
368
+ request ends before its body is complete, and Hue's upload URL signs the size and SHA-256, so the
369
+ store refuses other bytes), and it counts in `report.uploadFallbacks`, its part keeping the digest
370
+ it was recorded with.
349
371
 
350
372
  At its position the attachment is recorded as a GenAI `blob` part that names the file by digest,
351
373
  `{"type":"blob","mime_type":"application/pdf","modality":"document","sha256":"<64 hex>","size":48213,"name":"contract.pdf"}`,
@@ -362,7 +384,11 @@ With `captureContent: false` or a setup credential no bytes leave the process: a
362
384
  attribute still records the part, without its name, and content values are not recorded at all. A
363
385
  part the redactor removes or rewrites is not uploaded. A part that cannot be uploaded keeps its
364
386
  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
387
+ value, where Hue does not read parts, or one in a value cut to the value limit. An attachment in a
388
+ value that is still over the value limit with its files as digest parts is uploaded and listed as
389
+ usual, and `client.media.fetch({ traceId, sha256 })` and Hue's span read reach it by digest; the
390
+ value itself uploads whole and keeps only its first 16 KiB inline, so readers do not place the file
391
+ at its position in the value. Creating an
366
392
  attachment never throws: one without exactly one of `data` and `path`, with unreadable or larger
367
393
  data or an invalid content type is recorded as `null` and counted as an instrumentation failure;
368
394
  an invalid name alone is omitted and counted.
@@ -385,9 +411,14 @@ await hue.withSpan("review contract", async () => {
385
411
  ```
386
412
 
387
413
  `upload` defaults to `false`, which hashes and measures `data` locally without exporting bytes.
388
- `data` is limited to 25 MiB. Larger data is omitted and counted as an instrumentation failure;
389
- pass `sha256` and `byteSize` when you already have them. Upload requires supplied bytes, content
390
- capture and an ordinary project key. It runs during export and adds a `hue.blobs` entry to the
414
+ `data` is limited to 25 MiB, as it is held in memory until it is uploaded. Larger data is omitted
415
+ and counted as an instrumentation failure; pass `sha256` and `byteSize` when you already have them.
416
+ Give `path` instead of `data` for a file on disk: up to 25 MiB it is read as `data` is, and a larger
417
+ regular file, up to Hue's 1 GB upload limit, is hashed a slice at a time now and, with `upload`,
418
+ read again from disk when it is uploaded, as an [`Attachment`](#attach-a-file-anywhere) given by
419
+ path is: it uploads only if it still has the size and SHA-256 it was recorded with. A file over
420
+ 1 GB, a path that is no regular file, or `data` and `path` together, is omitted and counted. Upload requires supplied bytes or a
421
+ path, content capture and an ordinary project key. It runs during export and adds a `hue.blobs` entry to the
391
422
  same span, keyed `event:hue.file:<sha256>`, while preserving the event. An export fits every
392
423
  span's events and entries before it uploads any of these files, then uploads the files of all its
393
424
  spans together, four at a time; a file whose event was redacted or shed is not uploaded.
@@ -480,7 +511,37 @@ the recorded model call again with the real image, document or audio. See
480
511
  | `LangfuseMedia` | `Attachment` | [`Attachment`](#attach-a-file-anywhere), at the same position |
481
512
  | `resolveMediaReferences({ obj, resolveWith: "base64DataUri" })` | | `client.media.resolve(obj, { traceId })` |
482
513
  | `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 |
514
+ | Media tokens (`@@@langfuseMedia:…@@@`) | `ExternalAttachment` | Left as recorded: Hue never fetches a remote URL or a file another service holds. [`hue import langfuse --media`](#import-from-langfuse) copies Langfuse's files with your keys |
515
+
516
+ ## Import from Langfuse
517
+
518
+ `hue import langfuse` copies a Langfuse project's history into Hue. It runs on your machine with
519
+ the Langfuse project's own keys (`LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, and
520
+ `LANGFUSE_BASE_URL` or `LANGFUSE_HOST`), which go only to Langfuse, and sends the spans with your
521
+ `HUE_API_KEY`:
522
+
523
+ ```sh
524
+ npx -y @hue-run/sdk@0.18.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --dry-run
525
+ npx -y @hue-run/sdk@0.18.2 import langfuse --env-path .env.langfuse --from 2026-01-01 --media
526
+ ```
527
+
528
+ - It reads Langfuse Cloud and self-hosted v4 through the Observations API v2, and self-hosted v3
529
+ through the v1 traces and observations API, newest first; `--from`, `--to`, `--limit` and
530
+ `--trace-id` select traces, and `--dry-run` counts them without sending anything.
531
+ - Each observation becomes the span a Langfuse OpenTelemetry SDK records, with the
532
+ `langfuse.observation.*` and `langfuse.trace.*` attributes Hue reads, the user, session, release
533
+ and environment, and the model and token counts as GenAI attributes. Hex ids are kept and other
534
+ ids derived from them, so running the same command again resumes an import without duplicates.
535
+ - Spans are exported by the SDK's transport with its content-capture default; `--no-content`
536
+ sends metadata only.
537
+ - With `--media`, files that Langfuse media references point to are downloaded and uploaded under
538
+ the trace in the [digest form](#automatically-captured-media) the SDK writes, listed in
539
+ `hue.blobs`; a reference Langfuse holds no bytes for stays as recorded.
540
+ - Langfuse's rate limits are waited out for as long as `Retry-After` asks.
541
+
542
+ The command has no Python counterpart; it runs with `npx` for an application in any language. See
543
+ [Import traces from Langfuse](CLI.md#import-traces-from-langfuse) for every option, the attribute
544
+ mapping, media handling, limits and exit codes.
484
545
 
485
546
  ## Vercel AI SDK 6
486
547
 
@@ -787,7 +848,9 @@ apart from their record, up to Hue's upload limit of 1,000,000,000 bytes:
787
848
 
788
849
  Hue stores the value under the trace, so the trace's retention, deletion and erasure cover it.
789
850
  Uploads run in the export, never on the application's thread: at most four at a time, each request
790
- within the export timeout, and all of one export's uploads within six export timeouts; a
851
+ within the export timeout, and all of one export's uploads within six export timeouts (60 seconds
852
+ at the default `timeoutMillis` of 10000), so a 1 GB file needs about 17 MB/s at the defaults;
853
+ raise `timeoutMillis` (up to 60000) for slower links. A
791
854
  transient failure is retried once. A rate-limited reservation (429) waits out its `Retry-After`
792
855
  within the export's 60-second hold. `flush()` and `shutdown()` wait for uploads like any export
793
856
  work, and a `shutdownSafe()` whose budget runs out ends the uploads in flight.
@@ -796,7 +859,8 @@ A value that cannot be uploaded (Hue or the store refused it or failed, the netw
796
859
  did, the receiver has no upload route, or the value is over 1 GB) is exported as before uploads
797
860
  existed: whole attributes keep a prefix up to the limit and are listed under `hue.truncated`;
798
861
  media parts keep their digest without a blob entry; failed opt-in `recordFile` uploads preserve
799
- the file event. The explicit helper limit is 25 MiB; whole attributes retain their 1 GB limit. Each
862
+ the file event. Bytes given in memory to `recordFile` or an `Attachment` are limited to 25 MiB; a
863
+ file given by path, a file read in place from a value and a whole attribute to 1 GB. Each
800
864
  export reports such values in a `warning` issue per cause, whose `count` is the values and
801
865
  `traceIds` their traces, and counts them in `report.uploadFallbacks`; successful uploads are counted
802
866
  in `report.uploadedValues`. Both counters include whole attributes, eligible media, attachments
@@ -1,12 +1,15 @@
1
+ import type { ByteSource } from "./large-media.js";
1
2
  /** How deep in a value an attachment's part may sit and still be uploaded: Hue reads media parts
2
3
  * within ten levels of the value's root. */
3
4
  export declare const MAX_ATTACHMENT_DEPTH = 10;
4
5
  /** Options for {@link Attachment}. Give exactly one of `data` and `path`. */
5
6
  export interface AttachmentOptions {
6
7
  /** The file's bytes (a `Buffer`, typed array or `ArrayBuffer`), or a string holding them as
7
- * base64 or a `data:` URL. */
8
+ * base64 or a `data:` URL; up to 25 MiB. */
8
9
  data?: ArrayBufferView | ArrayBuffer | string;
9
- /** A file to read when the attachment is created (Node.js file system). */
10
+ /** A file to read (Node.js file system): up to 25 MiB it is read when the attachment is created;
11
+ * a larger regular file, up to Hue's 1 GB upload limit, is hashed then and read again from disk
12
+ * when it is uploaded. */
10
13
  path?: string;
11
14
  /** The file's media type, such as `image/png` or `application/pdf`. */
12
15
  contentType: string;
@@ -15,15 +18,50 @@ export interface AttachmentOptions {
15
18
  }
16
19
  /** What an attachment holds, read by the client and transport only. */
17
20
  export interface AttachmentState {
18
- /** The file's bytes; undefined for an attachment that could not be read or is invalid. */
21
+ /** The file's bytes; undefined for a file read from disk when it is uploaded, and for an
22
+ * attachment that could not be read or is invalid. */
19
23
  bytes?: Uint8Array;
24
+ /** A file given by path over 25 MiB, read again from disk when it is uploaded. */
25
+ file?: PathFile;
20
26
  sha256?: string;
27
+ /** The file's size; undefined for an invalid attachment. */
28
+ size?: number;
21
29
  contentType: string;
22
30
  /** The name, when it is a valid media name. */
23
31
  name?: string;
24
32
  /** A name was given and is not a valid media name: it is omitted and counted. */
25
33
  invalidName: boolean;
26
34
  }
35
+ /** A regular file given by path over the in-memory limit, as it was hashed when it was recorded:
36
+ * its absolute path, size and SHA-256. Its bytes are read again from disk when it is uploaded. */
37
+ export interface PathFile {
38
+ path: string;
39
+ size: number;
40
+ sha256: string;
41
+ }
42
+ /**
43
+ * A regular file given by path: its bytes when it is no larger than 25 MiB, and otherwise, up to
44
+ * Hue's 1 GB upload limit, its size and SHA-256 from one reading pass a slice at a time, never
45
+ * holding it whole. Undefined for a file over its limit and for anything that is no regular file
46
+ * (a named pipe, a device), which is opened without waiting and never read; throws for one that
47
+ * cannot be opened.
48
+ */
49
+ export declare function readPath(path: string): {
50
+ bytes: Uint8Array;
51
+ } | {
52
+ file: PathFile;
53
+ } | undefined;
54
+ /**
55
+ * A file given by path as an upload body, read from disk a slice at a time. Each slice is hashed as
56
+ * it is read and sent once the next one is read; the last is sent only once the file ended at its
57
+ * recorded size with its recorded SHA-256. A file that changed since it was recorded, or can no
58
+ * longer be read, ends the read with an error before its body is complete and sets `changed`, so
59
+ * the upload is abandoned and the value is counted as not uploaded; so does a path that is no
60
+ * longer a regular file, which is opened without waiting (a named pipe in its place would wait for
61
+ * a writer) and never read. Hue's presigned PUT also signs the size and SHA-256, so the store
62
+ * refuses a body that differs.
63
+ */
64
+ export declare function fileSource(file: PathFile): ByteSource;
27
65
  /**
28
66
  * A file placed in a recorded value, the equivalent of Langfuse's `LangfuseMedia` and
29
67
  * Braintrust's `Attachment`. Put it anywhere in a `withSpan()` input or output, in `model()`
@@ -32,10 +70,16 @@ export interface AttachmentState {
32
70
  * names the file by digest (`{ type: "blob", mime_type, modality, sha256, size, name }`) and
33
71
  * uploads its bytes during export, listed under the record's `hue.blobs`.
34
72
  *
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.
73
+ * The bytes are read and hashed when the attachment is created; `data` is limited to 25 MiB, as
74
+ * the bytes are held in memory until they are uploaded. A `path` names a regular file, opened
75
+ * without waiting: anything else, such as a named pipe, is invalid. One up to 25 MiB is read then
76
+ * too; a larger regular file, up to Hue's 1 GB upload limit, is hashed then a slice at a time and read
77
+ * again from disk when it is uploaded, so it must stay in place and unchanged until the client
78
+ * flushes: a file whose size or SHA-256 then differs is not uploaded and counts as an upload
79
+ * fallback, its part keeping the digest it was recorded with. Creating one never throws: an
80
+ * attachment without exactly one of `data` and `path`, with unreadable data, larger data or an
81
+ * invalid `contentType` records `null` where it is placed and counts an instrumentation failure;
82
+ * an invalid `name` alone is omitted and counted.
39
83
  */
40
84
  export declare class Attachment {
41
85
  /** The media type given. */