Skip to content
Source
Sign inSign up

toolOutputImageBytes.ts

114 lines3.8 KB

1import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
2import {
3 MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH,
4 readToolOutputImage,
5 toolOutputImageBlocks,
6} from "@t3tools/shared/toolOutput";
7​
8const IMAGE_MIME_KEYS = ["mimeType", "mime_type", "media_type", "type"] as const;
9const IMAGE_BODY_KEYS = new Set(["data", "blob", "base64"]);
10const MAX_DEPTH = 32;
11const MIN_BODY_LENGTH = 64;
12const BASE64_BODY = /^[A-Za-z0-9+/_-]+={0,2}$/;
13​
14type UnknownRecord = Record<string, unknown>;
15​
16function hasImageMime(record: UnknownRecord): boolean {
17 return IMAGE_MIME_KEYS.some((key) => {
18 const mime = record[key];
19 return typeof mime === "string" && mime.toLowerCase().startsWith("image/");
20 });
22​
23function isImageBody(record: UnknownRecord, key: string | undefined): boolean {
24 if (record.type === "image" || key === "image") return true;
25 if (!hasImageMime(record)) return false;
26 return (
27 record.type === undefined ||
28 record.type === "base64" ||
29 (typeof record.type === "string" && record.type.toLowerCase().startsWith("image/"))
30 );
32​
33function isBase64Body(value: unknown): value is string {
34 return typeof value === "string" && value.length >= MIN_BODY_LENGTH && BASE64_BODY.test(value);
36​
37function base64DecodedLength(encoded: string): number {
38 let length = encoded.length;
39 while (length > 0 && encoded.charCodeAt(length - 1) === 0x3d) {
40 length -= 1;
41 }
42 return Math.floor((length * 3) / 4);
44​
45interface StripContext {
46 readonly output: unknown;
47 served: ReadonlyArray<unknown> | undefined;
49​
50function isServed(context: StripContext, record: UnknownRecord): boolean {
51 context.served ??= toolOutputImageBlocks(context.output);
52 if (!context.served.includes(record)) return false;
53 const data = readToolOutputImage(record)?.data;
54 return data === undefined || data.length <= MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH;
56​
57function strip(
58 value: unknown,
59 key: string | undefined,
60 context: StripContext,
61 depth: number,
62): unknown {
63 if (depth > MAX_DEPTH || typeof value !== "object" || value === null) {
64 return value;
65 }
66 if (Array.isArray(value)) {
67 let next: Array<unknown> | undefined;
68 for (let index = 0; index < value.length; index += 1) {
69 const entry = value[index];
70 const stripped = strip(entry, undefined, context, depth + 1);
71 if (stripped !== entry) {
72 next ??= value.slice();
73 next[index] = stripped;
74 }
75 }
76 return next ?? value;
77 }
78 const record = value as UnknownRecord;
79 if (record.type === "image" && isServed(context, record)) return record;
80 const imageBody = isImageBody(record, key);
81 let next: UnknownRecord | undefined;
82 let removedBody: string | undefined;
83 for (const entryKey in record) {
84 if (!Object.hasOwn(record, entryKey)) continue;
85 const entry = record[entryKey];
86 if (imageBody && IMAGE_BODY_KEYS.has(entryKey) && isBase64Body(entry)) {
87 next ??= { ...record };
88 delete next[entryKey];
89 removedBody = entry;
90 continue;
91 }
92 const stripped = strip(entry, entryKey, context, depth + 1);
93 if (stripped !== entry) {
94 next ??= { ...record };
95 next[entryKey] = stripped;
96 }
97 }
98 if (next !== undefined && removedBody !== undefined) {
99 next.sizeBytes = base64DecodedLength(removedBody);
100 }
101 return next ?? record;
103​
104/** Image bytes are stored twice; retain only size-limited toolOutputImageBlocks that are served.
105 * Read-file previews use viewedImagePath, and context handoffs skip tools. */
106export function stripUnservedToolOutputImageBytes(
107 item: OrchestrationV2TurnItem,
108): OrchestrationV2TurnItem {
109 if (item.type !== "dynamic_tool" || typeof item.output !== "object" || item.output === null) {
110 return item;
111 }
112 const output = strip(item.output, undefined, { output: item.output, served: undefined }, 0);
113 return output === item.output ? item : { ...item, output };