@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.
- package/CLI.md +16 -12
- package/EVALUATIONS.md +1 -1
- package/README.md +110 -68
- package/dist/blobs.d.ts +116 -36
- package/dist/blobs.js +359 -162
- package/dist/cli/langfuse.d.ts +21 -5
- package/dist/cli/langfuse.js +123 -16
- package/dist/client.d.ts +11 -7
- package/dist/client.js +35 -26
- package/dist/inline-files.d.ts +21 -4
- package/dist/inline-files.js +100 -14
- package/dist/large-media.d.ts +15 -2
- package/dist/large-media.js +27 -16
- package/dist/transport.d.ts +38 -7
- package/dist/transport.js +240 -63
- package/dist/types.d.ts +5 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/cli/langfuse.d.ts
CHANGED
|
@@ -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).
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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. */
|
package/dist/cli/langfuse.js
CHANGED
|
@@ -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).
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
-
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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,
|
|
210
|
-
*
|
|
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
|
|
216
|
-
* fresh
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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.
|
|
1073
|
-
|
|
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,
|
|
1085
|
-
*
|
|
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
|
|
1134
|
-
* fresh
|
|
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
|
|
1144
|
-
|
|
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
|
|
1152
|
-
this.
|
|
1159
|
+
});
|
|
1160
|
+
const sending = { work: next, sent };
|
|
1161
|
+
this.sending = sending;
|
|
1153
1162
|
const clear = () => {
|
|
1154
|
-
if (this.
|
|
1155
|
-
this.
|
|
1163
|
+
if (this.sending === sending)
|
|
1164
|
+
this.sending = undefined;
|
|
1156
1165
|
};
|
|
1157
|
-
void
|
|
1166
|
+
void sent.then(clear, clear);
|
|
1158
1167
|
return next;
|
|
1159
1168
|
}
|
|
1160
|
-
async
|
|
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.
|
package/dist/inline-files.d.ts
CHANGED
|
@@ -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
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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;
|
package/dist/inline-files.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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)
|
|
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:
|
|
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
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
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
|
-
|
|
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)
|
|
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
|
}
|