refactor(agents): split transcript image redaction helpers (#112337)

This commit is contained in:
Peter Steinberger
2026-07-21 14:18:26 -07:00
committed by GitHub
parent 35e058711d
commit d04519e158
2 changed files with 144 additions and 133 deletions
+129
View File
@@ -0,0 +1,129 @@
import {
sanitizeInlineImageBase64,
sanitizeInlineImageDataUrlForStorage,
} from "@openclaw/media-core/inline-image-data-url";
const isImageMimeType = (value: unknown): value is string =>
typeof value === "string" && /^image\//iu.test(value.trim());
const normalizeImageMimeType = (value: unknown): string | undefined =>
isImageMimeType(value) ? value.trim().toLowerCase() : undefined;
function imageMimeTypeForRecord(value: Record<string, unknown>): string | undefined {
return (
normalizeImageMimeType(value.mimeType) ??
normalizeImageMimeType(value.mediaType) ??
normalizeImageMimeType(value.media_type)
);
}
function imageMimeTypeFieldsForRecord(value: Record<string, unknown>): string[] {
return ["mimeType", "mediaType", "media_type"].filter((key) => isImageMimeType(value[key]));
}
function sanitizeOpaqueImageBase64(
base64: string,
mimeType: string | undefined,
): { mimeType: string; base64: string } | undefined {
return mimeType ? sanitizeInlineImageBase64({ mimeType, base64 }) : undefined;
}
function isValidOpaqueImageBase64(base64: string, mimeType: string | undefined): boolean {
return sanitizeOpaqueImageBase64(base64, mimeType) !== undefined;
}
function isOpaqueImageDataBlock(value: Record<string, unknown>): boolean {
return (
(value.type === "image" || value.type === "base64") &&
typeof value.data === "string" &&
isValidOpaqueImageBase64(value.data, imageMimeTypeForRecord(value))
);
}
export function sanitizeTranscriptImageRecord(
source: Record<string, unknown>,
): Record<string, unknown> | undefined {
const isImageBlock = source.type === "image";
const isBase64SourceBlock = source.type === "base64";
if ((!isImageBlock && !isBase64SourceBlock) || typeof source.data !== "string") {
return undefined;
}
const mimeTypeFields = imageMimeTypeFieldsForRecord(source);
if (mimeTypeFields.length === 0) {
return undefined;
}
const sanitized = sanitizeOpaqueImageBase64(source.data, imageMimeTypeForRecord(source));
if (!sanitized) {
return undefined;
}
const hasCanonicalMimeTypes = mimeTypeFields.every((key) => source[key] === sanitized.mimeType);
if (source.data === sanitized.base64 && hasCanonicalMimeTypes) {
return source;
}
const next: Record<string, unknown> = { ...source, data: sanitized.base64 };
for (const field of mimeTypeFields) {
next[field] = sanitized.mimeType;
}
return next;
}
function startsWithDataUrl(value: string): boolean {
return value.slice(0, "data:".length).toLowerCase() === "data:";
}
function sanitizeImageDataUrlField(
source: Record<string, unknown>,
key: string,
value: string,
): string | undefined {
if (!startsWithDataUrl(value)) {
return undefined;
}
const isImageDataUrlField =
(source.type === "input_image" && key === "image_url") ||
((source.type === "image" || source.type === "image_url") && key === "url") ||
(source.type === "image" && (key === "source" || key === "data"));
return isImageDataUrlField ? sanitizeInlineImageDataUrlForStorage(value) : undefined;
}
export function sanitizeTranscriptImageDataUrlField(params: {
source: Record<string, unknown>;
key: string;
value: string;
preserveImageDataUrlFields: boolean;
}): string | undefined {
if (params.preserveImageDataUrlFields && params.key === "url") {
return startsWithDataUrl(params.value)
? sanitizeInlineImageDataUrlForStorage(params.value)
: undefined;
}
return sanitizeImageDataUrlField(params.source, params.key, params.value);
}
export function shouldPreserveTranscriptImagePayload(
source: Record<string, unknown>,
key: string,
item: unknown,
preserveImageDataUrlFields: boolean,
): boolean {
if (typeof item !== "string") {
return false;
}
if (key === "data" && isOpaqueImageDataBlock(source)) {
return true;
}
if (preserveImageDataUrlFields && key === "url") {
return startsWithDataUrl(item) && sanitizeInlineImageDataUrlForStorage(item) !== undefined;
}
return sanitizeImageDataUrlField(source, key, item) !== undefined;
}
export function shouldPreserveNestedTranscriptImageDataUrlFields(
source: Record<string, unknown>,
key: string,
): boolean {
return (
key === "image_url" &&
(source.type === "image_url" || source.type === "input_image" || source.type === "image")
);
}
+15 -133
View File
@@ -3,10 +3,6 @@
*
* Applies logging redaction rules to persisted messages while preserving unchanged object identity.
*/
import {
sanitizeInlineImageBase64,
sanitizeInlineImageDataUrlForStorage,
} from "@openclaw/media-core/inline-image-data-url";
import { findNormalizedProviderValue } from "@openclaw/model-catalog-core/provider-id";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readLoggingConfig } from "../logging/config.js";
@@ -18,6 +14,12 @@ import {
import type { ProviderEndpointClass } from "./provider-attribution.js";
import { resolveProviderEndpoint } from "./provider-attribution.js";
import type { AgentMessage } from "./runtime/index.js";
import {
sanitizeTranscriptImageDataUrlField,
sanitizeTranscriptImageRecord,
shouldPreserveNestedTranscriptImageDataUrlFields,
shouldPreserveTranscriptImagePayload,
} from "./transcript-redact-images.js";
function resolveTranscriptRedactPatterns(patterns?: string[]) {
return patterns && patterns.length > 0 ? [...patterns, ...getDefaultRedactPatterns()] : undefined;
@@ -68,126 +70,6 @@ function isPlainTranscriptObject(value: object): value is Record<string, unknown
return prototype === Object.prototype || prototype === null;
}
const isImageMimeType = (value: unknown): value is string =>
typeof value === "string" && /^image\//iu.test(value.trim());
const normalizeImageMimeType = (value: unknown): string | undefined =>
isImageMimeType(value) ? value.trim().toLowerCase() : undefined;
function imageMimeTypeForRecord(value: Record<string, unknown>): string | undefined {
return (
normalizeImageMimeType(value.mimeType) ??
normalizeImageMimeType(value.mediaType) ??
normalizeImageMimeType(value.media_type)
);
}
function imageMimeTypeFieldsForRecord(value: Record<string, unknown>): string[] {
return ["mimeType", "mediaType", "media_type"].filter((key) => isImageMimeType(value[key]));
}
function sanitizeOpaqueImageBase64(
base64: string,
mimeType: string | undefined,
): { mimeType: string; base64: string } | undefined {
return mimeType ? sanitizeInlineImageBase64({ mimeType, base64 }) : undefined;
}
function isValidOpaqueImageBase64(base64: string, mimeType: string | undefined): boolean {
return sanitizeOpaqueImageBase64(base64, mimeType) !== undefined;
}
function isTranscriptImageContentBlock(value: Record<string, unknown>): boolean {
return (
value.type === "image" &&
typeof value.data === "string" &&
isValidOpaqueImageBase64(value.data, imageMimeTypeForRecord(value))
);
}
function isImageBase64SourceBlock(value: Record<string, unknown>): boolean {
return (
value.type === "base64" &&
typeof value.data === "string" &&
isValidOpaqueImageBase64(value.data, imageMimeTypeForRecord(value))
);
}
function sanitizeImageRecord(source: Record<string, unknown>): Record<string, unknown> | undefined {
const isImageBlock = source.type === "image";
const isBase64SourceBlock = source.type === "base64";
if ((!isImageBlock && !isBase64SourceBlock) || typeof source.data !== "string") {
return undefined;
}
const mimeTypeFields = imageMimeTypeFieldsForRecord(source);
if (mimeTypeFields.length === 0) {
return undefined;
}
const sanitized = sanitizeOpaqueImageBase64(source.data, imageMimeTypeForRecord(source));
if (!sanitized) {
return undefined;
}
const hasCanonicalMimeTypes = mimeTypeFields.every((key) => source[key] === sanitized.mimeType);
if (source.data === sanitized.base64 && hasCanonicalMimeTypes) {
return source;
}
const next: Record<string, unknown> = { ...source, data: sanitized.base64 };
for (const field of mimeTypeFields) {
next[field] = sanitized.mimeType;
}
return next;
}
function startsWithDataUrl(value: string): boolean {
return value.slice(0, "data:".length).toLowerCase() === "data:";
}
function sanitizeImageDataUrlField(
source: Record<string, unknown>,
key: string,
value: string,
): string | undefined {
if (!startsWithDataUrl(value)) {
return undefined;
}
const isImageDataUrlField =
(source.type === "input_image" && key === "image_url") ||
((source.type === "image" || source.type === "image_url") && key === "url") ||
(source.type === "image" && (key === "source" || key === "data"));
return isImageDataUrlField ? sanitizeInlineImageDataUrlForStorage(value) : undefined;
}
function shouldPreserveOpaqueImagePayload(
source: Record<string, unknown>,
key: string,
item: unknown,
preserveImageDataUrlFields: boolean,
): boolean {
if (typeof item !== "string") {
return false;
}
if (
key === "data" &&
(isTranscriptImageContentBlock(source) || isImageBase64SourceBlock(source))
) {
return true;
}
if (preserveImageDataUrlFields && key === "url") {
return startsWithDataUrl(item) && sanitizeInlineImageDataUrlForStorage(item) !== undefined;
}
return sanitizeImageDataUrlField(source, key, item) !== undefined;
}
function shouldPreserveNestedImageDataUrlFields(
source: Record<string, unknown>,
key: string,
): boolean {
return (
key === "image_url" &&
(source.type === "image_url" || source.type === "input_image" || source.type === "image")
);
}
type TranscriptValueLocation =
| "root"
| "assistant-content-array"
@@ -628,7 +510,7 @@ function redactTranscriptStructuredValue(
}
seen.add(value);
const sanitizedImageRecord = sanitizeImageRecord(value);
const sanitizedImageRecord = sanitizeTranscriptImageRecord(value);
const source = sanitizedImageRecord ?? value;
const currentAssistantRoute =
location === "root" && source.role === "assistant"
@@ -708,12 +590,12 @@ function redactTranscriptStructuredValue(
continue;
}
if (typeof item === "string") {
const sanitizedDataUrl =
preserveImageDataUrlFields && key === "url"
? startsWithDataUrl(item)
? sanitizeInlineImageDataUrlForStorage(item)
: undefined
: sanitizeImageDataUrlField(source, key, item);
const sanitizedDataUrl = sanitizeTranscriptImageDataUrlField({
source,
key,
value: item,
preserveImageDataUrlFields,
});
if (sanitizedDataUrl !== undefined) {
if (sanitizedDataUrl !== item) {
next ??= { ...source };
@@ -722,7 +604,7 @@ function redactTranscriptStructuredValue(
continue;
}
}
if (shouldPreserveOpaqueImagePayload(source, key, item, preserveImageDataUrlFields)) {
if (shouldPreserveTranscriptImagePayload(source, key, item, preserveImageDataUrlFields)) {
continue;
}
const redacted = redactTranscriptStructuredValue(
@@ -730,7 +612,7 @@ function redactTranscriptStructuredValue(
cfg,
key,
seen,
preserveImageDataUrlFields || shouldPreserveNestedImageDataUrlFields(source, key),
preserveImageDataUrlFields || shouldPreserveNestedTranscriptImageDataUrlFields(source, key),
location === "root" && source.role === "assistant" && key === "content" && Array.isArray(item)
? "assistant-content-array"
: "nested",