@hue-run/sdk 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLI.md +18 -11
- package/EVALUATIONS.md +53 -2
- package/README.md +295 -45
- package/dist/attachment.d.ts +82 -0
- package/dist/attachment.js +225 -0
- package/dist/blobs.d.ts +73 -17
- package/dist/blobs.js +247 -96
- package/dist/cli/eval-direct.d.ts +3 -2
- package/dist/cli/eval-direct.js +49 -6
- package/dist/client.d.ts +21 -1
- package/dist/client.js +217 -32
- package/dist/evals/client.d.ts +14 -1
- package/dist/evals/client.js +73 -9
- package/dist/evals/files.d.ts +11 -1
- package/dist/evals/files.js +172 -20
- package/dist/evals/media.d.ts +217 -0
- package/dist/evals/media.js +907 -0
- package/dist/evals/runner.js +130 -106
- package/dist/evals/types.d.ts +48 -0
- package/dist/evals.d.ts +2 -0
- package/dist/evals.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inline-files.d.ts +160 -28
- package/dist/inline-files.js +809 -86
- package/dist/json-edits.d.ts +24 -0
- package/dist/json-edits.js +187 -0
- package/dist/privacy.d.ts +14 -1
- package/dist/privacy.js +86 -25
- package/dist/safety.d.ts +7 -2
- package/dist/safety.js +22 -4
- package/dist/snapshot.d.ts +7 -4
- package/dist/snapshot.js +177 -38
- package/dist/transport.d.ts +18 -0
- package/dist/transport.js +544 -38
- package/dist/types.d.ts +28 -7
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/inline-files.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { MAX_BODY_BYTES } from "./config.js";
|
|
3
|
-
|
|
3
|
+
import { applyJsonEdits } from "./json-edits.js";
|
|
4
|
+
/** Text media above this decoded byte limit is offloaded; binary media always uses a digest. */
|
|
4
5
|
export const INLINE_FILE_LIMIT = 64 * 1024;
|
|
5
|
-
/** The longest message text, in UTF-
|
|
6
|
+
/** The longest message text, in UTF-16 code units, inspected for inline files. */
|
|
6
7
|
export const MAX_INLINE_FILE_TEXT = 8 * MAX_BODY_BYTES;
|
|
7
8
|
/** Message text one record inspects for inline files in all, in UTF-16 code units. */
|
|
8
9
|
export const INLINE_FILE_TEXT_PER_RECORD = 2 * MAX_INLINE_FILE_TEXT;
|
|
9
|
-
/**
|
|
10
|
+
/** The span message attributes whose JSON may contain inline media. */
|
|
10
11
|
const messageKeys = new Set([
|
|
11
12
|
"gen_ai.input.messages",
|
|
12
13
|
"gen_ai.output.messages",
|
|
@@ -16,6 +17,78 @@ const messageKeys = new Set([
|
|
|
16
17
|
export function isMessageKey(key) {
|
|
17
18
|
return messageKeys.has(key);
|
|
18
19
|
}
|
|
20
|
+
/** Attributes whose value is JSON text by its convention, read for files anywhere in it: message
|
|
21
|
+
* sources (GenAI, AI SDK, OpenInference `input.value`/`output.value`, OpenLLMetry entities,
|
|
22
|
+
* Langfuse observations and traces) and tool arguments and results. */
|
|
23
|
+
const jsonMediaKeys = new Set([
|
|
24
|
+
"gen_ai.input.messages",
|
|
25
|
+
"gen_ai.output.messages",
|
|
26
|
+
"ai.prompt.messages",
|
|
27
|
+
"ai.prompt",
|
|
28
|
+
"input.value",
|
|
29
|
+
"output.value",
|
|
30
|
+
"traceloop.entity.input",
|
|
31
|
+
"traceloop.entity.output",
|
|
32
|
+
"langfuse.observation.input",
|
|
33
|
+
"langfuse.observation.output",
|
|
34
|
+
"langfuse.trace.input",
|
|
35
|
+
"langfuse.trace.output",
|
|
36
|
+
"gen_ai.tool.call.arguments",
|
|
37
|
+
"gen_ai.tool.call.result",
|
|
38
|
+
"ai.toolCall.args",
|
|
39
|
+
"ai.toolCall.result",
|
|
40
|
+
"tool.parameters",
|
|
41
|
+
"tool_arguments",
|
|
42
|
+
"tool_response",
|
|
43
|
+
]);
|
|
44
|
+
/** Recorded metadata: JSON text when it reads as an object or array, otherwise the text itself. */
|
|
45
|
+
const metadataKeys = new Set([
|
|
46
|
+
"metadata",
|
|
47
|
+
"langfuse.observation.metadata",
|
|
48
|
+
"langfuse.trace.metadata",
|
|
49
|
+
]);
|
|
50
|
+
const metadataPrefixes = [
|
|
51
|
+
"langfuse.observation.metadata.",
|
|
52
|
+
"langfuse.trace.metadata.",
|
|
53
|
+
"ai.telemetry.metadata.",
|
|
54
|
+
];
|
|
55
|
+
/** OpenLLMetry's indexed message content: the JSON text of a parts list. */
|
|
56
|
+
const indexedContentKey = /^gen_ai\.(?:prompt|completion)\.\d{1,9}\.content$/;
|
|
57
|
+
/** OpenInference's flattened image content: the image's URL. */
|
|
58
|
+
const openInferenceImageKey = /^llm\.(?:input|output)_messages\.\d{1,9}\.message\.contents\.\d{1,9}\.message_content\.image\.image\.url$/;
|
|
59
|
+
/** How an attribute's text is read for files, when it is read at all. */
|
|
60
|
+
export function readingOf(key) {
|
|
61
|
+
if (jsonMediaKeys.has(key))
|
|
62
|
+
return "json";
|
|
63
|
+
if (metadataKeys.has(key) || metadataPrefixes.some((prefix) => key.startsWith(prefix)))
|
|
64
|
+
return "metadata";
|
|
65
|
+
if (indexedContentKey.test(key))
|
|
66
|
+
return "parts";
|
|
67
|
+
if (openInferenceImageKey.test(key))
|
|
68
|
+
return "url";
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The attributes of a record that hold literal text: OpenInference's `input.value` and
|
|
73
|
+
* `output.value` when the record declares `input.mime_type` / `output.mime_type` as `text/plain`.
|
|
74
|
+
* Hue reads no JSON, and so no file, in them; neither does the SDK.
|
|
75
|
+
*/
|
|
76
|
+
export function literalKeys(attributes) {
|
|
77
|
+
const keys = new Set();
|
|
78
|
+
if (attributes === null || typeof attributes !== "object")
|
|
79
|
+
return keys;
|
|
80
|
+
for (const side of ["input", "output"]) {
|
|
81
|
+
const declared = Object.getOwnPropertyDescriptor(attributes, `${side}.mime_type`);
|
|
82
|
+
if (declared && "value" in declared && declared.value === "text/plain")
|
|
83
|
+
keys.add(`${side}.value`);
|
|
84
|
+
}
|
|
85
|
+
return keys;
|
|
86
|
+
}
|
|
87
|
+
/** Whether a span attribute is one whose value the SDK inspects for inline media: the attributes
|
|
88
|
+
* Hue's reader reads files from. */
|
|
89
|
+
export function isMediaKey(key) {
|
|
90
|
+
return readingOf(key) !== undefined;
|
|
91
|
+
}
|
|
19
92
|
/** Strict base64: alphabet characters only, padded to a multiple of four. */
|
|
20
93
|
const base64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
21
94
|
/** An RFC 2397 `data:` URL's header, up to the comma before its data. Its `;` parameters are read
|
|
@@ -69,108 +142,758 @@ export function inlineFileBytes(content) {
|
|
|
69
142
|
...(mediaType ? { urlMediaType: mediaType } : {}),
|
|
70
143
|
};
|
|
71
144
|
}
|
|
72
|
-
|
|
73
|
-
|
|
145
|
+
const audioTypes = {
|
|
146
|
+
wav: "audio/wav",
|
|
147
|
+
mp3: "audio/mpeg",
|
|
148
|
+
flac: "audio/flac",
|
|
149
|
+
ogg: "audio/ogg",
|
|
150
|
+
opus: "audio/opus",
|
|
151
|
+
aac: "audio/aac",
|
|
152
|
+
m4a: "audio/mp4",
|
|
153
|
+
webm: "audio/webm",
|
|
154
|
+
pcm16: "audio/pcm",
|
|
155
|
+
};
|
|
156
|
+
const documentTypes = {
|
|
157
|
+
pdf: "application/pdf",
|
|
158
|
+
csv: "text/csv",
|
|
159
|
+
doc: "application/msword",
|
|
160
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
161
|
+
xls: "application/vnd.ms-excel",
|
|
162
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
163
|
+
html: "text/html",
|
|
164
|
+
txt: "text/plain",
|
|
165
|
+
md: "text/markdown",
|
|
166
|
+
};
|
|
167
|
+
function object(value) {
|
|
168
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
169
|
+
? value
|
|
170
|
+
: undefined;
|
|
74
171
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return part.type === "blob" ? "content" : part.type === "file" ? "data" : undefined;
|
|
172
|
+
function string(value) {
|
|
173
|
+
return typeof value === "string" ? value : undefined;
|
|
78
174
|
}
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
175
|
+
export function isMediaName(value) {
|
|
176
|
+
return (typeof value === "string" &&
|
|
177
|
+
value.trim().length > 0 &&
|
|
178
|
+
value.length <= 256 &&
|
|
179
|
+
value.isWellFormed() &&
|
|
180
|
+
!Array.from(value).some((character) => {
|
|
181
|
+
const code = character.charCodeAt(0);
|
|
182
|
+
return code < 32 || code === 127;
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
function pointerSegment(segment) {
|
|
186
|
+
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
187
|
+
}
|
|
188
|
+
/** Whether the receiver can preview these bytes as text. */
|
|
189
|
+
export function isTextMediaType(contentType) {
|
|
190
|
+
const type = contentType.split(";", 1)[0].trim().toLowerCase();
|
|
191
|
+
return (type.startsWith("text/") ||
|
|
192
|
+
type === "application/json" ||
|
|
193
|
+
type.endsWith("+json") ||
|
|
194
|
+
type === "application/xml" ||
|
|
195
|
+
type.endsWith("+xml") ||
|
|
196
|
+
type === "application/yaml" ||
|
|
197
|
+
type === "application/x-yaml" ||
|
|
198
|
+
type === "application/javascript" ||
|
|
199
|
+
type === "application/x-javascript" ||
|
|
200
|
+
type === "application/x-www-form-urlencoded");
|
|
201
|
+
}
|
|
202
|
+
/** Cheap shared prefilter before bounded parsing of the attributes inspected for media. */
|
|
203
|
+
export function mayInlineFiles(key, value) {
|
|
204
|
+
return isMediaKey(key) && mayHoldMedia(value);
|
|
205
|
+
}
|
|
206
|
+
/** Whether text may hold inline media at all: cheap, before it is parsed or walked. */
|
|
207
|
+
export function mayHoldMedia(value) {
|
|
208
|
+
return (typeof value === "string" &&
|
|
209
|
+
[
|
|
210
|
+
"blob",
|
|
211
|
+
"file",
|
|
212
|
+
"image",
|
|
213
|
+
"document",
|
|
214
|
+
"media",
|
|
215
|
+
"inlineData",
|
|
216
|
+
"inline_data",
|
|
217
|
+
"audio",
|
|
218
|
+
"data:",
|
|
219
|
+
"\\u",
|
|
220
|
+
].some((marker) => value.includes(marker)));
|
|
221
|
+
}
|
|
222
|
+
/** A printable MIME type with optional parameters, accepted by the upload contract. */
|
|
223
|
+
export function isMediaContentType(value) {
|
|
224
|
+
return (typeof value === "string" &&
|
|
225
|
+
value.length >= 3 &&
|
|
226
|
+
value.length <= 255 &&
|
|
227
|
+
/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+(?: *;[ -~]*)?$/.test(value));
|
|
228
|
+
}
|
|
229
|
+
/** Detect the content pointer, MIME, name, and encoding contract of a provider media part. */
|
|
230
|
+
export function contentPath(part, pointer = "") {
|
|
231
|
+
let owner = part;
|
|
232
|
+
let key;
|
|
233
|
+
let path = "";
|
|
234
|
+
let contentType = string(part.mime_type ?? part.mimeType ?? part.mediaType ?? part.media_type);
|
|
235
|
+
let name = string(part.name ?? part.filename);
|
|
236
|
+
let encoded = false;
|
|
237
|
+
let dataUrlOnly = false;
|
|
238
|
+
const source = object(part.source);
|
|
239
|
+
const inline = object(part.inlineData ?? part.inline_data);
|
|
240
|
+
const audio = object(part.input_audio);
|
|
241
|
+
const chatFile = object(part.file);
|
|
242
|
+
const bedrock = object(part.image ?? part.document);
|
|
243
|
+
if (inline) {
|
|
244
|
+
owner = inline;
|
|
245
|
+
key = "data";
|
|
246
|
+
path = part.inlineData ? "/inlineData" : "/inline_data";
|
|
247
|
+
contentType = string(inline.mimeType ?? inline.mime_type);
|
|
248
|
+
encoded = true;
|
|
249
|
+
name = undefined;
|
|
250
|
+
}
|
|
251
|
+
else if (audio && part.type === "input_audio") {
|
|
252
|
+
owner = audio;
|
|
253
|
+
key = "data";
|
|
254
|
+
path = "/input_audio";
|
|
255
|
+
contentType = audioTypes[string(audio.format) ?? ""];
|
|
256
|
+
encoded = true;
|
|
257
|
+
name = undefined;
|
|
258
|
+
}
|
|
259
|
+
else if (chatFile && part.type === "file") {
|
|
260
|
+
owner = chatFile;
|
|
261
|
+
key = "file_data";
|
|
262
|
+
path = "/file";
|
|
263
|
+
name = string(chatFile.filename);
|
|
264
|
+
dataUrlOnly = true;
|
|
265
|
+
}
|
|
266
|
+
else if (bedrock && object(bedrock.source)) {
|
|
267
|
+
const isImage = object(part.image) !== undefined;
|
|
268
|
+
owner = object(bedrock.source);
|
|
269
|
+
key = "bytes";
|
|
270
|
+
path = isImage ? "/image/source" : "/document/source";
|
|
271
|
+
const format = string(bedrock.format) ?? "";
|
|
272
|
+
contentType =
|
|
273
|
+
isImage && ["png", "jpeg", "gif", "webp"].includes(format)
|
|
274
|
+
? `image/${format}`
|
|
275
|
+
: documentTypes[format];
|
|
276
|
+
name = isImage ? undefined : string(bedrock.name);
|
|
277
|
+
encoded = true;
|
|
278
|
+
}
|
|
279
|
+
else if (source && ["image", "document"].includes(string(part.type) ?? "")) {
|
|
280
|
+
if (source.type !== "base64")
|
|
281
|
+
return undefined;
|
|
282
|
+
owner = source;
|
|
283
|
+
key = "data";
|
|
284
|
+
path = "/source";
|
|
285
|
+
contentType = string(source.media_type);
|
|
286
|
+
name = string(part.title);
|
|
287
|
+
encoded = true;
|
|
101
288
|
}
|
|
102
|
-
|
|
289
|
+
else if (part.type === "image_url") {
|
|
290
|
+
const image = object(part.image_url);
|
|
291
|
+
owner = image ?? part;
|
|
292
|
+
key = image ? "url" : "image_url";
|
|
293
|
+
path = image ? "/image_url" : "";
|
|
294
|
+
contentType = undefined;
|
|
295
|
+
name = undefined;
|
|
296
|
+
dataUrlOnly = true;
|
|
297
|
+
}
|
|
298
|
+
else if (part.type === "input_image" || part.type === "computer_screenshot") {
|
|
299
|
+
key = "image_url";
|
|
300
|
+
contentType = undefined;
|
|
301
|
+
name = undefined;
|
|
302
|
+
dataUrlOnly = true;
|
|
303
|
+
}
|
|
304
|
+
else if (part.type === "input_file") {
|
|
305
|
+
key = "file_data";
|
|
306
|
+
contentType = undefined;
|
|
307
|
+
name = string(part.filename);
|
|
308
|
+
dataUrlOnly = true;
|
|
309
|
+
}
|
|
310
|
+
else if (part.type === "image") {
|
|
311
|
+
key = "image";
|
|
312
|
+
name = undefined;
|
|
313
|
+
}
|
|
314
|
+
else if (part.type === "media") {
|
|
315
|
+
key = "data";
|
|
316
|
+
name = undefined;
|
|
317
|
+
}
|
|
318
|
+
else
|
|
319
|
+
key = part.type === "blob" ? "content" : part.type === "file" ? "data" : undefined;
|
|
320
|
+
if (!key)
|
|
321
|
+
return undefined;
|
|
322
|
+
const content = owner[key];
|
|
323
|
+
if (typeof content !== "string" || /^(?:https?:|s3:|gs:|file:|blob:)/i.test(content))
|
|
324
|
+
return undefined;
|
|
325
|
+
if (dataUrlOnly && !content.startsWith("data:"))
|
|
326
|
+
return undefined;
|
|
327
|
+
// A declared base64 source must actually decode; malformed payloads stay as recorded.
|
|
328
|
+
if (encoded && !(content.length % 4 === 0 && base64.test(content)))
|
|
329
|
+
return undefined;
|
|
330
|
+
return {
|
|
331
|
+
part,
|
|
332
|
+
owner,
|
|
333
|
+
key,
|
|
334
|
+
content,
|
|
335
|
+
pointer: `${pointer}${path}/${key}`,
|
|
336
|
+
partPointer: pointer,
|
|
337
|
+
...(contentType ? { contentType } : {}),
|
|
338
|
+
...(isMediaName(name) ? { name } : {}),
|
|
339
|
+
...(encoded ? { base64: true } : {}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/** A media type as a `data:` URL declares one: `type/subtype`, no parameters. */
|
|
343
|
+
const declaredMediaType = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+$/;
|
|
344
|
+
/** The media type a whole string that is a `data:` URL declares, when it declares one: such a
|
|
345
|
+
* string is a file wherever it sits. A web URL, or any other text, is not. */
|
|
346
|
+
function dataUrlType(text) {
|
|
347
|
+
if (!text.startsWith("data:"))
|
|
348
|
+
return undefined;
|
|
349
|
+
const header = dataUrl.exec(text);
|
|
350
|
+
if (!header)
|
|
351
|
+
return undefined;
|
|
352
|
+
const type = header[1].split(";")[0].trim();
|
|
353
|
+
return declaredMediaType.test(type) && isMediaContentType(type) ? type : undefined;
|
|
354
|
+
}
|
|
355
|
+
/** The modality a media type implies, as Hue's reader names it. */
|
|
356
|
+
export function modalityOf(mediaType) {
|
|
357
|
+
const type = mediaType.split(";")[0].trim().toLowerCase();
|
|
358
|
+
if (type.startsWith("image/"))
|
|
359
|
+
return "image";
|
|
360
|
+
if (type.startsWith("audio/"))
|
|
361
|
+
return "audio";
|
|
362
|
+
if (type.startsWith("video/"))
|
|
363
|
+
return "video";
|
|
364
|
+
if (type.startsWith("text/") ||
|
|
365
|
+
type === "application/pdf" ||
|
|
366
|
+
type === "application/rtf" ||
|
|
367
|
+
type === "application/msword" ||
|
|
368
|
+
type === "application/vnd.ms-excel" ||
|
|
369
|
+
type === "application/vnd.ms-powerpoint" ||
|
|
370
|
+
type.startsWith("application/vnd.openxmlformats-officedocument."))
|
|
371
|
+
return "document";
|
|
372
|
+
return "file";
|
|
373
|
+
}
|
|
374
|
+
/** The media types OpenAI's audio `format` names; any other valid name is `audio/<name>`. */
|
|
375
|
+
const audioFormats = {
|
|
376
|
+
mp3: "audio/mpeg",
|
|
377
|
+
mpeg: "audio/mpeg",
|
|
378
|
+
mpga: "audio/mpeg",
|
|
379
|
+
wav: "audio/wav",
|
|
380
|
+
wave: "audio/wav",
|
|
381
|
+
ogg: "audio/ogg",
|
|
382
|
+
oga: "audio/ogg",
|
|
383
|
+
opus: "audio/opus",
|
|
384
|
+
flac: "audio/flac",
|
|
385
|
+
aac: "audio/aac",
|
|
386
|
+
m4a: "audio/mp4",
|
|
387
|
+
mp4: "audio/mp4",
|
|
388
|
+
webm: "audio/webm",
|
|
389
|
+
pcm16: "audio/pcm",
|
|
390
|
+
pcm: "audio/pcm",
|
|
391
|
+
};
|
|
392
|
+
function audioFormatMediaType(format) {
|
|
393
|
+
const name = format.trim().toLowerCase();
|
|
394
|
+
if (!/^[a-z0-9.+-]{1,32}$/.test(name))
|
|
395
|
+
return undefined;
|
|
396
|
+
return Object.hasOwn(audioFormats, name) ? audioFormats[name] : `audio/${name}`;
|
|
397
|
+
}
|
|
398
|
+
/** The media type the first bytes of base64 audio name by their container's signature (WAV,
|
|
399
|
+
* MP3, AAC, FLAC, Ogg): OpenAI's assistant audio keeps only the bytes, in the format the request
|
|
400
|
+
* asked for. Raw PCM has no signature and no type. */
|
|
401
|
+
function audioSignatureMediaType(base64) {
|
|
402
|
+
const head = Buffer.from(base64.slice(0, 16), "base64");
|
|
403
|
+
const text = (start, end) => head.subarray(start, end).toString("latin1");
|
|
404
|
+
if (text(0, 4) === "RIFF" && text(8, 12) === "WAVE")
|
|
405
|
+
return "audio/wav";
|
|
406
|
+
if (text(0, 3) === "ID3")
|
|
407
|
+
return "audio/mpeg";
|
|
408
|
+
if (text(0, 4) === "fLaC")
|
|
409
|
+
return "audio/flac";
|
|
410
|
+
if (text(0, 4) === "OggS")
|
|
411
|
+
return "audio/ogg";
|
|
412
|
+
// An MPEG frame's sync word: layer bits 00 are an AAC ADTS frame, any other an MP3 frame.
|
|
413
|
+
if (head.length >= 2 && head[0] === 0xff && (head[1] & 0xf0) === 0xf0)
|
|
414
|
+
return (head[1] & 0x06) === 0 ? "audio/aac" : "audio/mpeg";
|
|
415
|
+
if (head.length >= 2 && head[0] === 0xff && (head[1] & 0xe0) === 0xe0)
|
|
416
|
+
return "audio/mpeg";
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
/** A label as Hue's reader takes one: nonempty once trimmed, at most 256 code units. */
|
|
420
|
+
function readerLabel(value) {
|
|
421
|
+
if (typeof value !== "string" || value.length === 0)
|
|
422
|
+
return false;
|
|
423
|
+
const text = value.trim();
|
|
424
|
+
return text.length > 0 && text.length <= 256;
|
|
103
425
|
}
|
|
104
426
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* are not JSON are returned unchanged.
|
|
427
|
+
* The keys of a value Hue's reader reads as a file by its shape, where the SDK found no inline
|
|
428
|
+
* bytes to upload in it (a remote URL, a provider file id, an empty or malformed field): nothing
|
|
429
|
+
* inside them is read as another file, so nothing inside them is replaced either. Undefined for a
|
|
430
|
+
* value that is no file. Mirrors the reader's shapes (`mediaPartOf`).
|
|
110
431
|
*/
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
typeof
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return
|
|
432
|
+
function readerConsumed(part) {
|
|
433
|
+
const type = typeof part.type === "string" ? part.type.toLowerCase().replaceAll("-", "_") : undefined;
|
|
434
|
+
const size = part.size ?? part.byte_size;
|
|
435
|
+
const digest = (typeof part.sha256 === "string" && /^[0-9a-f]{64}$/.test(part.sha256)) ||
|
|
436
|
+
(typeof size === "number" && Number.isSafeInteger(size) && size >= 0);
|
|
437
|
+
const identifies = (content, fileId) => (typeof content === "string" && content.length > 0) || readerLabel(fileId) || digest;
|
|
438
|
+
const anthropic = () => {
|
|
439
|
+
const source = object(part.source);
|
|
440
|
+
return source && source.type !== "content"
|
|
441
|
+
? ["type", "source", "title", "name", "filename", "cache_control"]
|
|
442
|
+
: undefined;
|
|
443
|
+
};
|
|
444
|
+
switch (type) {
|
|
445
|
+
case "blob":
|
|
446
|
+
return ["type", "content", "mime_type", "modality", "name", "filename", "transcript"];
|
|
447
|
+
case "uri":
|
|
448
|
+
return ["type", "uri", "mime_type", "modality", "name", "filename", "transcript"];
|
|
449
|
+
case "file": {
|
|
450
|
+
const file = object(part.file);
|
|
451
|
+
if (file)
|
|
452
|
+
return identifies(file.file_data, file.file_id) ? ["type", "file"] : undefined;
|
|
453
|
+
if (typeof part.file_id === "string" || typeof part.modality === "string")
|
|
454
|
+
return identifies(undefined, part.file_id)
|
|
455
|
+
? ["type", "file_id", "mime_type", "modality", "name", "filename"]
|
|
456
|
+
: undefined;
|
|
457
|
+
return identifies(part.data)
|
|
458
|
+
? ["type", "data", "mediaType", "mimeType", "filename", "name", "providerOptions"]
|
|
459
|
+
: undefined;
|
|
460
|
+
}
|
|
461
|
+
case "image":
|
|
462
|
+
if (object(part.source))
|
|
463
|
+
return anthropic();
|
|
464
|
+
return typeof part.image === "string" || digest
|
|
465
|
+
? ["type", "image", "mediaType", "mimeType", "filename", "name", "providerOptions"]
|
|
466
|
+
: undefined;
|
|
467
|
+
case "document":
|
|
468
|
+
return anthropic();
|
|
469
|
+
case "image_url":
|
|
470
|
+
return ["type", "image_url"];
|
|
471
|
+
case "input_image":
|
|
472
|
+
return ["type", "image_url", "file_id", "detail"];
|
|
473
|
+
case "input_audio":
|
|
474
|
+
return ["type", "input_audio", "data", "format", "transcript"];
|
|
475
|
+
case "input_file":
|
|
476
|
+
return ["type", "file_data", "file_url", "file_id", "filename", "name"];
|
|
477
|
+
case "computer_screenshot":
|
|
478
|
+
return ["type", "image_url", "file_id"];
|
|
479
|
+
case "media":
|
|
480
|
+
return ["type", "data", "mime_type", "file_uri", "fileUri", "name", "filename"];
|
|
481
|
+
case undefined: {
|
|
482
|
+
for (const key of ["inlineData", "inline_data", "fileData", "file_data"])
|
|
483
|
+
if (object(part[key]))
|
|
484
|
+
return [key];
|
|
485
|
+
for (const key of ["image", "document"])
|
|
486
|
+
if (object(object(part[key])?.source))
|
|
487
|
+
return [key];
|
|
488
|
+
const audio = object(part.audio);
|
|
489
|
+
if (audio &&
|
|
490
|
+
typeof audio.id === "string" &&
|
|
491
|
+
(typeof audio.data === "string" ||
|
|
492
|
+
typeof audio.transcript === "string" ||
|
|
493
|
+
typeof audio.expires_at === "number"))
|
|
494
|
+
return ["audio"];
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
default:
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** The keys of a part whose string value is JSON by its convention, read as the value it encodes:
|
|
502
|
+
* a tool call's `arguments`, a tool result, and a `tool` or `function` message's `content`. A
|
|
503
|
+
* message's own text is never read as JSON. */
|
|
504
|
+
function encodedJsonKeys(item) {
|
|
505
|
+
if (item.role === "tool" || item.role === "function")
|
|
506
|
+
return ["content"];
|
|
507
|
+
const type = typeof item.type === "string" ? item.type.toLowerCase().replaceAll("-", "_") : undefined;
|
|
508
|
+
if (type === "tool_call" || type === "function_call")
|
|
509
|
+
return ["arguments"];
|
|
510
|
+
if (type === "tool_result" || type === "function_call_output" || type === "tool_call_response")
|
|
511
|
+
return ["output", "result", "content", "response"];
|
|
512
|
+
if (typeof item.name === "string" && Object.hasOwn(item, "arguments"))
|
|
513
|
+
return ["arguments"];
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
/** The object or array a JSON-encoded string holds; undefined for any other string. */
|
|
517
|
+
function encodedJsonValue(text) {
|
|
518
|
+
if (!/^\s*[[{]/.test(text))
|
|
519
|
+
return undefined;
|
|
120
520
|
try {
|
|
121
|
-
const parsed = JSON.parse(
|
|
122
|
-
|
|
123
|
-
const hashed = hashNode(parsed, state, 0);
|
|
124
|
-
if (count)
|
|
125
|
-
count.files += state.files;
|
|
126
|
-
return state.changed ? JSON.stringify(hashed) : value;
|
|
521
|
+
const parsed = JSON.parse(text);
|
|
522
|
+
return parsed !== null && typeof parsed === "object" ? parsed : undefined;
|
|
127
523
|
}
|
|
128
524
|
catch {
|
|
129
|
-
|
|
130
|
-
return value;
|
|
525
|
+
return undefined;
|
|
131
526
|
}
|
|
132
527
|
}
|
|
133
|
-
|
|
134
|
-
|
|
528
|
+
/** How deep Hue's reader looks for files in a recorded value: a part more than ten levels below
|
|
529
|
+
* the value's root (an encoded string's JSON counting as a level) is not read as a file, so it is
|
|
530
|
+
* not inspected either. */
|
|
531
|
+
export const MAX_MEDIA_DEPTH = 10;
|
|
532
|
+
/**
|
|
533
|
+
* Whether Hue's reader reads JSON text as the value it encodes, and so reads files in it: nested
|
|
534
|
+
* at most 32 levels, with no number JavaScript cannot hold exactly (an integer past 2^53, or one
|
|
535
|
+
* past the double range). Hue keeps any other text as it is, so the SDK leaves it as recorded.
|
|
536
|
+
*/
|
|
537
|
+
export function readerParses(value) {
|
|
538
|
+
const stack = [[value, 0]];
|
|
539
|
+
while (stack.length) {
|
|
540
|
+
const [item, depth] = stack.pop();
|
|
541
|
+
if (depth > 32)
|
|
542
|
+
return false;
|
|
543
|
+
if (typeof item === "number" &&
|
|
544
|
+
(!Number.isFinite(item) || (Number.isInteger(item) && !Number.isSafeInteger(item))))
|
|
545
|
+
return false;
|
|
546
|
+
if (item !== null && typeof item === "object")
|
|
547
|
+
for (const child of Object.values(item))
|
|
548
|
+
stack.push([child, depth + 1]);
|
|
549
|
+
}
|
|
550
|
+
return true;
|
|
135
551
|
}
|
|
136
552
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
553
|
+
* Every file a recorded value carries for upload, in reading order, as Hue's reader finds them
|
|
554
|
+
* (`forEachMediaPart`): a provider part with inline bytes, an OpenAI assistant message's audio,
|
|
555
|
+
* a whole string that is a typed `data:` URL, and the files inside a tool call's or tool result's
|
|
556
|
+
* JSON-encoded string. A part whose bytes are not inline (a remote URL, a file id) is left whole.
|
|
557
|
+
* The caller applies the decoded text threshold.
|
|
141
558
|
*/
|
|
142
|
-
export function
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
559
|
+
export function mediaIn(value, source) {
|
|
560
|
+
const box = { value };
|
|
561
|
+
const files = [];
|
|
562
|
+
const encoded = [];
|
|
563
|
+
const document = { at: "", edits: [] };
|
|
564
|
+
const visit = (node, pointer, depth, owner, key, within) => {
|
|
565
|
+
if (depth > MAX_MEDIA_DEPTH)
|
|
566
|
+
return;
|
|
567
|
+
const add = (file) => files.push({
|
|
568
|
+
...file,
|
|
569
|
+
...(within.length ? { encoded: within } : {}),
|
|
570
|
+
document: within.at(-1) ?? document,
|
|
571
|
+
});
|
|
572
|
+
if (typeof node === "string") {
|
|
573
|
+
const type = dataUrlType(node);
|
|
574
|
+
if (type !== undefined)
|
|
575
|
+
add({
|
|
576
|
+
part: {},
|
|
577
|
+
owner,
|
|
578
|
+
key,
|
|
579
|
+
content: node,
|
|
580
|
+
pointer,
|
|
581
|
+
partPointer: pointer,
|
|
582
|
+
contentType: type,
|
|
583
|
+
whole: true,
|
|
584
|
+
});
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
147
587
|
if (Array.isArray(node)) {
|
|
148
|
-
|
|
588
|
+
for (let index = 0; index < node.length; index++)
|
|
589
|
+
visit(node[index], `${pointer}/${index}`, depth + 1, node, index, within);
|
|
149
590
|
return;
|
|
150
591
|
}
|
|
151
|
-
|
|
592
|
+
const part = object(node);
|
|
593
|
+
if (!part)
|
|
152
594
|
return;
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// Decoding only shrinks the text, so text of at most the limit holds no file over it.
|
|
157
|
-
if (key !== undefined &&
|
|
158
|
-
typeof inline === "string" &&
|
|
159
|
-
inline.length * 3 > INLINE_FILE_LIMIT &&
|
|
160
|
-
Buffer.byteLength(inline, "utf8") > INLINE_FILE_LIMIT) {
|
|
161
|
-
found.push({ part, key, content: inline, pointer: `${pointer}/${key}` });
|
|
595
|
+
const file = contentPath(part, pointer);
|
|
596
|
+
if (file) {
|
|
597
|
+
add(file);
|
|
162
598
|
return;
|
|
163
599
|
}
|
|
164
|
-
|
|
165
|
-
|
|
600
|
+
const consumed = readerConsumed(part);
|
|
601
|
+
// An OpenAI Chat assistant message's spoken reply: the message is the part.
|
|
602
|
+
const audio = consumed?.length === 1 && consumed[0] === "audio" ? object(part.audio) : undefined;
|
|
603
|
+
if (audio &&
|
|
604
|
+
typeof audio.data === "string" &&
|
|
605
|
+
audio.data.length % 4 === 0 &&
|
|
606
|
+
base64.test(audio.data)) {
|
|
607
|
+
const format = typeof audio.format === "string" && audio.format ? audio.format : undefined;
|
|
608
|
+
const contentType = format
|
|
609
|
+
? audioFormatMediaType(format)
|
|
610
|
+
: audioSignatureMediaType(audio.data);
|
|
611
|
+
add({
|
|
612
|
+
part,
|
|
613
|
+
owner: audio,
|
|
614
|
+
key: "data",
|
|
615
|
+
content: audio.data,
|
|
616
|
+
pointer: `${pointer}/audio/data`,
|
|
617
|
+
partPointer: pointer,
|
|
618
|
+
...(contentType ? { contentType } : {}),
|
|
619
|
+
base64: true,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
const encodedKeys = encodedJsonKeys(part);
|
|
623
|
+
for (const [name, item] of Object.entries(part)) {
|
|
624
|
+
if (consumed?.includes(name))
|
|
625
|
+
continue;
|
|
626
|
+
const at = `${pointer}/${pointerSegment(name)}`;
|
|
627
|
+
const inner = typeof item === "string" && encodedKeys.includes(name) ? encodedJsonValue(item) : undefined;
|
|
628
|
+
if (inner !== undefined) {
|
|
629
|
+
const holder = {
|
|
630
|
+
owner: part,
|
|
631
|
+
key: name,
|
|
632
|
+
value: inner,
|
|
633
|
+
dirty: false,
|
|
634
|
+
text: item,
|
|
635
|
+
at,
|
|
636
|
+
edits: [],
|
|
637
|
+
parent: within.at(-1) ?? document,
|
|
638
|
+
};
|
|
639
|
+
const before = files.length;
|
|
640
|
+
visit(inner, at, depth + 1, holder, "value", [
|
|
641
|
+
...within,
|
|
642
|
+
holder,
|
|
643
|
+
]);
|
|
644
|
+
if (files.length > before)
|
|
645
|
+
encoded.push(holder);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
visit(item, at, depth + 1, part, name, within);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
visit(value, "", 0, box, "value", []);
|
|
652
|
+
return { box, files, encoded, document, ...(source !== undefined ? { source } : {}) };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* An attribute's recorded text read for files as Hue's reader reads that attribute: JSON by its
|
|
656
|
+
* convention; metadata as JSON when it reads as an object or array, otherwise as the text itself;
|
|
657
|
+
* an OpenLLMetry content as the parts list it encodes (any other content stays text); an
|
|
658
|
+
* OpenInference image URL as the text itself. Undefined for text that reading finds nothing in.
|
|
659
|
+
* Throws for JSON that does not parse or nests too deep.
|
|
660
|
+
*/
|
|
661
|
+
export function readMediaText(key, text) {
|
|
662
|
+
/** JSON text read as Hue's reader reads it: text it keeps as text holds no file. */
|
|
663
|
+
const json = (parsed) => (readerParses(parsed) ? mediaIn(parsed, text) : undefined);
|
|
664
|
+
switch (readingOf(key)) {
|
|
665
|
+
case "json":
|
|
666
|
+
return json(JSON.parse(text));
|
|
667
|
+
case "metadata":
|
|
668
|
+
return /^\s*[[{]/.test(text) ? json(JSON.parse(text)) : mediaIn(text);
|
|
669
|
+
case "parts": {
|
|
670
|
+
if (!/^\s*\[/.test(text))
|
|
671
|
+
return undefined;
|
|
672
|
+
const parsed = JSON.parse(text);
|
|
673
|
+
return Array.isArray(parsed) &&
|
|
674
|
+
parsed.length > 0 &&
|
|
675
|
+
parsed.every((part) => typeof object(part)?.type === "string")
|
|
676
|
+
? json(parsed)
|
|
677
|
+
: undefined;
|
|
678
|
+
}
|
|
679
|
+
case "url":
|
|
680
|
+
return mediaIn(text);
|
|
681
|
+
default:
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** The recorded value's text once its files are placed: its JSON text as recorded with only the
|
|
686
|
+
* placed files' values (or their parts' members) replaced, each JSON-encoded string a placed file
|
|
687
|
+
* sits in written again the same way, innermost first. */
|
|
688
|
+
export function placedMediaText(media) {
|
|
689
|
+
reencodeMedia(media);
|
|
690
|
+
return media.source !== undefined
|
|
691
|
+
? applyJsonEdits(media.source, media.document.edits)
|
|
692
|
+
: JSON.stringify(media.box.value);
|
|
693
|
+
}
|
|
694
|
+
/** Each JSON-encoded string a placed file sits in, written again, innermost first: the string as
|
|
695
|
+
* recorded with only its placed files changed. */
|
|
696
|
+
export function reencodeMedia(media) {
|
|
697
|
+
for (const holder of media.encoded) {
|
|
698
|
+
if (!holder.dirty)
|
|
699
|
+
continue;
|
|
700
|
+
const text = applyJsonEdits(holder.text, holder.edits);
|
|
701
|
+
holder.owner[holder.key] = text;
|
|
702
|
+
holder.dirty = false;
|
|
703
|
+
holder.parent.edits.push({
|
|
704
|
+
pointer: holder.at.slice(holder.parent.at.length),
|
|
705
|
+
value: JSON.stringify(text),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Whether a found file has bytes to place: nonempty, and a whole string's `data:` URL decodes. */
|
|
710
|
+
export function placeable(file, decoded) {
|
|
711
|
+
return decoded.bytes.byteLength > 0 && (!file.whole || decoded.decoded);
|
|
712
|
+
}
|
|
713
|
+
/** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
|
|
714
|
+
* provider part, whose entry names the content) rather than its digest. A whole string and a
|
|
715
|
+
* file inside a JSON-encoded string always take the digest form. */
|
|
716
|
+
export function keepsPrefix(file, textual) {
|
|
717
|
+
return textual && !file.whole && !file.encoded?.length;
|
|
718
|
+
}
|
|
719
|
+
export function mediaBytes(file) {
|
|
720
|
+
if (file.base64)
|
|
721
|
+
return { bytes: Buffer.from(file.content, "base64"), decoded: true };
|
|
722
|
+
if (isMediaContentType(file.contentType) &&
|
|
723
|
+
isTextMediaType(file.contentType) &&
|
|
724
|
+
!file.content.startsWith("data:"))
|
|
725
|
+
return { bytes: Buffer.from(file.content, "utf8"), decoded: false };
|
|
726
|
+
return inlineFileBytes(file.content);
|
|
727
|
+
}
|
|
728
|
+
export function mediaContentType(file, decoded) {
|
|
729
|
+
return ((isMediaContentType(file.contentType) ? file.contentType : undefined) ??
|
|
730
|
+
(isMediaContentType(decoded.urlMediaType) ? decoded.urlMediaType : undefined) ??
|
|
731
|
+
(decoded.decoded ? "application/octet-stream" : "text/plain; charset=utf-8"));
|
|
732
|
+
}
|
|
733
|
+
/** Bounded payload identity memo, shared by admission and export, cleared at shutdown. */
|
|
734
|
+
export class MediaDigestMemo {
|
|
735
|
+
entries = new Map();
|
|
736
|
+
units = 0;
|
|
737
|
+
clear() {
|
|
738
|
+
this.entries.clear();
|
|
739
|
+
this.units = 0;
|
|
740
|
+
}
|
|
741
|
+
digest(file, decoded, traceId = "") {
|
|
742
|
+
return this.bytes((decoded ?? mediaBytes(file)).bytes, traceId);
|
|
743
|
+
}
|
|
744
|
+
bytes(bytes, traceId) {
|
|
745
|
+
const digest = {
|
|
746
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
747
|
+
size: bytes.byteLength,
|
|
748
|
+
};
|
|
749
|
+
const key = `${traceId}:${digest.sha256}`;
|
|
750
|
+
const known = this.entries.get(key);
|
|
751
|
+
if (known)
|
|
752
|
+
return known;
|
|
753
|
+
const limit = 32 * 1024 * 1024;
|
|
754
|
+
if (key.length <= limit) {
|
|
755
|
+
while (this.entries.size >= 4096 || this.units + key.length > limit) {
|
|
756
|
+
const first = this.entries.keys().next().value;
|
|
757
|
+
this.units -= first.length;
|
|
758
|
+
this.entries.delete(first);
|
|
759
|
+
}
|
|
760
|
+
this.entries.set(key, digest);
|
|
761
|
+
this.units += key.length;
|
|
762
|
+
}
|
|
763
|
+
return digest;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** The GenAI `blob` part in digest form a whole-string file is replaced by. */
|
|
767
|
+
function blobPart(contentType, digest) {
|
|
768
|
+
return {
|
|
769
|
+
type: "blob",
|
|
770
|
+
mime_type: contentType,
|
|
771
|
+
modality: modalityOf(contentType),
|
|
772
|
+
sha256: digest.sha256,
|
|
773
|
+
size: digest.size,
|
|
166
774
|
};
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
775
|
+
}
|
|
776
|
+
/** Digest placement removes only the leaf and adds identity to the provider part (the message,
|
|
777
|
+
* for OpenAI assistant audio); a whole string becomes a GenAI `blob` part in digest form. */
|
|
778
|
+
export function digestPart(file, digest) {
|
|
779
|
+
const owner = file.owner;
|
|
780
|
+
const document = file.document;
|
|
781
|
+
const at = (pointer) => pointer.slice(document.at.length);
|
|
782
|
+
if (file.whole) {
|
|
783
|
+
const part = blobPart(file.contentType, digest);
|
|
784
|
+
owner[file.key] = part;
|
|
785
|
+
document?.edits.push({ pointer: at(file.pointer), value: JSON.stringify(part) });
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
delete owner[file.key];
|
|
789
|
+
file.part.sha256 = digest.sha256;
|
|
790
|
+
file.part.size = digest.size;
|
|
791
|
+
if (document) {
|
|
792
|
+
const set = [
|
|
793
|
+
["sha256", JSON.stringify(digest.sha256)],
|
|
794
|
+
["size", String(digest.size)],
|
|
795
|
+
];
|
|
796
|
+
const ownerPointer = file.pointer.slice(0, file.pointer.lastIndexOf("/"));
|
|
797
|
+
if (ownerPointer === file.partPointer)
|
|
798
|
+
document.edits.push({ pointer: at(ownerPointer), remove: String(file.key), set });
|
|
799
|
+
else {
|
|
800
|
+
document.edits.push({ pointer: at(ownerPointer), remove: String(file.key) });
|
|
801
|
+
document.edits.push({ pointer: at(file.partPointer), set });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
for (const holder of file.encoded ?? [])
|
|
806
|
+
holder.dirty = true;
|
|
807
|
+
}
|
|
808
|
+
/** Prefix placement keeps the first 16 KiB of a text file's content in place. */
|
|
809
|
+
export function prefixPart(file, prefix) {
|
|
810
|
+
file.owner[file.key] = prefix;
|
|
811
|
+
file.document?.edits.push({
|
|
812
|
+
pointer: file.pointer.slice(file.document.at.length),
|
|
813
|
+
value: JSON.stringify(prefix),
|
|
814
|
+
});
|
|
815
|
+
for (const holder of file.encoded ?? [])
|
|
816
|
+
holder.dirty = true;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* A span's own attribute with its inline media replaced by their digest, as export places media
|
|
820
|
+
* it does not upload. Only the recorded message attributes (`isMessageKey`) shrink without an
|
|
821
|
+
* upload; every other inspected attribute keeps its bytes inline unless they upload, so `anyKey`
|
|
822
|
+
* asks for the digest form of any inspected attribute (what the queue is charged for a value it
|
|
823
|
+
* holds whole for upload).
|
|
824
|
+
*/
|
|
825
|
+
export function hashInlineFiles(key, value, count, memo, traceId = "", anyKey = false) {
|
|
826
|
+
return anyKey || isMessageKey(key) ? hashFiles(key, value, false, count, memo, traceId) : value;
|
|
827
|
+
}
|
|
828
|
+
/** Nested message attributes retain small media and digest larger media without upload state. */
|
|
829
|
+
export function hashNestedInlineFiles(key, value, count) {
|
|
830
|
+
return isMessageKey(key) ? hashFiles(key, value, true, count) : value;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Each file a recorded value holds replaced by its digest, as export places a file it does not
|
|
834
|
+
* upload: binary media of any size (`nested`: over the inline limit) and text media over the
|
|
835
|
+
* inline limit. Whether any was replaced; `count` counts them.
|
|
836
|
+
*/
|
|
837
|
+
export function digestMedia(media, nested, count, memo, traceId = "") {
|
|
838
|
+
let changed = false;
|
|
839
|
+
for (const file of media.files) {
|
|
840
|
+
const decoded = mediaBytes(file);
|
|
841
|
+
if (!placeable(file, decoded))
|
|
842
|
+
continue;
|
|
843
|
+
if ((nested || isTextMediaType(mediaContentType(file, decoded))) &&
|
|
844
|
+
decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
|
845
|
+
continue;
|
|
846
|
+
const digest = memo
|
|
847
|
+
? memo.digest(file, decoded, traceId)
|
|
848
|
+
: {
|
|
849
|
+
sha256: createHash("sha256").update(decoded.bytes).digest("hex"),
|
|
850
|
+
size: decoded.bytes.byteLength,
|
|
851
|
+
};
|
|
852
|
+
digestPart(file, digest);
|
|
853
|
+
changed = true;
|
|
854
|
+
if (count)
|
|
855
|
+
count.files++;
|
|
856
|
+
}
|
|
857
|
+
if (changed)
|
|
858
|
+
reencodeMedia(media);
|
|
859
|
+
return changed;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The UTF-8 size of a structured value's JSON once each file the SDK may upload from it, within
|
|
863
|
+
* the inspection limits, is placed in its digest form: whether a log body over the value cap fits
|
|
864
|
+
* it once its media upload. Mutates `value`, a copy the caller owns.
|
|
865
|
+
*/
|
|
866
|
+
export function digestFormBytes(value) {
|
|
867
|
+
const media = mediaIn(value);
|
|
868
|
+
let inspected = 0;
|
|
869
|
+
for (const file of media.files) {
|
|
870
|
+
const units = file.content.length;
|
|
871
|
+
if (units > MAX_INLINE_FILE_TEXT || inspected + units > INLINE_FILE_TEXT_PER_RECORD)
|
|
872
|
+
continue;
|
|
873
|
+
inspected += units;
|
|
874
|
+
const decoded = mediaBytes(file);
|
|
875
|
+
if (!placeable(file, decoded))
|
|
876
|
+
continue;
|
|
877
|
+
if (isTextMediaType(mediaContentType(file, decoded)) &&
|
|
878
|
+
decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
|
879
|
+
continue;
|
|
880
|
+
digestPart(file, { sha256: "0".repeat(64), size: decoded.bytes.byteLength });
|
|
881
|
+
}
|
|
882
|
+
reencodeMedia(media);
|
|
883
|
+
return Buffer.byteLength(JSON.stringify(media.box.value));
|
|
884
|
+
}
|
|
885
|
+
function hashFiles(key, value, nested, count, memo, traceId = "") {
|
|
886
|
+
if (typeof value !== "string" ||
|
|
887
|
+
!mayInlineFiles(key, value) ||
|
|
888
|
+
value.length > MAX_INLINE_FILE_TEXT)
|
|
889
|
+
return value;
|
|
890
|
+
try {
|
|
891
|
+
const media = readMediaText(key, value);
|
|
892
|
+
if (!media || !digestMedia(media, nested, count, memo, traceId))
|
|
893
|
+
return value;
|
|
894
|
+
return placedMediaText(media);
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return value;
|
|
898
|
+
}
|
|
176
899
|
}
|