@hue-run/sdk 0.18.2 → 0.19.1

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.
@@ -86,12 +86,15 @@ export declare function readObservation(value: unknown): LangfuseObservation | u
86
86
  /** A v1 trace from an API answer, or undefined when it lacks an id or timestamp. */
87
87
  export declare function readTrace(value: unknown): LangfuseTrace | undefined;
88
88
  /** An answer read whole: its status and body, or `tooLarge` for a body past the bound. */
89
- type Answer = {
89
+ type Answer = ({
90
90
  status: number;
91
91
  body: Buffer;
92
92
  } | {
93
93
  status: number;
94
94
  tooLarge: true;
95
+ }) & {
96
+ /** A redirect's `Location`, which no request follows itself. */
97
+ location?: string;
95
98
  };
96
99
  /** Reads one Langfuse project through its public API with Basic authentication. */
97
100
  export declare class LangfuseApi {
@@ -105,9 +108,10 @@ export declare class LangfuseApi {
105
108
  /**
106
109
  * One GET with retries: a rate limit (HTTP 429) is waited out for as long as its `Retry-After`
107
110
  * asks (up to 15 minutes, ten times in a row), and a connection failure, a timeout or a 5xx is
108
- * tried again after 1, 2, 4, 8 and 16 seconds (or its `Retry-After`, up to a minute). An
109
- * authenticated request never follows a redirect, so the keys go only to the configured host;
110
- * `authenticated` is false for a presigned media download, which carries no Langfuse keys.
111
+ * tried again after 1, 2, 4, 8 and 16 seconds (or its `Retry-After`, up to a minute). No
112
+ * redirect is followed (its `Location` is answered), so the keys go only to the configured host
113
+ * and a media download reaches only links {@link LangfuseApi.media} checked; `authenticated` is
114
+ * false for a presigned media download, which carries no Langfuse keys.
111
115
  */
112
116
  get(url: URL, authenticated?: boolean, maxBytes?: number, timeoutMs?: number): Promise<Answer>;
113
117
  /** A GET of `path` under the API, answered with JSON; refusals of the keys are fatal. */
@@ -131,10 +135,22 @@ export declare class LangfuseApi {
131
135
  /**
132
136
  * A media file's bytes through `GET /api/public/media/{id}` and the download link it answers
133
137
  * (fetched without the Langfuse keys): `missing` when Langfuse holds no bytes for it (no record,
134
- * no link, or a link whose object is gone), `tooLarge` past {@link MAX_FILE_DATA_BYTES}.
138
+ * no link, or a link whose object is gone), `tooLarge` past {@link MAX_FILE_DATA_BYTES}. The link
139
+ * and each redirect from it must pass {@link refusedDownload}; one that does not fails the file.
135
140
  */
136
141
  media(id: string): Promise<Uint8Array | "missing" | "tooLarge">;
137
142
  }
143
+ /** Whether an IP address is one {@link INTERNAL_ADDRESSES} holds; false for anything else. */
144
+ export declare function isInternalAddress(address: string): boolean;
145
+ /**
146
+ * Why a media download link is refused, or undefined when it may be fetched: it must be HTTPS
147
+ * (plain HTTP only to this machine), at most 8192 characters, without credentials or a fragment,
148
+ * and name a host whose every address is public, unless it names the configured Langfuse host or
149
+ * that host is on this machine. Langfuse chooses the link, so this keeps a hostile or compromised
150
+ * deployment from sending requests to this machine's network. The name's lookup gives up after a
151
+ * minute or once `signal` stops the import.
152
+ */
153
+ export declare function refusedDownload(url: URL, langfuse: URL, signal: AbortSignal): Promise<string | undefined>;
138
154
  /** The OpenTelemetry trace id of a Langfuse trace: its own id when it is one (32 lowercase hex
139
155
  * digits, as the Langfuse OpenTelemetry SDKs write), otherwise one derived from it by SHA-256, so
140
156
  * the same trace always takes the same id. */
@@ -1,6 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { lookup } from "node:dns/promises";
3
+ import { BlockList, isIP } from "node:net";
2
4
  import { SpanKind, SpanStatusCode, TraceFlags, } from "@opentelemetry/api";
3
- import { MAX_CONTENT_BYTES, MAX_FILE_DATA_BYTES } from "../config.js";
5
+ import { MAX_CONTENT_BYTES, MAX_FILE_DATA_BYTES, isLoopbackHost } from "../config.js";
4
6
  import { BLOBS_KEY, MAX_BLOB_ENTRIES, blobEntry, newTally, offloadAttributes, } from "../blobs.js";
5
7
  import { digestPart, isMediaContentType, mayInlineFiles, mediaIn, placedMediaText, readerParses, } from "../inline-files.js";
6
8
  import { sdkVersion } from "../version.js";
@@ -16,6 +18,10 @@ import { sdkVersion } from "../version.js";
16
18
  const REQUEST_TIMEOUT_MS = 60_000;
17
19
  /** A media file download's deadline: up to 25 MiB over a slow link. */
18
20
  const DOWNLOAD_TIMEOUT_MS = 300_000;
21
+ /** Redirects a media download follows, each to a link checked as the first one was. */
22
+ const MAX_DOWNLOAD_REDIRECTS = 5;
23
+ /** The longest media download link accepted, as the Python SDK's media client accepts. */
24
+ const MAX_LINK_LENGTH = 8192;
19
25
  /** The largest answer read from the API. Langfuse Cloud answers at most 5 MB; a larger answer is
20
26
  * asked for again in smaller pages. */
21
27
  const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
@@ -210,9 +216,10 @@ export class LangfuseApi {
210
216
  /**
211
217
  * One GET with retries: a rate limit (HTTP 429) is waited out for as long as its `Retry-After`
212
218
  * asks (up to 15 minutes, ten times in a row), and a connection failure, a timeout or a 5xx is
213
- * tried again after 1, 2, 4, 8 and 16 seconds (or its `Retry-After`, up to a minute). An
214
- * authenticated request never follows a redirect, so the keys go only to the configured host;
215
- * `authenticated` is false for a presigned media download, which carries no Langfuse keys.
219
+ * tried again after 1, 2, 4, 8 and 16 seconds (or its `Retry-After`, up to a minute). No
220
+ * redirect is followed (its `Location` is answered), so the keys go only to the configured host
221
+ * and a media download reaches only links {@link LangfuseApi.media} checked; `authenticated` is
222
+ * false for a presigned media download, which carries no Langfuse keys.
216
223
  */
217
224
  async get(url, authenticated = true, maxBytes = MAX_RESPONSE_BYTES, timeoutMs = REQUEST_TIMEOUT_MS) {
218
225
  let failures = 0;
@@ -227,9 +234,7 @@ export class LangfuseApi {
227
234
  "user-agent": `hue-sdk-typescript/${sdkVersion} hue-import`,
228
235
  ...(authenticated ? { authorization: this.authorization } : {}),
229
236
  },
230
- // The keys go only to the configured host; a presigned download carries none and may
231
- // be redirected by its store.
232
- redirect: authenticated ? "manual" : "follow",
237
+ redirect: "manual",
233
238
  signal: AbortSignal.any([this.connection.signal, AbortSignal.timeout(timeoutMs)]),
234
239
  });
235
240
  }
@@ -258,6 +263,11 @@ export class LangfuseApi {
258
263
  failures++;
259
264
  continue;
260
265
  }
266
+ const location = status >= 300 && status < 400 ? response.headers.get("location") : null;
267
+ if (location !== null) {
268
+ await response.body?.cancel().catch(() => undefined);
269
+ return { status, body: Buffer.alloc(0), location };
270
+ }
261
271
  let body;
262
272
  try {
263
273
  body = await readBounded(response, maxBytes);
@@ -478,7 +488,8 @@ export class LangfuseApi {
478
488
  /**
479
489
  * A media file's bytes through `GET /api/public/media/{id}` and the download link it answers
480
490
  * (fetched without the Langfuse keys): `missing` when Langfuse holds no bytes for it (no record,
481
- * no link, or a link whose object is gone), `tooLarge` past {@link MAX_FILE_DATA_BYTES}.
491
+ * no link, or a link whose object is gone), `tooLarge` past {@link MAX_FILE_DATA_BYTES}. The link
492
+ * and each redirect from it must pass {@link refusedDownload}; one that does not fails the file.
482
493
  */
483
494
  async media(id) {
484
495
  const answer = await this.json(`/api/public/media/${encodeURIComponent(id)}`, {});
@@ -499,16 +510,112 @@ export class LangfuseApi {
499
510
  }
500
511
  if (url.protocol !== "https:" && url.protocol !== "http:")
501
512
  return "missing";
502
- const download = await this.get(url, false, MAX_FILE_DATA_BYTES, DOWNLOAD_TIMEOUT_MS);
503
- if ("tooLarge" in download)
504
- return download.status === 200 ? "tooLarge" : "missing";
505
- if (download.status === 200)
506
- return new Uint8Array(download.body);
507
- if ([403, 404, 410].includes(download.status))
508
- return "missing";
509
- throw new LangfuseError(`The media download answered HTTP ${download.status}`);
513
+ const langfuse = new URL(this.connection.baseUrl);
514
+ for (let redirects = 0;; redirects++) {
515
+ const refused = await refusedDownload(url, langfuse, this.connection.signal);
516
+ this.stopped();
517
+ if (refused)
518
+ throw new LangfuseError(`Langfuse named a media download link that ${refused}`);
519
+ const download = await this.get(url, false, MAX_FILE_DATA_BYTES, DOWNLOAD_TIMEOUT_MS);
520
+ if (download.location !== undefined) {
521
+ if (redirects === MAX_DOWNLOAD_REDIRECTS)
522
+ throw new LangfuseError("The media download redirected too many times");
523
+ try {
524
+ url = new URL(download.location, url);
525
+ }
526
+ catch {
527
+ throw new LangfuseError("The media download redirected to something other than a URL");
528
+ }
529
+ continue;
530
+ }
531
+ if ("tooLarge" in download)
532
+ return download.status === 200 ? "tooLarge" : "missing";
533
+ if (download.status === 200)
534
+ return new Uint8Array(download.body);
535
+ if ([403, 404, 410].includes(download.status))
536
+ return "missing";
537
+ throw new LangfuseError(`The media download answered HTTP ${download.status}`);
538
+ }
510
539
  }
511
540
  }
541
+ /** Addresses a media download may not reach: this machine, private networks, link-local
542
+ * (cloud metadata), shared, benchmarking, multicast and reserved ranges. IPv4-mapped IPv6
543
+ * addresses are checked as their IPv4 address. */
544
+ const INTERNAL_ADDRESSES = new BlockList();
545
+ for (const [network, prefix] of [
546
+ ["0.0.0.0", 8],
547
+ ["10.0.0.0", 8],
548
+ ["100.64.0.0", 10],
549
+ ["127.0.0.0", 8],
550
+ ["169.254.0.0", 16],
551
+ ["172.16.0.0", 12],
552
+ ["192.0.0.0", 24],
553
+ ["192.168.0.0", 16],
554
+ ["198.18.0.0", 15],
555
+ ["224.0.0.0", 3],
556
+ ])
557
+ INTERNAL_ADDRESSES.addSubnet(network, prefix, "ipv4");
558
+ for (const [network, prefix] of [
559
+ ["::", 128],
560
+ ["::1", 128],
561
+ ["64:ff9b::", 96],
562
+ ["fc00::", 7],
563
+ ["fe80::", 10],
564
+ ["fec0::", 10],
565
+ ["ff00::", 8],
566
+ ])
567
+ INTERNAL_ADDRESSES.addSubnet(network, prefix, "ipv6");
568
+ /** Whether an IP address is one {@link INTERNAL_ADDRESSES} holds; false for anything else. */
569
+ export function isInternalAddress(address) {
570
+ const version = isIP(address);
571
+ return version !== 0 && INTERNAL_ADDRESSES.check(address, version === 4 ? "ipv4" : "ipv6");
572
+ }
573
+ /**
574
+ * Why a media download link is refused, or undefined when it may be fetched: it must be HTTPS
575
+ * (plain HTTP only to this machine), at most 8192 characters, without credentials or a fragment,
576
+ * and name a host whose every address is public, unless it names the configured Langfuse host or
577
+ * that host is on this machine. Langfuse chooses the link, so this keeps a hostile or compromised
578
+ * deployment from sending requests to this machine's network. The name's lookup gives up after a
579
+ * minute or once `signal` stops the import.
580
+ */
581
+ export async function refusedDownload(url, langfuse, signal) {
582
+ if (url.href.length > MAX_LINK_LENGTH)
583
+ return "is too long";
584
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname)))
585
+ return "does not use https";
586
+ if (url.username || url.password || url.hash)
587
+ return "carries credentials or a fragment";
588
+ if (isLoopbackHost(langfuse.hostname) || url.hostname === langfuse.hostname)
589
+ return undefined;
590
+ const host = url.hostname.replace(/^\[|\]$/gu, "");
591
+ const named = host === "localhost" || host.endsWith(".localhost");
592
+ // shortcut: the name is resolved apart from the request's own lookup, so a host that answers a
593
+ // second lookup differently (DNS rebinding) is not caught; pin the lookup if fetch gains a hook.
594
+ let addresses;
595
+ try {
596
+ addresses =
597
+ named || isIP(host)
598
+ ? [named ? "127.0.0.1" : host]
599
+ : await resolved(host, AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]));
600
+ }
601
+ catch {
602
+ return "names a host that could not be resolved";
603
+ }
604
+ if (addresses.length === 0 || addresses.some(isInternalAddress))
605
+ return "names an address on this machine or a private network";
606
+ return undefined;
607
+ }
608
+ /** Every address `host` resolves to; rejects once `signal` aborts, since a lookup cannot be
609
+ * cancelled. */
610
+ function resolved(host, signal) {
611
+ return new Promise((resolve, reject) => {
612
+ const stop = () => reject(new Error("The lookup was abandoned"));
613
+ if (signal.aborted)
614
+ return stop();
615
+ signal.addEventListener("abort", stop, { once: true });
616
+ lookup(host, { all: true }).then((entries) => resolve(entries.map((entry) => entry.address)), reject);
617
+ });
618
+ }
512
619
  // ---------------------------------------------------------------------------------------------
513
620
  // Conversion
514
621
  // ---------------------------------------------------------------------------------------------
package/dist/client.d.ts CHANGED
@@ -51,8 +51,9 @@ export declare class HueClient {
51
51
  private ownedProviders?;
52
52
  private closed;
53
53
  private shutdownPromise?;
54
- private flushPromise?;
55
- private safeFlushPromise?;
54
+ /** The latest flush while it still sends its records (`sent`, which the next flush's send
55
+ * follows), and the safe result that joined it. */
56
+ private sending?;
56
57
  private safeShutdownPromise?;
57
58
  constructor(options: HueOptions | ExistingHueProviders);
58
59
  /**
@@ -206,22 +207,25 @@ export declare class HueClient {
206
207
  /**
207
208
  * Safe for finally blocks; preserves the application's result or original exception. When its
208
209
  * budget runs out while the client's own transport holds records for a receiver that limited
209
- * the rate, the hold ends: those records are reported lost to the rate limit, and the hold no
210
- * longer keeps the process running.
210
+ * the rate, or still uploads values its records listed, the hold and the uploads end: those
211
+ * records are reported lost to the rate limit, the uploads counted as failed (their records keep
212
+ * their entries), and neither keeps the process running.
211
213
  */
212
214
  shutdownSafe(options?: SafeLifecycleOptions): Promise<SafeLifecycleResult>;
213
215
  private safeLifecycle;
214
216
  /**
215
- * Drains the trace and log providers and waits for Hue's acknowledgements. Each caller gets a
216
- * fresh serialized drain that includes records emitted before its call.
217
+ * Drains the trace and log providers and waits for Hue's acknowledgements, then for the uploads
218
+ * queued so far. Each caller gets a fresh drain that includes records emitted before its call;
219
+ * its records are sent after an earlier drain's records, never after that drain's uploads.
217
220
  *
218
221
  * @throws HueExportError when this drain observed a new rejection, delivery failure, drop or
219
222
  * invalid record; its `issues` and `report` are sanitized counts, never server text.
220
223
  */
221
224
  flush(): Promise<ExportReport>;
222
- private flushOnce;
225
+ private flushProviders;
223
226
  /**
224
227
  * Flushes, then shuts down the providers this client owns; borrowed providers are left running.
228
+ * Like {@link flush}, it waits for queued uploads after the records are sent.
225
229
  * Idempotent: later calls return the same promise, and later helper calls only run their callbacks.
226
230
  *
227
231
  * @throws HueExportError when the final flush observed new failures; owned providers are still released.
package/dist/client.js CHANGED
@@ -219,8 +219,9 @@ export class HueClient {
219
219
  ownedProviders;
220
220
  closed = false;
221
221
  shutdownPromise;
222
- flushPromise;
223
- safeFlushPromise;
222
+ /** The latest flush while it still sends its records (`sent`, which the next flush's send
223
+ * follows), and the safe result that joined it. */
224
+ sending;
224
225
  safeShutdownPromise;
225
226
  constructor(options) {
226
227
  if ("transport" in options) {
@@ -1065,24 +1066,25 @@ export class HueClient {
1065
1066
  }
1066
1067
  /** Production lifecycle path: never rejects; timeouts do not cancel borrowed provider work. */
1067
1068
  flushSafe(options = {}) {
1068
- if (this.safeFlushPromise)
1069
- return this.safeFlushPromise;
1070
- const work = this.flushPromise ?? this.flush();
1069
+ // A flush still sending its records is joined, so repeated timeouts do not accumulate
1070
+ // promises against a hung borrowed provider. Once it sent them, a new flush sends the
1071
+ // caller's own records instead of waiting on the earlier flush's uploads.
1072
+ if (this.sending?.safe)
1073
+ return this.sending.safe;
1074
+ const work = this.sending?.work ?? this.flush();
1075
+ // An invalid budget returns before the result awaits the flush: its failure is still handled.
1076
+ void work.catch(() => { });
1071
1077
  const result = this.safeLifecycle(() => work, options);
1072
- this.safeFlushPromise = result;
1073
- const clear = () => {
1074
- this.safeFlushPromise = undefined;
1075
- };
1076
- // Retain the timed-out result until its underlying drain settles. Repeated
1077
- // timeouts must not accumulate promises against a hung borrowed provider.
1078
- void work.then(clear, clear);
1078
+ if (this.sending?.work === work)
1079
+ this.sending.safe = result;
1079
1080
  return result;
1080
1081
  }
1081
1082
  /**
1082
1083
  * Safe for finally blocks; preserves the application's result or original exception. When its
1083
1084
  * budget runs out while the client's own transport holds records for a receiver that limited
1084
- * the rate, the hold ends: those records are reported lost to the rate limit, and the hold no
1085
- * longer keeps the process running.
1085
+ * the rate, or still uploads values its records listed, the hold and the uploads end: those
1086
+ * records are reported lost to the rate limit, the uploads counted as failed (their records keep
1087
+ * their entries), and neither keeps the process running.
1086
1088
  */
1087
1089
  shutdownSafe(options = {}) {
1088
1090
  if (this.safeShutdownPromise)
@@ -1130,8 +1132,9 @@ export class HueClient {
1130
1132
  }
1131
1133
  }
1132
1134
  /**
1133
- * Drains the trace and log providers and waits for Hue's acknowledgements. Each caller gets a
1134
- * fresh serialized drain that includes records emitted before its call.
1135
+ * Drains the trace and log providers and waits for Hue's acknowledgements, then for the uploads
1136
+ * queued so far. Each caller gets a fresh drain that includes records emitted before its call;
1137
+ * its records are sent after an earlier drain's records, never after that drain's uploads.
1135
1138
  *
1136
1139
  * @throws HueExportError when this drain observed a new rejection, delivery failure, drop or
1137
1140
  * invalid record; its `issues` and `report` are sanitized counts, never server text.
@@ -1139,25 +1142,31 @@ export class HueClient {
1139
1142
  flush() {
1140
1143
  // Each caller needs a drain after its own preceding span/log emissions.
1141
1144
  // Joining an earlier drain can acknowledge records that were not in its batch.
1145
+ // Only the sends are serialized: each flush then waits on its own for the uploads queued so
1146
+ // far, so a later flush's records never wait behind an earlier flush's uploads.
1142
1147
  const from = this.transport.getFailureSequence();
1143
- const drain = async () => {
1144
- const report = await this.flushOnce();
1148
+ const start = () => this.transport.flushRecords(() => this.flushProviders());
1149
+ const flushing = (this.sending?.sent ?? Promise.resolve()).then(start, start);
1150
+ const sent = flushing.then(({ sent }) => sent);
1151
+ const next = flushing
1152
+ .then(({ report }) => report)
1153
+ .then((report) => {
1145
1154
  if (this.transport.getFailureSequence() !== from)
1146
1155
  throw new HueExportError(this.transport
1147
1156
  .getIssues()
1148
1157
  .filter((issue) => issue.sequence > from && issue.kind !== "warning"), report);
1149
1158
  return report;
1150
- };
1151
- const next = (this.flushPromise ?? Promise.resolve()).then(drain, drain);
1152
- this.flushPromise = next;
1159
+ });
1160
+ const sending = { work: next, sent };
1161
+ this.sending = sending;
1153
1162
  const clear = () => {
1154
- if (this.flushPromise === next)
1155
- this.flushPromise = undefined;
1163
+ if (this.sending === sending)
1164
+ this.sending = undefined;
1156
1165
  };
1157
- void next.then(clear, clear);
1166
+ void sent.then(clear, clear);
1158
1167
  return next;
1159
1168
  }
1160
- async flushOnce() {
1169
+ async flushProviders() {
1161
1170
  // Providers this client owns wait as long as an export may take, a rate-limit hold included,
1162
1171
  // instead of OpenTelemetry's 30 s; borrowed providers keep their own budget.
1163
1172
  const owned = this.ownedProviders;
@@ -1169,10 +1178,10 @@ export class HueClient {
1169
1178
  for (const [index, result] of results.entries())
1170
1179
  if (result.status === "rejected")
1171
1180
  this.transport.issue(index === 0 ? "traces" : "logs", "failed", 0, "OpenTelemetry provider flush failed");
1172
- return this.transport.flush();
1173
1181
  }
1174
1182
  /**
1175
1183
  * Flushes, then shuts down the providers this client owns; borrowed providers are left running.
1184
+ * Like {@link flush}, it waits for queued uploads after the records are sent.
1176
1185
  * Idempotent: later calls return the same promise, and later helper calls only run their callbacks.
1177
1186
  *
1178
1187
  * @throws HueExportError when the final flush observed new failures; owned providers are still released.
@@ -21,6 +21,19 @@ export declare function literalKeys(attributes: unknown): ReadonlySet<string>;
21
21
  /** Whether a span attribute is one whose value the SDK inspects for inline media: the attributes
22
22
  * Hue's reader reads files from. */
23
23
  export declare function isMediaKey(key: string): boolean;
24
+ /**
25
+ * The canonical media type of raw 16-bit PCM whose type declares its format, as Hue's reader reads
26
+ * one and names it: `audio/pcm;rate=<Hz>;channels=<n>` for little-endian `audio/pcm` and
27
+ * `audio/x-pcm`, `audio/L16;rate=<Hz>;channels=<n>` for big-endian `audio/L16`, `rate` a whole
28
+ * 8000 to 192000 and `channels` 1 or 2 (1 when absent). Undefined for any other type, raw PCM
29
+ * without a rate or with any other parameter included: Hue plays raw PCM only in a format its
30
+ * type declares.
31
+ */
32
+ export declare function pcmMediaType(mediaType: string): string | undefined;
33
+ /** The media type a `data:` URL's header (between `data:` and its comma) gives its file: raw PCM
34
+ * whose parameters declare its format as its canonical type (`pcmMediaType`), any other type
35
+ * without its parameters. Empty when the header names no type. */
36
+ export declare function headerMediaType(header: string): string;
24
37
  /** An inline file's bytes, whether they were decoded from the part's text (base64 or
25
38
  * percent-escapes) rather than taken as its UTF-8, and the media type its `data:` URL names. */
26
39
  export interface InlineFileBytes {
@@ -124,10 +137,12 @@ export interface MediaValue {
124
137
  export declare function mediaIn(value: unknown, source?: string): MediaValue;
125
138
  /**
126
139
  * An attribute's recorded text read for files as Hue's reader reads that attribute: JSON by its
127
- * convention; metadata as JSON when it reads as an object or array, otherwise as the text itself;
128
- * an OpenLLMetry content as the parts list it encodes (any other content stays text); an
129
- * OpenInference image URL as the text itself. Undefined for text that reading finds nothing in.
130
- * Throws for JSON that does not parse or nests too deep.
140
+ * convention, text that does not parse as the text itself (a Langfuse SDK records a string input
141
+ * or output as it is, so a whole `data:` URL is a file); metadata as JSON when it reads as an
142
+ * object or array, otherwise as the text itself; an OpenLLMetry content as the parts list it
143
+ * encodes (any other content stays text); an OpenInference image URL as the text itself.
144
+ * Undefined for text that reading finds nothing in. Throws for metadata or parts JSON that does
145
+ * not parse.
131
146
  */
132
147
  export declare function readMediaText(key: string, text: string): MediaValue | undefined;
133
148
  /** The recorded value's text once its files are placed: its JSON text as recorded with only the
@@ -151,6 +166,8 @@ export declare function keepsPrefix(file: InlineFilePart, textual: boolean, deco
151
166
  * `client.media.resolve`, write in a `blob` or `media` part for every media type), anything else
152
167
  * as its UTF-8. */
153
168
  export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
169
+ /** The type a file is reserved and listed under: the type its part declares, else its `data:`
170
+ * URL's, raw PCM whose type declares its format in its canonical form (`pcmMediaType`). */
154
171
  export declare function mediaContentType(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded" | "urlMediaType">): string;
155
172
  export interface FileDigest {
156
173
  sha256: string;
@@ -102,6 +102,74 @@ function base64Header(header) {
102
102
  return semicolon !== -1 && `${header.slice(semicolon)};`.includes(";base64;");
103
103
  }
104
104
  const hexPair = /^[0-9A-Fa-f]{2}$/;
105
+ /** Raw 16-bit PCM media types and the byte order of their samples, as Hue's reader names them. */
106
+ const pcmEndian = new Map([
107
+ ["audio/l16", "big"],
108
+ ["audio/pcm", "little"],
109
+ ["audio/x-pcm", "little"],
110
+ ]);
111
+ /** One media type parameter (`name=value`, the value a token or a quoted string) or an empty one,
112
+ * then the `;` that ends it or the end of the type (RFC 9110 section 8.3.1). */
113
+ const pcmParameter = /[ \t]*(?:([!#$%&'*+.^_`|~0-9A-Za-z-]+)[ \t]*=[ \t]*([!#$%&'*+.^_`|~0-9A-Za-z-]+|"(?:[^"\\]|\\.)*")[ \t]*)?(;|$)/y;
114
+ /** A whole number written in decimal digits alone, within `[min, max]`. */
115
+ function wholeIn(value, min, max) {
116
+ if (value === undefined || !/^\d{1,7}$/.test(value))
117
+ return undefined;
118
+ const number = Number(value);
119
+ return number >= min && number <= max ? number : undefined;
120
+ }
121
+ /**
122
+ * The canonical media type of raw 16-bit PCM whose type declares its format, as Hue's reader reads
123
+ * one and names it: `audio/pcm;rate=<Hz>;channels=<n>` for little-endian `audio/pcm` and
124
+ * `audio/x-pcm`, `audio/L16;rate=<Hz>;channels=<n>` for big-endian `audio/L16`, `rate` a whole
125
+ * 8000 to 192000 and `channels` 1 or 2 (1 when absent). Undefined for any other type, raw PCM
126
+ * without a rate or with any other parameter included: Hue plays raw PCM only in a format its
127
+ * type declares.
128
+ */
129
+ export function pcmMediaType(mediaType) {
130
+ if (mediaType.length > 255)
131
+ return undefined;
132
+ const endian = pcmEndian.get(mediaType.split(";", 1)[0].trim().toLowerCase());
133
+ if (!endian)
134
+ return undefined;
135
+ const parameters = new Map();
136
+ const start = mediaType.indexOf(";");
137
+ if (start !== -1) {
138
+ pcmParameter.lastIndex = start + 1;
139
+ for (;;) {
140
+ const match = pcmParameter.exec(mediaType);
141
+ if (!match)
142
+ return undefined;
143
+ if (match[1] !== undefined) {
144
+ const name = match[1].toLowerCase();
145
+ if (parameters.has(name))
146
+ return undefined;
147
+ const value = match[2];
148
+ parameters.set(name, value.startsWith('"') ? value.slice(1, -1).replace(/\\(.)/g, "$1") : value);
149
+ }
150
+ if (match[3] === "")
151
+ break;
152
+ }
153
+ }
154
+ for (const name of parameters.keys())
155
+ if (name !== "rate" && name !== "channels")
156
+ return undefined;
157
+ const rate = wholeIn(parameters.get("rate"), 8_000, 192_000);
158
+ const channels = parameters.has("channels") ? wholeIn(parameters.get("channels"), 1, 2) : 1;
159
+ if (rate === undefined || channels === undefined)
160
+ return undefined;
161
+ return `${endian === "big" ? "audio/L16" : "audio/pcm"};rate=${rate};channels=${channels}`;
162
+ }
163
+ /** The media type a `data:` URL's header (between `data:` and its comma) gives its file: raw PCM
164
+ * whose parameters declare its format as its canonical type (`pcmMediaType`), any other type
165
+ * without its parameters. Empty when the header names no type. */
166
+ export function headerMediaType(header) {
167
+ const type = header.split(";", 1)[0].trim();
168
+ if (!type)
169
+ return type;
170
+ const declared = header.endsWith(";base64") ? header.slice(0, -";base64".length) : header;
171
+ return pcmMediaType(declared) ?? type;
172
+ }
105
173
  /** RFC 2397 data without `;base64`: percent-escaped octets, other characters as UTF-8. Undefined
106
174
  * when a `%` does not start an escape. */
107
175
  function percentDecoded(payload) {
@@ -135,7 +203,7 @@ export function inlineFileBytes(content) {
135
203
  ? Buffer.from(payload, "base64")
136
204
  : undefined;
137
205
  const header = url?.[1];
138
- const mediaType = header === undefined ? undefined : header.split(";")[0].trim();
206
+ const mediaType = header === undefined ? undefined : headerMediaType(header);
139
207
  return {
140
208
  bytes: decoded ?? Buffer.from(content, "utf8"),
141
209
  decoded: decoded !== undefined,
@@ -143,6 +211,8 @@ export function inlineFileBytes(content) {
143
211
  ...(decoded !== undefined && !url ? { bareBase64: true } : {}),
144
212
  };
145
213
  }
214
+ /** OpenAI's `pcm16` audio: raw 16-bit little-endian PCM, 24 kHz mono, as Hue's reader types it. */
215
+ const PCM16 = "audio/pcm;rate=24000;channels=1";
146
216
  const audioTypes = {
147
217
  wav: "audio/wav",
148
218
  mp3: "audio/mpeg",
@@ -152,7 +222,7 @@ const audioTypes = {
152
222
  aac: "audio/aac",
153
223
  m4a: "audio/mp4",
154
224
  webm: "audio/webm",
155
- pcm16: "audio/pcm",
225
+ pcm16: PCM16,
156
226
  };
157
227
  const documentTypes = {
158
228
  pdf: "application/pdf",
@@ -342,16 +412,18 @@ export function contentPath(part, pointer = "") {
342
412
  }
343
413
  /** A media type as a `data:` URL declares one: `type/subtype`, no parameters. */
344
414
  const declaredMediaType = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+$/;
345
- /** The media type a whole string that is a `data:` URL declares, when it declares one: such a
346
- * string is a file wherever it sits. A web URL, or any other text, is not. */
415
+ /** The media type of a whole string that is a `data:` URL declaring one (`headerMediaType`): such
416
+ * a string is a file wherever it sits. A web URL, or any other text, is not. */
347
417
  function dataUrlType(text) {
348
418
  if (!text.startsWith("data:"))
349
419
  return undefined;
350
420
  const header = dataUrl.exec(text);
351
421
  if (!header)
352
422
  return undefined;
353
- const type = header[1].split(";")[0].trim();
354
- return declaredMediaType.test(type) && isMediaContentType(type) ? type : undefined;
423
+ const type = header[1].split(";", 1)[0].trim();
424
+ return declaredMediaType.test(type) && isMediaContentType(type)
425
+ ? headerMediaType(header[1])
426
+ : undefined;
355
427
  }
356
428
  /** The modality a media type implies, as Hue's reader names it. */
357
429
  export function modalityOf(mediaType) {
@@ -387,7 +459,7 @@ const audioFormats = {
387
459
  m4a: "audio/mp4",
388
460
  mp4: "audio/mp4",
389
461
  webm: "audio/webm",
390
- pcm16: "audio/pcm",
462
+ pcm16: PCM16,
391
463
  pcm: "audio/pcm",
392
464
  };
393
465
  function audioFormatMediaType(format) {
@@ -654,17 +726,27 @@ export function mediaIn(value, source) {
654
726
  }
655
727
  /**
656
728
  * An attribute's recorded text read for files as Hue's reader reads that attribute: JSON by its
657
- * convention; metadata as JSON when it reads as an object or array, otherwise as the text itself;
658
- * an OpenLLMetry content as the parts list it encodes (any other content stays text); an
659
- * OpenInference image URL as the text itself. Undefined for text that reading finds nothing in.
660
- * Throws for JSON that does not parse or nests too deep.
729
+ * convention, text that does not parse as the text itself (a Langfuse SDK records a string input
730
+ * or output as it is, so a whole `data:` URL is a file); metadata as JSON when it reads as an
731
+ * object or array, otherwise as the text itself; an OpenLLMetry content as the parts list it
732
+ * encodes (any other content stays text); an OpenInference image URL as the text itself.
733
+ * Undefined for text that reading finds nothing in. Throws for metadata or parts JSON that does
734
+ * not parse.
661
735
  */
662
736
  export function readMediaText(key, text) {
663
737
  /** JSON text read as Hue's reader reads it: text it keeps as text holds no file. */
664
738
  const json = (parsed) => (readerParses(parsed) ? mediaIn(parsed, text) : undefined);
665
739
  switch (readingOf(key)) {
666
- case "json":
667
- return json(JSON.parse(text));
740
+ case "json": {
741
+ let parsed;
742
+ try {
743
+ parsed = JSON.parse(text);
744
+ }
745
+ catch {
746
+ return mediaIn(text);
747
+ }
748
+ return json(parsed);
749
+ }
668
750
  case "metadata":
669
751
  return /^\s*[[{]/.test(text) ? json(JSON.parse(text)) : mediaIn(text);
670
752
  case "parts": {
@@ -729,8 +811,12 @@ export function mediaBytes(file) {
729
811
  return { bytes: Buffer.from(file.content, "base64"), decoded: true };
730
812
  return inlineFileBytes(file.content);
731
813
  }
814
+ /** The type a file is reserved and listed under: the type its part declares, else its `data:`
815
+ * URL's, raw PCM whose type declares its format in its canonical form (`pcmMediaType`). */
732
816
  export function mediaContentType(file, decoded) {
733
- return ((isMediaContentType(file.contentType) ? file.contentType : undefined) ??
817
+ return ((isMediaContentType(file.contentType)
818
+ ? (pcmMediaType(file.contentType) ?? file.contentType)
819
+ : undefined) ??
734
820
  (isMediaContentType(decoded.urlMediaType) ? decoded.urlMediaType : undefined) ??
735
821
  (decoded.decoded ? "application/octet-stream" : "text/plain; charset=utf-8"));
736
822
  }