@hue-run/sdk 0.16.0 → 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.
- package/CLI.md +18 -11
- package/README.md +68 -43
- package/dist/blobs.d.ts +35 -17
- package/dist/blobs.js +141 -75
- package/dist/cli/eval-direct.d.ts +3 -2
- package/dist/cli/eval-direct.js +49 -6
- package/dist/client.d.ts +3 -1
- package/dist/client.js +70 -20
- package/dist/evals/files.js +12 -0
- package/dist/inline-files.d.ts +38 -29
- package/dist/inline-files.js +288 -83
- package/dist/privacy.d.ts +1 -1
- package/dist/privacy.js +4 -10
- package/dist/safety.d.ts +1 -0
- package/dist/safety.js +7 -1
- package/dist/snapshot.d.ts +6 -3
- package/dist/snapshot.js +70 -25
- package/dist/transport.d.ts +11 -0
- package/dist/transport.js +296 -20
- package/dist/types.d.ts +7 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { isMediaName } from "./inline-files.js";
|
|
3
4
|
import { context, isSpanContextValid, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
4
5
|
import { defaultTextMapGetter, defaultTextMapSetter } from "@opentelemetry/api";
|
|
5
6
|
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
@@ -10,7 +11,7 @@ import { defaultResource, resourceFromAttributes } from "@opentelemetry/resource
|
|
|
10
11
|
import { HUE_SCOPE } from "./config.js";
|
|
11
12
|
import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, providerErrorDescription, } from "./provider-tools.js";
|
|
12
13
|
import { toolCatalogSummary } from "./tool-definitions.js";
|
|
13
|
-
import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, } from "./safety.js";
|
|
14
|
+
import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, spanSource, } from "./safety.js";
|
|
14
15
|
import { TRUNCATED_KEY, withTruncatedKeys } from "./privacy.js";
|
|
15
16
|
import { MAX_BLOB_BYTES } from "./blobs.js";
|
|
16
17
|
import { createHueTransport, exportBudgetMillis, HueExportError, } from "./transport.js";
|
|
@@ -662,7 +663,7 @@ export class HueClient {
|
|
|
662
663
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
663
664
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
664
665
|
* and, when `captureContent` is true, `hue.file.name`. `data` is hashed and measured locally and
|
|
665
|
-
*
|
|
666
|
+
* uploaded only with `upload: true`. The event is metadata, so it is recorded in both capture modes. An invalid
|
|
666
667
|
* record, or one without an active span, is omitted and counted, never thrown; an invalid name
|
|
667
668
|
* alone is omitted and counted while the rest is recorded.
|
|
668
669
|
*/
|
|
@@ -680,26 +681,30 @@ export class HueClient {
|
|
|
680
681
|
throw new TypeError("Invalid media type");
|
|
681
682
|
let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
|
|
682
683
|
let byteSize = file.byteSize;
|
|
684
|
+
let bytes;
|
|
683
685
|
if (data !== undefined) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return Buffer.from(data, "utf8");
|
|
692
|
-
})()
|
|
693
|
-
: data instanceof Uint8Array
|
|
694
|
-
? data.byteLength <= MAX_FILE_DATA_BYTES
|
|
695
|
-
? data
|
|
696
|
-
: (() => {
|
|
686
|
+
bytes =
|
|
687
|
+
typeof data === "string"
|
|
688
|
+
? (() => {
|
|
689
|
+
// Buffer.byteLength measures UTF-8 without allocating the copy that hashing would
|
|
690
|
+
// otherwise require. Reject before Buffer.from/createHash can retain large input.
|
|
691
|
+
if (data.length > MAX_FILE_DATA_BYTES ||
|
|
692
|
+
Buffer.byteLength(data, "utf8") > MAX_FILE_DATA_BYTES)
|
|
697
693
|
throw new RangeError("File data exceeds Hue's 25 MiB limit");
|
|
698
|
-
|
|
699
|
-
|
|
694
|
+
return Buffer.from(data, "utf8");
|
|
695
|
+
})()
|
|
696
|
+
: data instanceof Uint8Array
|
|
697
|
+
? data.byteLength <= MAX_FILE_DATA_BYTES
|
|
698
|
+
? data
|
|
699
|
+
: (() => {
|
|
700
|
+
throw new RangeError("File data exceeds Hue's 25 MiB limit");
|
|
701
|
+
})()
|
|
702
|
+
: undefined;
|
|
700
703
|
if (!bytes)
|
|
701
704
|
throw new TypeError("File data must be bytes or a string");
|
|
702
|
-
const digest =
|
|
705
|
+
const digest = bytes.byteLength > 0 && file.upload === true && this.transport.holdsValues()
|
|
706
|
+
? this.transport.digestMedia(bytes, span.spanContext().traceId).sha256
|
|
707
|
+
: createHash("sha256").update(bytes).digest("hex");
|
|
703
708
|
// A caller-supplied digest or size must describe the same bytes.
|
|
704
709
|
if ((sha256 !== undefined && sha256 !== digest) ||
|
|
705
710
|
(byteSize !== undefined && byteSize !== bytes.byteLength))
|
|
@@ -719,12 +724,55 @@ export class HueClient {
|
|
|
719
724
|
if (byteSize !== undefined)
|
|
720
725
|
attributes["hue.file.size"] = byteSize;
|
|
721
726
|
if (this.captureContent && name !== undefined) {
|
|
722
|
-
if (
|
|
727
|
+
if (isMediaName(name))
|
|
723
728
|
attributes["hue.file.name"] = name;
|
|
724
729
|
else
|
|
725
730
|
this.instrumentationFailed();
|
|
726
731
|
}
|
|
727
|
-
span
|
|
732
|
+
// Let OpenTelemetry use the span's wall-clock anchor, then keep this event's exact
|
|
733
|
+
// timestamp for upload admission. A dropped event cannot authorize an upload.
|
|
734
|
+
const source = spanSource(span);
|
|
735
|
+
const previous = this.fileEventTimes.get(source);
|
|
736
|
+
const after = (time) => {
|
|
737
|
+
if (previous &&
|
|
738
|
+
(time[0] < previous[0] || (time[0] === previous[0] && time[1] <= previous[1]))) {
|
|
739
|
+
time[0] = previous[0];
|
|
740
|
+
time[1] = previous[1] + 1;
|
|
741
|
+
if (time[1] === 1_000_000_000) {
|
|
742
|
+
time[0]++;
|
|
743
|
+
time[1] = 0;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return time;
|
|
747
|
+
};
|
|
748
|
+
let eventTime;
|
|
749
|
+
if (Array.isArray(source.events)) {
|
|
750
|
+
const previousEvent = source.events.at(-1);
|
|
751
|
+
span.addEvent("hue.file", attributes);
|
|
752
|
+
const event = source.events.at(-1);
|
|
753
|
+
eventTime = event && event !== previousEvent ? after(event.time) : undefined;
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
// A span that does not expose its events (an API-only wrapper in attach mode): stamp the
|
|
757
|
+
// event with the wall clock and keep that exact time for upload admission.
|
|
758
|
+
const now = Date.now();
|
|
759
|
+
eventTime = after([Math.floor(now / 1000), (now % 1000) * 1_000_000]);
|
|
760
|
+
span.addEvent("hue.file", attributes, [eventTime[0], eventTime[1]]);
|
|
761
|
+
}
|
|
762
|
+
if (eventTime)
|
|
763
|
+
this.fileEventTimes.set(source, eventTime);
|
|
764
|
+
if (file.upload === true && this.transport.offloadsValues()) {
|
|
765
|
+
if (!this.transport.stageMedia(span, bytes && eventTime
|
|
766
|
+
? {
|
|
767
|
+
bytes,
|
|
768
|
+
digest: { sha256, size: bytes.byteLength },
|
|
769
|
+
contentType: mediaType,
|
|
770
|
+
eventTime,
|
|
771
|
+
...(isMediaName(name) ? { name } : {}),
|
|
772
|
+
}
|
|
773
|
+
: undefined))
|
|
774
|
+
this.instrumentationFailed();
|
|
775
|
+
}
|
|
728
776
|
}
|
|
729
777
|
catch {
|
|
730
778
|
this.instrumentationFailed();
|
|
@@ -732,6 +780,8 @@ export class HueClient {
|
|
|
732
780
|
}
|
|
733
781
|
/** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
|
|
734
782
|
truncatedKeys = new WeakMap();
|
|
783
|
+
/** Distinguishes requests whose helper events otherwise have identical metadata. */
|
|
784
|
+
fileEventTimes = new WeakMap();
|
|
735
785
|
/** A content value's bounds: Hue's value cap as the receiver last advertised it. */
|
|
736
786
|
contentLimits() {
|
|
737
787
|
return contentLimitsOf(this.transport.receiverLimits().valueBytes);
|
package/dist/evals/files.js
CHANGED
|
@@ -30,6 +30,18 @@ export const outputContentTypes = [
|
|
|
30
30
|
"image/png",
|
|
31
31
|
"image/jpeg",
|
|
32
32
|
"image/webp",
|
|
33
|
+
"image/gif",
|
|
34
|
+
"image/avif",
|
|
35
|
+
"audio/mpeg",
|
|
36
|
+
"audio/wav",
|
|
37
|
+
"audio/ogg",
|
|
38
|
+
"audio/webm",
|
|
39
|
+
"audio/mp4",
|
|
40
|
+
"audio/flac",
|
|
41
|
+
"audio/opus",
|
|
42
|
+
"audio/aac",
|
|
43
|
+
"video/mp4",
|
|
44
|
+
"video/webm",
|
|
33
45
|
];
|
|
34
46
|
/** Thrown when the target's declared files cannot be used; recorded as the target's error. */
|
|
35
47
|
export class OutputFileError extends Error {
|
package/dist/inline-files.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Text media above this decoded byte limit is offloaded; binary media always uses a digest. */
|
|
2
2
|
export declare const INLINE_FILE_LIMIT: number;
|
|
3
|
-
/** The longest message text, in UTF-
|
|
3
|
+
/** The longest message text, in UTF-16 code units, inspected for inline files. */
|
|
4
4
|
export declare const MAX_INLINE_FILE_TEXT: number;
|
|
5
5
|
/** Message text one record inspects for inline files in all, in UTF-16 code units. */
|
|
6
6
|
export declare const INLINE_FILE_TEXT_PER_RECORD: number;
|
|
@@ -20,38 +20,47 @@ export interface InlineFileBytes {
|
|
|
20
20
|
* `data:` URL whose data does not decode is taken as UTF-8 too.
|
|
21
21
|
*/
|
|
22
22
|
export declare function inlineFileBytes(content: string): InlineFileBytes;
|
|
23
|
-
/**
|
|
24
|
-
export declare function contentKey(part: Record<string, unknown>): "content" | "data" | undefined;
|
|
25
|
-
/**
|
|
26
|
-
* Replaces inline files longer than {@link INLINE_FILE_LIMIT} in a recorded message attribute
|
|
27
|
-
* with their SHA-256 and byte size, so a span that inlines a large file exports the file's
|
|
28
|
-
* identity instead of being rejected for its size. The part keeps its other fields (`type`,
|
|
29
|
-
* `mime_type`, `modality`, `mediaType`, …). Other attributes, shorter messages and values that
|
|
30
|
-
* are not JSON are returned unchanged.
|
|
31
|
-
*/
|
|
32
|
-
export declare function hashInlineFiles(key: string, value: unknown,
|
|
33
|
-
/** Counts the files replaced by their digest, when given. */
|
|
34
|
-
count?: {
|
|
35
|
-
files: number;
|
|
36
|
-
}): unknown;
|
|
37
|
-
/** One inline file part found in a parsed message: the part object, the key holding its inline
|
|
38
|
-
* content, that content, and the part's content as a JSON Pointer (RFC 6901) into the message. */
|
|
23
|
+
/** A part and the exact leaf that contains its bytes. Nested provider metadata stays in place. */
|
|
39
24
|
export interface InlineFilePart {
|
|
40
25
|
part: Record<string, unknown>;
|
|
41
|
-
|
|
26
|
+
owner: Record<string, unknown>;
|
|
27
|
+
key: string;
|
|
42
28
|
content: string;
|
|
43
29
|
pointer: string;
|
|
30
|
+
partPointer: string;
|
|
31
|
+
contentType?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
base64?: boolean;
|
|
44
34
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
*/
|
|
35
|
+
export declare function isMediaName(value: unknown): value is string;
|
|
36
|
+
/** Whether the receiver can preview these bytes as text. */
|
|
37
|
+
export declare function isTextMediaType(contentType: string): boolean;
|
|
38
|
+
/** Cheap shared prefilter before bounded parsing of span message attributes. */
|
|
39
|
+
export declare function mayInlineFiles(key: string, value: unknown): boolean;
|
|
40
|
+
/** A printable MIME type with optional parameters, accepted by the upload contract. */
|
|
41
|
+
export declare function isMediaContentType(value: unknown): value is string;
|
|
42
|
+
/** Detect the content pointer, MIME, name, and encoding contract of a provider media part. */
|
|
43
|
+
export declare function contentPath(part: Record<string, unknown>, pointer?: string): InlineFilePart | undefined;
|
|
44
|
+
/** Inline media in document order. The caller applies the decoded text threshold. */
|
|
51
45
|
export declare function largeInlineFileParts(value: unknown): InlineFilePart[];
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
export
|
|
46
|
+
export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
|
|
47
|
+
export declare function mediaContentType(file: InlineFilePart, decoded: InlineFileBytes): string;
|
|
48
|
+
export interface FileDigest {
|
|
55
49
|
sha256: string;
|
|
56
50
|
size: number;
|
|
57
|
-
}
|
|
51
|
+
}
|
|
52
|
+
/** Bounded payload identity memo, shared by admission and export, cleared at shutdown. */
|
|
53
|
+
export declare class MediaDigestMemo {
|
|
54
|
+
private entries;
|
|
55
|
+
private units;
|
|
56
|
+
clear(): void;
|
|
57
|
+
digest(file: InlineFilePart, decoded?: InlineFileBytes, traceId?: string): FileDigest;
|
|
58
|
+
bytes(bytes: Uint8Array, traceId: string): FileDigest;
|
|
59
|
+
}
|
|
60
|
+
/** Digest placement removes only the leaf and adds identity to the provider part. */
|
|
61
|
+
export declare function digestPart(file: InlineFilePart, digest: FileDigest): void;
|
|
62
|
+
export declare function hashInlineFiles(key: string, value: unknown, count?: {
|
|
63
|
+
files: number;
|
|
64
|
+
}, memo?: MediaDigestMemo, traceId?: string): unknown;
|
|
65
|
+
/** Nested message attributes retain small media and digest larger media without upload state. */
|
|
66
|
+
export declare function hashNestedInlineFiles(key: string, value: unknown): unknown;
|
package/dist/inline-files.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { MAX_BODY_BYTES } from "./config.js";
|
|
3
|
-
/**
|
|
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-
|
|
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
|
-
/**
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
275
|
+
const part = object(node);
|
|
276
|
+
if (!part)
|
|
152
277
|
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}` });
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
/**
|