@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 +130 -8
- package/EVALUATIONS.md +7 -4
- package/README.md +86 -22
- package/dist/attachment.d.ts +51 -7
- package/dist/attachment.js +141 -33
- package/dist/blobs.d.ts +22 -5
- package/dist/blobs.js +187 -72
- package/dist/cli/import.d.ts +35 -0
- package/dist/cli/import.js +692 -0
- package/dist/cli/langfuse.d.ts +309 -0
- package/dist/cli/langfuse.js +1075 -0
- package/dist/client.d.ts +9 -4
- package/dist/client.js +46 -15
- package/dist/evals/client.d.ts +7 -2
- package/dist/evals/client.js +20 -4
- package/dist/evals/files.js +100 -12
- package/dist/evals/media.d.ts +5 -1
- package/dist/evals/media.js +15 -3
- package/dist/evals/types.d.ts +4 -3
- package/dist/inline-files.d.ts +16 -6
- package/dist/inline-files.js +18 -14
- package/dist/json-edits.js +26 -7
- package/dist/large-media.d.ts +106 -0
- package/dist/large-media.js +414 -0
- package/dist/setup/cli.js +7 -3
- package/dist/snapshot.d.ts +2 -0
- package/dist/snapshot.js +19 -1
- package/dist/transport.d.ts +4 -0
- package/dist/transport.js +36 -16
- package/dist/types.d.ts +10 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/attachment.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
3
5
|
import { types as utilTypes } from "node:util";
|
|
6
|
+
import { MAX_BLOB_BYTES } from "./blobs.js";
|
|
4
7
|
import { MAX_FILE_DATA_BYTES } from "./config.js";
|
|
5
8
|
import { inlineFileBytes, isMediaContentType, isMediaName } from "./inline-files.js";
|
|
6
9
|
/** How deep in a value an attachment's part may sit and still be uploaded: Hue reads media parts
|
|
@@ -19,41 +22,137 @@ function stringBytes(text) {
|
|
|
19
22
|
return undefined;
|
|
20
23
|
return Buffer.from(text, "base64");
|
|
21
24
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
/** How much of a file given by path is read at once, to hash it or to upload it. */
|
|
26
|
+
const FILE_SLICE = 1024 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* A regular file given by path: its bytes when it is no larger than 25 MiB, and otherwise, up to
|
|
29
|
+
* Hue's 1 GB upload limit, its size and SHA-256 from one reading pass a slice at a time, never
|
|
30
|
+
* holding it whole. Undefined for a file over its limit and for anything that is no regular file
|
|
31
|
+
* (a named pipe, a device), which is opened without waiting and never read; throws for one that
|
|
32
|
+
* cannot be opened.
|
|
33
|
+
*/
|
|
34
|
+
export function readPath(path) {
|
|
35
|
+
// Resolved first: the file read now is the one read again when it is uploaded.
|
|
36
|
+
const absolute = resolve(path);
|
|
37
|
+
// Without waiting: a named pipe would block the application until something wrote to it.
|
|
38
|
+
const descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
25
39
|
try {
|
|
26
|
-
|
|
40
|
+
const info = fstatSync(descriptor);
|
|
41
|
+
if (!info.isFile())
|
|
42
|
+
return undefined;
|
|
43
|
+
if (info.size <= MAX_FILE_DATA_BYTES) {
|
|
44
|
+
const bytes = fileBytes(descriptor);
|
|
45
|
+
return bytes ? { bytes } : undefined;
|
|
46
|
+
}
|
|
47
|
+
if (info.size > MAX_BLOB_BYTES)
|
|
27
48
|
return undefined;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
let total = 0;
|
|
49
|
+
const hash = createHash("sha256");
|
|
50
|
+
const slice = Buffer.allocUnsafe(FILE_SLICE);
|
|
51
|
+
let size = 0;
|
|
32
52
|
for (;;) {
|
|
33
|
-
const
|
|
34
|
-
if (room <= 0)
|
|
35
|
-
return undefined;
|
|
36
|
-
const buffer = Buffer.alloc(room);
|
|
37
|
-
const read = readSync(descriptor, buffer, 0, room, null);
|
|
53
|
+
const read = readSync(descriptor, slice, 0, FILE_SLICE, null);
|
|
38
54
|
if (read === 0)
|
|
39
55
|
break;
|
|
40
|
-
|
|
41
|
-
|
|
56
|
+
size += read;
|
|
57
|
+
// A file that grew past the limit while it was read is refused, not read on.
|
|
58
|
+
if (size > MAX_BLOB_BYTES)
|
|
59
|
+
return undefined;
|
|
60
|
+
hash.update(slice.subarray(0, read));
|
|
42
61
|
}
|
|
43
|
-
return
|
|
62
|
+
return { file: { path: absolute, size, sha256: hash.digest("hex") } };
|
|
44
63
|
}
|
|
45
64
|
finally {
|
|
46
65
|
closeSync(descriptor);
|
|
47
66
|
}
|
|
48
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* A file given by path as an upload body, read from disk a slice at a time. Each slice is hashed as
|
|
70
|
+
* it is read and sent once the next one is read; the last is sent only once the file ended at its
|
|
71
|
+
* recorded size with its recorded SHA-256. A file that changed since it was recorded, or can no
|
|
72
|
+
* longer be read, ends the read with an error before its body is complete and sets `changed`, so
|
|
73
|
+
* the upload is abandoned and the value is counted as not uploaded; so does a path that is no
|
|
74
|
+
* longer a regular file, which is opened without waiting (a named pipe in its place would wait for
|
|
75
|
+
* a writer) and never read. Hue's presigned PUT also signs the size and SHA-256, so the store
|
|
76
|
+
* refuses a body that differs.
|
|
77
|
+
*/
|
|
78
|
+
export function fileSource(file) {
|
|
79
|
+
const source = {
|
|
80
|
+
size: file.size,
|
|
81
|
+
async *chunks() {
|
|
82
|
+
// Each read starts fresh: a retried upload reads the file again.
|
|
83
|
+
source.changed = false;
|
|
84
|
+
const changed = () => {
|
|
85
|
+
source.changed = true;
|
|
86
|
+
return new Error("The file changed after it was recorded");
|
|
87
|
+
};
|
|
88
|
+
const handle = await open(file.path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)).catch(() => {
|
|
89
|
+
throw changed();
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
const info = await handle.stat().catch(() => undefined);
|
|
93
|
+
if (!info?.isFile())
|
|
94
|
+
throw changed();
|
|
95
|
+
const hash = createHash("sha256");
|
|
96
|
+
let size = 0;
|
|
97
|
+
let pending;
|
|
98
|
+
// Two buffers in turn: the slice withheld until the next is read, and the next. A reader
|
|
99
|
+
// is done with a slice before it asks for the next, so each buffer is free when reused.
|
|
100
|
+
const buffers = [Buffer.allocUnsafe(FILE_SLICE), Buffer.allocUnsafe(FILE_SLICE)];
|
|
101
|
+
for (let turn = 0;; turn ^= 1) {
|
|
102
|
+
const slice = buffers[turn];
|
|
103
|
+
const { bytesRead } = await handle.read(slice, 0, FILE_SLICE, null).catch(() => {
|
|
104
|
+
throw changed();
|
|
105
|
+
});
|
|
106
|
+
if (bytesRead === 0)
|
|
107
|
+
break;
|
|
108
|
+
size += bytesRead;
|
|
109
|
+
if (size > file.size)
|
|
110
|
+
throw changed();
|
|
111
|
+
const read = slice.subarray(0, bytesRead);
|
|
112
|
+
hash.update(read);
|
|
113
|
+
if (pending)
|
|
114
|
+
yield pending;
|
|
115
|
+
pending = read;
|
|
116
|
+
}
|
|
117
|
+
if (size !== file.size || hash.digest("hex") !== file.sha256)
|
|
118
|
+
throw changed();
|
|
119
|
+
if (pending)
|
|
120
|
+
yield pending;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
await handle.close().catch(() => undefined);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
return source;
|
|
128
|
+
}
|
|
129
|
+
/** A file's bytes, read through one descriptor and never past the in-memory limit. */
|
|
130
|
+
function fileBytes(descriptor) {
|
|
131
|
+
// Read to its end, which may lie past its size as stated (a file that grew), but never more
|
|
132
|
+
// than one byte past the limit.
|
|
133
|
+
const parts = [];
|
|
134
|
+
let total = 0;
|
|
135
|
+
for (;;) {
|
|
136
|
+
const room = Math.min(1 << 20, MAX_FILE_DATA_BYTES + 1 - total);
|
|
137
|
+
if (room <= 0)
|
|
138
|
+
return undefined;
|
|
139
|
+
const buffer = Buffer.alloc(room);
|
|
140
|
+
const read = readSync(descriptor, buffer, 0, room, null);
|
|
141
|
+
if (read === 0)
|
|
142
|
+
break;
|
|
143
|
+
parts.push(buffer.subarray(0, read));
|
|
144
|
+
total += read;
|
|
145
|
+
}
|
|
146
|
+
return Buffer.concat(parts, total);
|
|
147
|
+
}
|
|
49
148
|
function attachmentBytes(options) {
|
|
50
149
|
const { data, path } = options;
|
|
51
150
|
if ((data === undefined) === (path === undefined))
|
|
52
151
|
return undefined;
|
|
152
|
+
if (path !== undefined)
|
|
153
|
+
return typeof path === "string" && path ? readPath(path) : undefined;
|
|
53
154
|
let bytes;
|
|
54
|
-
if (typeof
|
|
55
|
-
bytes = path ? fileBytes(path) : undefined;
|
|
56
|
-
else if (typeof data === "string") {
|
|
155
|
+
if (typeof data === "string") {
|
|
57
156
|
if (data.length > 4 * MAX_FILE_DATA_BYTES)
|
|
58
157
|
return undefined;
|
|
59
158
|
bytes = stringBytes(data);
|
|
@@ -70,7 +169,7 @@ function attachmentBytes(options) {
|
|
|
70
169
|
return undefined;
|
|
71
170
|
bytes = new Uint8Array(data.slice(0));
|
|
72
171
|
}
|
|
73
|
-
return bytes && bytes.byteLength <= MAX_FILE_DATA_BYTES ? bytes : undefined;
|
|
172
|
+
return bytes && bytes.byteLength <= MAX_FILE_DATA_BYTES ? { bytes } : undefined;
|
|
74
173
|
}
|
|
75
174
|
/**
|
|
76
175
|
* A file placed in a recorded value, the equivalent of Langfuse's `LangfuseMedia` and
|
|
@@ -80,10 +179,16 @@ function attachmentBytes(options) {
|
|
|
80
179
|
* names the file by digest (`{ type: "blob", mime_type, modality, sha256, size, name }`) and
|
|
81
180
|
* uploads its bytes during export, listed under the record's `hue.blobs`.
|
|
82
181
|
*
|
|
83
|
-
* The bytes are read and hashed when the attachment is created; `data`
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
182
|
+
* The bytes are read and hashed when the attachment is created; `data` is limited to 25 MiB, as
|
|
183
|
+
* the bytes are held in memory until they are uploaded. A `path` names a regular file, opened
|
|
184
|
+
* without waiting: anything else, such as a named pipe, is invalid. One up to 25 MiB is read then
|
|
185
|
+
* too; a larger regular file, up to Hue's 1 GB upload limit, is hashed then a slice at a time and read
|
|
186
|
+
* again from disk when it is uploaded, so it must stay in place and unchanged until the client
|
|
187
|
+
* flushes: a file whose size or SHA-256 then differs is not uploaded and counts as an upload
|
|
188
|
+
* fallback, its part keeping the digest it was recorded with. Creating one never throws: an
|
|
189
|
+
* attachment without exactly one of `data` and `path`, with unreadable data, larger data or an
|
|
190
|
+
* invalid `contentType` records `null` where it is placed and counts an instrumentation failure;
|
|
191
|
+
* an invalid `name` alone is omitted and counted.
|
|
87
192
|
*/
|
|
88
193
|
export class Attachment {
|
|
89
194
|
/** The media type given. */
|
|
@@ -97,21 +202,24 @@ export class Attachment {
|
|
|
97
202
|
constructor(options) {
|
|
98
203
|
const contentType = options?.contentType;
|
|
99
204
|
const name = options?.name;
|
|
100
|
-
let
|
|
205
|
+
let read;
|
|
101
206
|
try {
|
|
102
|
-
|
|
207
|
+
read = isMediaContentType(contentType) ? attachmentBytes(options) : undefined;
|
|
103
208
|
}
|
|
104
209
|
catch {
|
|
105
210
|
// An unreadable path, or data that is not bytes: the attachment is invalid.
|
|
106
|
-
|
|
211
|
+
read = undefined;
|
|
107
212
|
}
|
|
108
|
-
const
|
|
213
|
+
const bytes = read && "bytes" in read ? read.bytes : undefined;
|
|
214
|
+
const file = read && "file" in read ? read.file : undefined;
|
|
215
|
+
const sha256 = bytes ? createHash("sha256").update(bytes).digest("hex") : file?.sha256;
|
|
109
216
|
this.contentType = typeof contentType === "string" ? contentType : "";
|
|
110
217
|
this.name = isMediaName(name) ? name : undefined;
|
|
111
218
|
this.sha256 = sha256;
|
|
112
|
-
this.size = bytes?.byteLength;
|
|
219
|
+
this.size = bytes?.byteLength ?? file?.size;
|
|
113
220
|
states.set(this, {
|
|
114
|
-
...(bytes ? { bytes, sha256 } : {}),
|
|
221
|
+
...(bytes ? { bytes, sha256, size: bytes.byteLength } : {}),
|
|
222
|
+
...(file ? { file, sha256, size: file.size } : {}),
|
|
115
223
|
contentType: this.contentType,
|
|
116
224
|
...(this.name === undefined ? {} : { name: this.name }),
|
|
117
225
|
invalidName: name !== undefined && name !== null && this.name === undefined,
|
|
@@ -144,14 +252,14 @@ export function modalityOf(contentType) {
|
|
|
144
252
|
/** An attachment's digest-form GenAI part, the name only with content capture; undefined for an
|
|
145
253
|
* invalid attachment. */
|
|
146
254
|
export function attachmentPart(state, captureContent) {
|
|
147
|
-
if (
|
|
255
|
+
if (state.size === undefined || !state.sha256)
|
|
148
256
|
return undefined;
|
|
149
257
|
return {
|
|
150
258
|
type: "blob",
|
|
151
259
|
mime_type: state.contentType,
|
|
152
260
|
modality: modalityOf(state.contentType),
|
|
153
261
|
sha256: state.sha256,
|
|
154
|
-
size: state.
|
|
262
|
+
size: state.size,
|
|
155
263
|
...(captureContent && state.name !== undefined ? { name: state.name } : {}),
|
|
156
264
|
};
|
|
157
265
|
}
|
package/dist/blobs.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type HrTime } from "@opentelemetry/api";
|
|
2
2
|
import { type MediaDigestMemo, type FileDigest } from "./inline-files.js";
|
|
3
|
+
import { type ByteSource } from "./large-media.js";
|
|
3
4
|
/**
|
|
4
5
|
* Large values and binary media travel apart from their span through reservation, presigned PUT,
|
|
5
6
|
* and completion. Text keeps a 16 KiB prefix. Binary media keeps digest metadata on its part.
|
|
@@ -43,6 +44,9 @@ export interface OffloadCollector {
|
|
|
43
44
|
* export inspects only these. Absent for a record admission did not copy, and for a log record,
|
|
44
45
|
* which export inspects within its own allowance. */
|
|
45
46
|
inspected?: ReadonlySet<string> | undefined;
|
|
47
|
+
/** The span's own attributes admission held whole for upload past the inspection bounds: export
|
|
48
|
+
* reads their media in place (`readLargeMediaText`). */
|
|
49
|
+
large?: ReadonlySet<string> | undefined;
|
|
46
50
|
/** Set for a log record: its values over the inline limit are cut, never uploaded whole; only
|
|
47
51
|
* the media in its inspected attributes and its body upload, with the attachments placed in
|
|
48
52
|
* its body (`attachments`, keyed `body#<pointer>`). */
|
|
@@ -56,7 +60,8 @@ export interface OffloadCollector {
|
|
|
56
60
|
bodyCut?: Set<string>;
|
|
57
61
|
}
|
|
58
62
|
export interface MediaAttachment {
|
|
59
|
-
bytes
|
|
63
|
+
/** The file's bytes, or for a file given by path over the in-memory limit, its source on disk. */
|
|
64
|
+
bytes: Uint8Array | ByteSource;
|
|
60
65
|
digest: FileDigest;
|
|
61
66
|
contentType: string;
|
|
62
67
|
name?: string;
|
|
@@ -88,9 +93,14 @@ export declare function textContentType(text: string): string;
|
|
|
88
93
|
* as its own copy so the whole value is not kept alive by it. */
|
|
89
94
|
export declare function blobPrefix(value: string): string;
|
|
90
95
|
export declare function blobPrefix(value: Uint8Array): Uint8Array;
|
|
96
|
+
/** A value's size in bytes as it is uploaded: a string's UTF-8 encoding, counted, not made. */
|
|
97
|
+
export declare function sizeOf(value: string | Uint8Array | ByteSource): number;
|
|
98
|
+
/** The bytes a value holds in memory: none for a source read as it is uploaded. */
|
|
99
|
+
export declare function heldBytesOf(value: Uint8Array | ByteSource): number;
|
|
91
100
|
/** The SHA-256 (lowercase hex) and byte size of what an upload of `value` carries, yielding to
|
|
92
|
-
* the event loop between slices so a large value does not stall the application.
|
|
93
|
-
|
|
101
|
+
* the event loop between slices so a large value does not stall the application. Past
|
|
102
|
+
* `deadline`, it throws rather than read on. */
|
|
103
|
+
export declare function digestOf(value: string | Uint8Array | ByteSource, deadline?: number): Promise<{
|
|
94
104
|
sha256: string;
|
|
95
105
|
size: number;
|
|
96
106
|
}>;
|
|
@@ -102,12 +112,15 @@ export interface UploadContext {
|
|
|
102
112
|
deadline: number;
|
|
103
113
|
wait(millis: number): Promise<boolean>;
|
|
104
114
|
}
|
|
115
|
+
/** An upload's outcome; `changed` when the value's own source no longer held its bytes (a file
|
|
116
|
+
* that changed since it was recorded), which says nothing of another reference's bytes. */
|
|
105
117
|
type Outcome = {
|
|
106
118
|
ok: true;
|
|
107
119
|
contentType: string;
|
|
108
120
|
} | {
|
|
109
121
|
ok: false;
|
|
110
122
|
reason: FallbackReason;
|
|
123
|
+
changed?: boolean;
|
|
111
124
|
};
|
|
112
125
|
/** The upload route's options, from the transport's validated options. */
|
|
113
126
|
export interface UploaderOptions {
|
|
@@ -153,7 +166,7 @@ export declare class BlobUploader {
|
|
|
153
166
|
* trace is already storing shares that upload's outcome without one, so repeated files never
|
|
154
167
|
* hold the slots new files need. The caller must not hold a slot.
|
|
155
168
|
*/
|
|
156
|
-
store(traceId: string, key: string, value: string | Uint8Array, contentType: string, digest: FileDigest, upload: UploadContext): Promise<Outcome>;
|
|
169
|
+
store(traceId: string, key: string, value: string | Uint8Array | ByteSource, contentType: string, digest: FileDigest, upload: UploadContext): Promise<Outcome>;
|
|
157
170
|
private storeFresh;
|
|
158
171
|
private jsonHeaders;
|
|
159
172
|
/** Reserves the value: Hue answers that it holds it already, or a presigned PUT. A rate limit
|
|
@@ -162,7 +175,9 @@ export declare class BlobUploader {
|
|
|
162
175
|
private delay;
|
|
163
176
|
/** A 200 answer read: `exists`, or an upload whose URL and headers are usable as given. */
|
|
164
177
|
private reservation;
|
|
165
|
-
/** The PUT of the value's bytes, with exactly the headers Hue signed.
|
|
178
|
+
/** The PUT of the value's bytes, with exactly the headers Hue signed. A source that no longer
|
|
179
|
+
* holds its bytes (a file changed since it was recorded) ends the request before its body is
|
|
180
|
+
* complete, and fails the value without a retry. */
|
|
166
181
|
private put;
|
|
167
182
|
/**
|
|
168
183
|
* One HTTP request with a deadline and an idle timeout, never following a redirect, its
|
|
@@ -181,6 +196,8 @@ export interface OffloadOptions {
|
|
|
181
196
|
upload: UploadContext;
|
|
182
197
|
tally: OffloadTally;
|
|
183
198
|
memo?: MediaDigestMemo;
|
|
199
|
+
/** For tests: the length past which a long string is read in place (`LONG_STRING_UNITS`). */
|
|
200
|
+
longUnits?: number;
|
|
184
201
|
}
|
|
185
202
|
/**
|
|
186
203
|
* A span's own attributes with each candidate placed: binary media becomes a digest, while
|