@hue-run/sdk 0.15.2 → 0.17.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.
@@ -1,12 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { MAX_BODY_BYTES } from "./config.js";
3
- /** Inline file content larger than this many UTF-8 bytes is exported as its digest instead. */
3
+ /** Text media above this decoded byte limit is offloaded; binary media always uses a digest. */
4
4
  export const INLINE_FILE_LIMIT = 64 * 1024;
5
- /** The longest message text, in UTF-8 bytes, inspected for inline files. */
5
+ /** The longest message text, in UTF-16 code units, inspected for inline files. */
6
6
  export const MAX_INLINE_FILE_TEXT = 8 * MAX_BODY_BYTES;
7
7
  /** Message text one record inspects for inline files in all, in UTF-16 code units. */
8
8
  export const INLINE_FILE_TEXT_PER_RECORD = 2 * MAX_INLINE_FILE_TEXT;
9
- /** Message attributes whose JSON can inline files: GenAI blob parts and AI SDK 6 file parts. */
9
+ /** The span message attributes whose JSON may contain inline media. */
10
10
  const messageKeys = new Set([
11
11
  "gen_ai.input.messages",
12
12
  "gen_ai.output.messages",
@@ -69,76 +69,200 @@ export function inlineFileBytes(content) {
69
69
  ...(mediaType ? { urlMediaType: mediaType } : {}),
70
70
  };
71
71
  }
72
- function fileBytes(content) {
73
- return inlineFileBytes(content).bytes;
72
+ const audioTypes = {
73
+ wav: "audio/wav",
74
+ mp3: "audio/mpeg",
75
+ flac: "audio/flac",
76
+ ogg: "audio/ogg",
77
+ opus: "audio/opus",
78
+ aac: "audio/aac",
79
+ m4a: "audio/mp4",
80
+ webm: "audio/webm",
81
+ pcm16: "audio/pcm",
82
+ };
83
+ const documentTypes = {
84
+ pdf: "application/pdf",
85
+ csv: "text/csv",
86
+ doc: "application/msword",
87
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
88
+ xls: "application/vnd.ms-excel",
89
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
90
+ html: "text/html",
91
+ txt: "text/plain",
92
+ md: "text/markdown",
93
+ };
94
+ function object(value) {
95
+ return value !== null && typeof value === "object" && !Array.isArray(value)
96
+ ? value
97
+ : undefined;
74
98
  }
75
- /** The key holding a part's inline content: GenAI `blob` parts and AI SDK 6 `file` parts. */
76
- export function contentKey(part) {
77
- return part.type === "blob" ? "content" : part.type === "file" ? "data" : undefined;
99
+ function string(value) {
100
+ return typeof value === "string" ? value : undefined;
78
101
  }
79
- function hashNode(value, state, depth) {
80
- if (depth > 256)
81
- throw new Error("Message exceeds the supported nesting limit");
82
- if (Array.isArray(value))
83
- return value.map((item) => hashNode(item, state, depth + 1));
84
- if (value === null || typeof value !== "object")
85
- return value;
86
- const part = value;
87
- const key = contentKey(part);
88
- const inline = key === undefined ? undefined : part[key];
89
- if (key !== undefined && typeof inline === "string") {
90
- const bytes = fileBytes(inline);
91
- if (bytes.byteLength > INLINE_FILE_LIMIT) {
92
- const { [key]: _omitted, ...rest } = part;
93
- state.changed = true;
94
- state.files++;
95
- return {
96
- ...rest,
97
- sha256: createHash("sha256").update(bytes).digest("hex"),
98
- size: bytes.byteLength,
99
- };
100
- }
101
- }
102
- return Object.fromEntries(Object.entries(part).map(([name, item]) => [name, hashNode(item, state, depth + 1)]));
103
- }
104
- /**
105
- * Replaces inline files longer than {@link INLINE_FILE_LIMIT} in a recorded message attribute
106
- * with their SHA-256 and byte size, so a span that inlines a large file exports the file's
107
- * identity instead of being rejected for its size. The part keeps its other fields (`type`,
108
- * `mime_type`, `modality`, `mediaType`, …). Other attributes, shorter messages and values that
109
- * are not JSON are returned unchanged.
110
- */
111
- export function hashInlineFiles(key, value,
112
- /** Counts the files replaced by their digest, when given. */
113
- count) {
114
- if (!messageKeys.has(key) ||
115
- typeof value !== "string" ||
116
- Buffer.byteLength(value, "utf8") <= INLINE_FILE_LIMIT ||
117
- Buffer.byteLength(value, "utf8") > MAX_INLINE_FILE_TEXT ||
118
- !(value.includes('"blob"') || value.includes('"file"')))
119
- return value;
120
- try {
121
- const parsed = JSON.parse(value);
122
- const state = { changed: false, files: 0 };
123
- const hashed = hashNode(parsed, state, 0);
124
- if (count)
125
- count.files += state.files;
126
- return state.changed ? JSON.stringify(hashed) : value;
127
- }
128
- catch {
129
- // Not JSON, or nested too deeply to inspect: export decides the value's fate as before.
130
- return value;
131
- }
102
+ export function isMediaName(value) {
103
+ return (typeof value === "string" &&
104
+ value.trim().length > 0 &&
105
+ value.length <= 256 &&
106
+ value.isWellFormed() &&
107
+ !Array.from(value).some((character) => {
108
+ const code = character.charCodeAt(0);
109
+ return code < 32 || code === 127;
110
+ }));
132
111
  }
133
112
  function pointerSegment(segment) {
134
113
  return segment.replaceAll("~", "~0").replaceAll("/", "~1");
135
114
  }
136
- /**
137
- * The inline file parts of a parsed message whose content text is longer than
138
- * {@link INLINE_FILE_LIMIT} UTF-8 bytes, in document order: the caller decodes each and keeps the
139
- * ones whose file bytes are over the limit. The parts are the parsed value's own objects, so the
140
- * caller may replace their content in place.
141
- */
115
+ /** Whether the receiver can preview these bytes as text. */
116
+ export function isTextMediaType(contentType) {
117
+ const type = contentType.split(";", 1)[0].trim().toLowerCase();
118
+ return (type.startsWith("text/") ||
119
+ type === "application/json" ||
120
+ type.endsWith("+json") ||
121
+ type === "application/xml" ||
122
+ type.endsWith("+xml") ||
123
+ type === "application/yaml" ||
124
+ type === "application/x-yaml" ||
125
+ type === "application/javascript" ||
126
+ type === "application/x-javascript" ||
127
+ type === "application/x-www-form-urlencoded");
128
+ }
129
+ /** Cheap shared prefilter before bounded parsing of span message attributes. */
130
+ export function mayInlineFiles(key, value) {
131
+ return (isMessageKey(key) &&
132
+ typeof value === "string" &&
133
+ [
134
+ "blob",
135
+ "file",
136
+ "image",
137
+ "document",
138
+ "media",
139
+ "inlineData",
140
+ "inline_data",
141
+ "input_audio",
142
+ "\\u",
143
+ ].some((marker) => value.includes(marker)));
144
+ }
145
+ /** A printable MIME type with optional parameters, accepted by the upload contract. */
146
+ export function isMediaContentType(value) {
147
+ return (typeof value === "string" &&
148
+ value.length >= 3 &&
149
+ value.length <= 255 &&
150
+ /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9!#$&^_.+-]+(?: *;[ -~]*)?$/.test(value));
151
+ }
152
+ /** Detect the content pointer, MIME, name, and encoding contract of a provider media part. */
153
+ export function contentPath(part, pointer = "") {
154
+ let owner = part;
155
+ let key;
156
+ let path = "";
157
+ let contentType = string(part.mime_type ?? part.mimeType ?? part.mediaType ?? part.media_type);
158
+ let name = string(part.name ?? part.filename);
159
+ let encoded = false;
160
+ let dataUrlOnly = false;
161
+ const source = object(part.source);
162
+ const inline = object(part.inlineData ?? part.inline_data);
163
+ const audio = object(part.input_audio);
164
+ const chatFile = object(part.file);
165
+ const bedrock = object(part.image ?? part.document);
166
+ if (inline) {
167
+ owner = inline;
168
+ key = "data";
169
+ path = part.inlineData ? "/inlineData" : "/inline_data";
170
+ contentType = string(inline.mimeType ?? inline.mime_type);
171
+ encoded = true;
172
+ name = undefined;
173
+ }
174
+ else if (audio && part.type === "input_audio") {
175
+ owner = audio;
176
+ key = "data";
177
+ path = "/input_audio";
178
+ contentType = audioTypes[string(audio.format) ?? ""];
179
+ encoded = true;
180
+ name = undefined;
181
+ }
182
+ else if (chatFile && part.type === "file") {
183
+ owner = chatFile;
184
+ key = "file_data";
185
+ path = "/file";
186
+ name = string(chatFile.filename);
187
+ dataUrlOnly = true;
188
+ }
189
+ else if (bedrock && object(bedrock.source)) {
190
+ const isImage = object(part.image) !== undefined;
191
+ owner = object(bedrock.source);
192
+ key = "bytes";
193
+ path = isImage ? "/image/source" : "/document/source";
194
+ const format = string(bedrock.format) ?? "";
195
+ contentType =
196
+ isImage && ["png", "jpeg", "gif", "webp"].includes(format)
197
+ ? `image/${format}`
198
+ : documentTypes[format];
199
+ name = isImage ? undefined : string(bedrock.name);
200
+ encoded = true;
201
+ }
202
+ else if (source && ["image", "document"].includes(string(part.type) ?? "")) {
203
+ if (source.type !== "base64")
204
+ return undefined;
205
+ owner = source;
206
+ key = "data";
207
+ path = "/source";
208
+ contentType = string(source.media_type);
209
+ name = string(part.title);
210
+ encoded = true;
211
+ }
212
+ else if (part.type === "image_url") {
213
+ const image = object(part.image_url);
214
+ owner = image ?? part;
215
+ key = image ? "url" : "image_url";
216
+ path = image ? "/image_url" : "";
217
+ contentType = undefined;
218
+ name = undefined;
219
+ dataUrlOnly = true;
220
+ }
221
+ else if (part.type === "input_image") {
222
+ key = "image_url";
223
+ contentType = undefined;
224
+ name = undefined;
225
+ dataUrlOnly = true;
226
+ }
227
+ else if (part.type === "input_file") {
228
+ key = "file_data";
229
+ contentType = undefined;
230
+ name = string(part.filename);
231
+ dataUrlOnly = true;
232
+ }
233
+ else if (part.type === "image") {
234
+ key = "image";
235
+ name = undefined;
236
+ }
237
+ else if (part.type === "media") {
238
+ key = "data";
239
+ name = undefined;
240
+ }
241
+ else
242
+ key = part.type === "blob" ? "content" : part.type === "file" ? "data" : undefined;
243
+ if (!key)
244
+ return undefined;
245
+ const content = owner[key];
246
+ if (typeof content !== "string" || /^(?:https?:|s3:|gs:|file:|blob:)/i.test(content))
247
+ return undefined;
248
+ if (dataUrlOnly && !content.startsWith("data:"))
249
+ return undefined;
250
+ // A declared base64 source must actually decode; malformed payloads stay as recorded.
251
+ if (encoded && !(content.length % 4 === 0 && base64.test(content)))
252
+ return undefined;
253
+ return {
254
+ part,
255
+ owner,
256
+ key,
257
+ content,
258
+ pointer: `${pointer}${path}/${key}`,
259
+ partPointer: pointer,
260
+ ...(contentType ? { contentType } : {}),
261
+ ...(isMediaName(name) ? { name } : {}),
262
+ ...(encoded ? { base64: true } : {}),
263
+ };
264
+ }
265
+ /** Inline media in document order. The caller applies the decoded text threshold. */
142
266
  export function largeInlineFileParts(value) {
143
267
  const found = [];
144
268
  const visit = (node, pointer, depth) => {
@@ -148,17 +272,12 @@ export function largeInlineFileParts(value) {
148
272
  node.forEach((item, index) => visit(item, `${pointer}/${index}`, depth + 1));
149
273
  return;
150
274
  }
151
- if (node === null || typeof node !== "object")
275
+ const part = object(node);
276
+ if (!part)
152
277
  return;
153
- const part = node;
154
- const key = contentKey(part);
155
- const inline = key === undefined ? undefined : part[key];
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}` });
278
+ const file = contentPath(part, pointer);
279
+ if (file) {
280
+ found.push(file);
162
281
  return;
163
282
  }
164
283
  for (const [name, item] of Object.entries(part))
@@ -167,10 +286,96 @@ export function largeInlineFileParts(value) {
167
286
  visit(value, "", 0);
168
287
  return found;
169
288
  }
170
- /** The digest an inline file part is exported as when its bytes are not uploaded: the part's
171
- * other fields with the file's SHA-256 and byte size, as {@link hashInlineFiles} writes it. */
172
- export function digestPart(part, key, digest) {
173
- delete part[key];
174
- part.sha256 = digest.sha256;
175
- part.size = digest.size;
289
+ export function mediaBytes(file) {
290
+ if (file.base64)
291
+ return { bytes: Buffer.from(file.content, "base64"), decoded: true };
292
+ if (isMediaContentType(file.contentType) &&
293
+ isTextMediaType(file.contentType) &&
294
+ !file.content.startsWith("data:"))
295
+ return { bytes: Buffer.from(file.content, "utf8"), decoded: false };
296
+ return inlineFileBytes(file.content);
297
+ }
298
+ export function mediaContentType(file, decoded) {
299
+ return ((isMediaContentType(file.contentType) ? file.contentType : undefined) ??
300
+ (isMediaContentType(decoded.urlMediaType) ? decoded.urlMediaType : undefined) ??
301
+ (decoded.decoded ? "application/octet-stream" : "text/plain; charset=utf-8"));
302
+ }
303
+ /** Bounded payload identity memo, shared by admission and export, cleared at shutdown. */
304
+ export class MediaDigestMemo {
305
+ entries = new Map();
306
+ units = 0;
307
+ clear() {
308
+ this.entries.clear();
309
+ this.units = 0;
310
+ }
311
+ digest(file, decoded, traceId = "") {
312
+ return this.bytes((decoded ?? mediaBytes(file)).bytes, traceId);
313
+ }
314
+ bytes(bytes, traceId) {
315
+ const digest = {
316
+ sha256: createHash("sha256").update(bytes).digest("hex"),
317
+ size: bytes.byteLength,
318
+ };
319
+ const key = `${traceId}:${digest.sha256}`;
320
+ const known = this.entries.get(key);
321
+ if (known)
322
+ return known;
323
+ const limit = 32 * 1024 * 1024;
324
+ if (key.length <= limit) {
325
+ while (this.entries.size >= 4096 || this.units + key.length > limit) {
326
+ const first = this.entries.keys().next().value;
327
+ this.units -= first.length;
328
+ this.entries.delete(first);
329
+ }
330
+ this.entries.set(key, digest);
331
+ this.units += key.length;
332
+ }
333
+ return digest;
334
+ }
335
+ }
336
+ /** Digest placement removes only the leaf and adds identity to the provider part. */
337
+ export function digestPart(file, digest) {
338
+ delete file.owner[file.key];
339
+ file.part.sha256 = digest.sha256;
340
+ file.part.size = digest.size;
341
+ }
342
+ export function hashInlineFiles(key, value, count, memo, traceId = "") {
343
+ return hashFiles(key, value, false, count, memo, traceId);
344
+ }
345
+ /** Nested message attributes retain small media and digest larger media without upload state. */
346
+ export function hashNestedInlineFiles(key, value) {
347
+ return hashFiles(key, value, true);
348
+ }
349
+ function hashFiles(key, value, nested, count, memo, traceId = "") {
350
+ if (typeof value !== "string" ||
351
+ !mayInlineFiles(key, value) ||
352
+ value.length > MAX_INLINE_FILE_TEXT)
353
+ return value;
354
+ try {
355
+ const parsed = JSON.parse(value);
356
+ const files = largeInlineFileParts(parsed);
357
+ let changed = false;
358
+ for (const file of files) {
359
+ const decoded = mediaBytes(file);
360
+ if (!decoded.bytes.byteLength)
361
+ continue;
362
+ if ((nested || isTextMediaType(mediaContentType(file, decoded))) &&
363
+ decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
364
+ continue;
365
+ const digest = memo
366
+ ? memo.digest(file, decoded, traceId)
367
+ : {
368
+ sha256: createHash("sha256").update(decoded.bytes).digest("hex"),
369
+ size: decoded.bytes.byteLength,
370
+ };
371
+ digestPart(file, digest);
372
+ changed = true;
373
+ if (count)
374
+ count.files++;
375
+ }
376
+ return changed ? JSON.stringify(parsed) : value;
377
+ }
378
+ catch {
379
+ return value;
380
+ }
176
381
  }
package/dist/privacy.d.ts CHANGED
@@ -33,7 +33,7 @@ export interface RedactionLimits {
33
33
  valueBytes?: number;
34
34
  /** The record's value strings cut when it was queued. */
35
35
  cut?: ReadonlySet<string>;
36
- /** The span's own messages whose large inline files were shrunk to their digest when it was
36
+ /** The span's own messages whose inline media were shrunk to their digest when it was
37
37
  * queued, with how many files each: reported as not uploaded when values may be. */
38
38
  digested?: ReadonlyMap<string, DigestedMessage> | undefined;
39
39
  /**
package/dist/privacy.js CHANGED
@@ -2,7 +2,7 @@ import { SpanStatusCode } from "@opentelemetry/api";
2
2
  import { resourceFromAttributes } from "@opentelemetry/resources";
3
3
  import { MAX_CONTENT_BYTES, MAX_RECORD_NODES, REDACTION_CONTEXT_UNITS } from "./config.js";
4
4
  import { scrubToolCredentials, withToolCatalogSummary } from "./tool-definitions.js";
5
- import { hashInlineFiles, INLINE_FILE_LIMIT, isMessageKey } from "./inline-files.js";
5
+ import { hashInlineFiles, mayInlineFiles } from "./inline-files.js";
6
6
  import { truncateUtf8 } from "./safety.js";
7
7
  export { truncateUtf8 };
8
8
  /** Attribute keys (and their dotted children) removed in metadata-only mode. */
@@ -142,18 +142,12 @@ function redactedText(value, path, options, cap) {
142
142
  throw new Error("Redaction produced unsupported text");
143
143
  return result;
144
144
  }
145
- /** Whether a recorded message's text may inline a file over the inline file limit. */
146
- function mayInlineFiles(key, text) {
147
- return (isMessageKey(key) &&
148
- text.length * 3 > INLINE_FILE_LIMIT &&
149
- (text.includes('"blob"') || text.includes('"file"')));
150
- }
151
145
  /** Marks a value the upload step does not place. */
152
146
  const NOT_HELD = Symbol("not held");
153
147
  /**
154
148
  * One of a span's own attribute values, when its values may be uploaded: text through the
155
149
  * credential scrub and the redactor, whole, and collected when it is over the cap or may inline
156
- * a large file; bytes over the cap collected as they are. {@link NOT_HELD} for any other value,
150
+ * media; bytes over the cap collected as they are. {@link NOT_HELD} for any other value,
157
151
  * and for text the queue cut when the record was admitted, which is never the whole value.
158
152
  */
159
153
  function offloadValue(key, value, path, options, budget) {
@@ -168,7 +162,7 @@ function offloadValue(key, value, path, options, budget) {
168
162
  }
169
163
  if (typeof value !== "string")
170
164
  return NOT_HELD;
171
- // Each large inline file the queue shrank to its digest is reported as not uploaded; what is
165
+ // Each inline media part the queue shrank to its digest is reported as not uploaded; what is
172
166
  // left of the message is placed like any other value, and reported too if it is cut.
173
167
  const digested = budget.digested?.get(key);
174
168
  const files = digested?.text === value ? digested.files : 0;
@@ -271,7 +265,7 @@ function attributes(source, options, path, budget, listPrefix = "", own = false)
271
265
  return [
272
266
  [
273
267
  key,
274
- redactValue(scrubToolCredentials(key, hashInlineFiles(key, value)), `${path}.${key}`, options, budget, 0, `${listPrefix}${key}`),
268
+ redactValue(scrubToolCredentials(key, own ? hashInlineFiles(key, value) : value), `${path}.${key}`, options, budget, 0, `${listPrefix}${key}`),
275
269
  ],
276
270
  ];
277
271
  }));
package/dist/safety.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Span } from "@opentelemetry/api";
2
2
  export declare function noopSpan(): Span;
3
3
  export declare function safeSpan(source: Span, failed: () => void): Span;
4
+ export declare function spanSource(span: Span): Span;
4
5
  /** The bounds `encodeContent` enforces while it copies a value. */
5
6
  export interface EncodeLimits {
6
7
  bytes: number;
package/dist/safety.js CHANGED
@@ -96,8 +96,14 @@ class SafeSpan {
96
96
  this.write(() => this.source.recordException(...args));
97
97
  }
98
98
  }
99
+ const spanSources = new WeakMap();
99
100
  export function safeSpan(source, failed) {
100
- return new SafeSpan(source, failed);
101
+ const span = new SafeSpan(source, failed);
102
+ spanSources.set(span, spanSource(source));
103
+ return span;
104
+ }
105
+ export function spanSource(span) {
106
+ return spanSources.get(span) ?? span;
101
107
  }
102
108
  /** One content attribute's bounds: the value cap, and as many values as the Python SDK's content
103
109
  * snapshot allows, so a structured value of small members is cut at the cap rather than refused
@@ -1,5 +1,6 @@
1
1
  import type { ReadableSpan } from "@opentelemetry/sdk-trace";
2
2
  import type { ReadableLogRecord, ReadWriteLogRecord } from "@opentelemetry/sdk-logs";
3
+ import { type MediaDigestMemo } from "./inline-files.js";
3
4
  /**
4
5
  * How a span's own values over the receiver's value cap are held whole for export to upload:
5
6
  * the cap, the bytes the held-value budget has left, and whether nothing else is held (a single
@@ -12,7 +13,7 @@ export interface HoldLimits {
12
13
  budget: number;
13
14
  first: boolean;
14
15
  }
15
- /** A span's own message whose large inline files shrank to their digest when it was queued: the
16
+ /** A span's own message whose inline media shrank to their digest when it was queued: the
16
17
  * text queued, and how many files it lost. */
17
18
  export interface DigestedMessage {
18
19
  text: string;
@@ -20,7 +21,7 @@ export interface DigestedMessage {
20
21
  }
21
22
  /** What admission keeps of a record: its copy, the bytes charged for it, whether resource
22
23
  * attributes were still unresolved, the value strings it cut, the bytes of the values it held
23
- * whole for upload, and the span's own messages whose large inline files it shrank to their
24
+ * whole for upload, and the span's own messages whose inline media it shrank to their
24
25
  * digest (with how many files each). */
25
26
  export interface RecordSnapshot<T> {
26
27
  record: T;
@@ -29,6 +30,8 @@ export interface RecordSnapshot<T> {
29
30
  cut: ReadonlySet<string>;
30
31
  held: number;
31
32
  digested: ReadonlyMap<string, DigestedMessage>;
33
+ /** The span's own message attributes admission inspected or left to export to inspect. */
34
+ inspected: ReadonlySet<string>;
32
35
  }
33
- export declare function snapshotSpan(source: ReadableSpan, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits): RecordSnapshot<ReadableSpan>;
36
+ export declare function snapshotSpan(source: ReadableSpan, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits, memo?: MediaDigestMemo): RecordSnapshot<ReadableSpan>;
34
37
  export declare function snapshotLog(source: ReadableLogRecord, limit: number, captureContent?: boolean, valueUnits?: number): RecordSnapshot<ReadWriteLogRecord>;