openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
293 lines (292 loc) • 11.7 kB
JavaScript
import { F as resolveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js";
import { c as normalizeOptionalLowercaseString } from "./string-coerce-CIXf7egm.js";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import "./http-body-D3IMwTJJ.js";
import { i as readResponseWithLimit, t as cancelUnreadResponseBody } from "./http-response-body-CwT_cCNz.js";
import { i as logWarn } from "./logger-DwECwNVZ.js";
import { i as fetchWithSsrFGuard } from "./fetch-guard-BMdGQhbb.js";
import { t as parseMediaContentLength } from "./content-length-CHOuQ9D3.js";
import { d as normalizeMimeType, n as detectMime } from "./mime-CVpcq9ju.js";
import { n as estimateBase64DecodedBytes, t as canonicalizeBase64 } from "./base64-Vw7DZYSc.js";
import { i as convertHeicToJpeg } from "./image-ops-DU1SIRgh.js";
import "./media-services-By-i0Drw.js";
import { t as extractPdfContent } from "./pdf-extract-D4eXXKMg.js";
import { n as classifyAttachmentBytes } from "./attachment-classify-DzYPUMr_.js";
import { MIMEType } from "node:util";
//#region src/media/input-files.ts
/** Default MIME allowlist for input_image sources. */
const DEFAULT_INPUT_IMAGE_MIMES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/heic",
"image/heif"
];
/** Default MIME allowlist for input_file text/PDF extraction. */
const DEFAULT_INPUT_FILE_MIMES = [
"text/plain",
"text/markdown",
"text/html",
"text/csv",
"application/json",
"application/pdf"
];
/** Default decoded-byte cap for input_image payloads. */
const DEFAULT_INPUT_IMAGE_MAX_BYTES = 10485760;
/** Default decoded-byte cap for input_file payloads. */
const DEFAULT_INPUT_FILE_MAX_BYTES = 5242880;
/** Default maximum model-visible characters emitted from input_file text. */
const DEFAULT_INPUT_FILE_MAX_CHARS = 6e4;
/** Default timeout for guarded input source URL fetches. */
const DEFAULT_INPUT_TIMEOUT_MS = 1e4;
/** Default PDF page cap for input_file extraction. */
const DEFAULT_INPUT_PDF_MAX_PAGES = 4;
/** Default PDF raster pixel cap for extracted input_file images. */
const DEFAULT_INPUT_PDF_MAX_PIXELS = 4e6;
/** Default text threshold before PDF extraction keeps text-only output. */
const DEFAULT_INPUT_PDF_MIN_TEXT_CHARS = 200;
const NORMALIZED_INPUT_IMAGE_MIME = "image/jpeg";
const HEIC_INPUT_IMAGE_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
function rejectOversizedBase64Payload(params) {
const estimated = estimateBase64DecodedBytes(params.data);
if (estimated > params.maxBytes) throw new Error(`${params.label} too large: ${estimated} bytes (limit: ${params.maxBytes} bytes)`);
}
/** Parses a Content-Type header into normalized MIME and optional charset values. */
function parseContentType(value) {
if (!value) return {};
const mimeType = normalizeMimeType(value);
try {
return {
mimeType,
charset: new MIMEType(value).params.get("charset") ?? void 0
};
} catch {
return { mimeType };
}
}
/** Converts configured MIME lists into a normalized allowlist, using fallback defaults when empty. */
function normalizeMimeList(values, fallback) {
const input = values && values.length > 0 ? values : fallback;
return new Set(input.flatMap((value) => normalizeMimeType(value) ?? []));
}
/** Resolves input_file extraction limits from partial config and stable defaults. */
function resolveInputFileLimits(config) {
return {
allowUrl: config?.allowUrl ?? true,
allowedMimes: normalizeMimeList(config?.allowedMimes, DEFAULT_INPUT_FILE_MIMES),
maxBytes: config?.maxBytes ?? DEFAULT_INPUT_FILE_MAX_BYTES,
maxChars: config?.maxChars ?? DEFAULT_INPUT_FILE_MAX_CHARS,
maxRedirects: config?.maxRedirects ?? 3,
timeoutMs: config?.timeoutMs ?? 1e4,
pdf: {
maxPages: config?.pdf?.maxPages ?? DEFAULT_INPUT_PDF_MAX_PAGES,
maxPixels: config?.pdf?.maxPixels ?? DEFAULT_INPUT_PDF_MAX_PIXELS,
minTextChars: config?.pdf?.minTextChars ?? DEFAULT_INPUT_PDF_MIN_TEXT_CHARS
}
};
}
/** Fetches an input source URL through SSRF, redirect, timeout, and byte-limit guards. */
async function fetchWithGuard(params) {
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
maxRedirects: params.maxRedirects,
timeoutMs: params.timeoutMs,
policy: params.policy,
auditContext: params.auditContext,
init: { headers: { "User-Agent": "OpenClaw-Gateway/1.0" } }
});
try {
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
let contentLength;
try {
contentLength = parseMediaContentLength(response.headers.get("content-length"));
} catch (err) {
await cancelUnreadResponseBody(response);
throw err;
}
if (contentLength !== null && contentLength > params.maxBytes) {
await cancelUnreadResponseBody(response);
throw new Error(`Content too large: ${contentLength} bytes (limit: ${params.maxBytes} bytes)`);
}
return {
buffer: await readResponseWithLimit(response, params.maxBytes),
contentType: response.headers.get("content-type") ?? void 0
};
} finally {
await release();
}
}
function decodeTextContent(buffer, charset) {
const encoding = normalizeOptionalLowercaseString(charset) || "utf-8";
try {
return new TextDecoder(encoding).decode(buffer);
} catch {
return new TextDecoder("utf-8").decode(buffer);
}
}
function withInputFileTimeout(params) {
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 1);
let timeout;
const timedOut = new Promise((_, reject) => {
timeout = setTimeout(() => {
reject(/* @__PURE__ */ new Error(`${params.label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([params.task, timedOut]).finally(() => {
if (timeout) clearTimeout(timeout);
});
}
/** Validates image bytes and converts HEIC/HEIF to JPEG, keeping the original Buffer otherwise. */
async function normalizeInputImageBuffer(params) {
if (params.buffer.byteLength > params.limits.maxBytes) throw new Error(`Image too large: ${params.buffer.byteLength} bytes (limit: ${params.limits.maxBytes} bytes)`);
const declaredMime = normalizeMimeType(params.mimeType) ?? "application/octet-stream";
const detectedMime = normalizeMimeType(await detectMime({
buffer: params.buffer,
headerMime: params.mimeType
}));
if (declaredMime.startsWith("image/") && detectedMime && !detectedMime.startsWith("image/")) throw new Error(`Unsupported image MIME type: ${detectedMime}`);
const sourceMime = (detectedMime?.startsWith("image/") ? detectedMime : declaredMime).replace(/^(image\/hei[cf])-sequence$/, "$1");
if (!params.limits.allowedMimes.has(sourceMime)) throw new Error(`Unsupported image MIME type: ${sourceMime}`);
if (!HEIC_INPUT_IMAGE_MIMES.has(sourceMime)) return {
buffer: params.buffer,
mimeType: sourceMime
};
const normalizedBuffer = await convertHeicToJpeg(params.buffer);
if (normalizedBuffer.byteLength > params.limits.maxBytes) throw new Error(`Image too large after HEIC conversion: ${normalizedBuffer.byteLength} bytes (limit: ${params.limits.maxBytes} bytes)`);
return {
buffer: normalizedBuffer,
mimeType: NORMALIZED_INPUT_IMAGE_MIME
};
}
/** Extracts and normalizes an input_image source from base64 or guarded URL input. */
async function extractImageContentFromSource(source, limits) {
let buffer;
let mimeType;
if (source.type === "base64") {
rejectOversizedBase64Payload({
data: source.data,
maxBytes: limits.maxBytes,
label: "Image"
});
const canonicalData = canonicalizeBase64(source.data);
if (!canonicalData) throw new Error("input_image base64 source has invalid 'data' field");
buffer = Buffer.from(canonicalData, "base64");
mimeType = normalizeMimeType(source.mediaType) ?? "image/png";
} else if (source.type === "url") {
if (!limits.allowUrl) throw new Error("input_image URL sources are disabled by config");
const result = await fetchWithGuard({
url: source.url,
maxBytes: limits.maxBytes,
timeoutMs: limits.timeoutMs,
maxRedirects: limits.maxRedirects,
policy: {
allowPrivateNetwork: false,
hostnameAllowlist: limits.urlAllowlist
},
auditContext: "openresponses.input_image"
});
buffer = result.buffer;
mimeType = parseContentType(result.contentType).mimeType;
} else throw new Error(`Unsupported input_image source type: ${source.type}`);
const image = await normalizeInputImageBuffer({
buffer,
mimeType,
limits
});
return {
type: "image",
data: image.buffer.toString("base64"),
mimeType: image.mimeType
};
}
/** Extracts model-visible text and images from an input_file source after MIME validation. */
async function extractFileContentFromSource(params) {
const { source, limits } = params;
const filename = source.filename || "file";
let buffer;
let mimeType;
let charset;
if (source.type === "base64") {
rejectOversizedBase64Payload({
data: source.data,
maxBytes: limits.maxBytes,
label: "File"
});
const canonicalData = canonicalizeBase64(source.data);
if (!canonicalData) throw new Error("input_file base64 source has invalid 'data' field");
const parsed = parseContentType(source.mediaType);
mimeType = parsed.mimeType;
charset = parsed.charset;
buffer = Buffer.from(canonicalData, "base64");
} else {
if (!limits.allowUrl) throw new Error("input_file URL sources are disabled by config");
const result = await fetchWithGuard({
url: source.url,
maxBytes: limits.maxBytes,
timeoutMs: limits.timeoutMs,
maxRedirects: limits.maxRedirects,
policy: {
allowPrivateNetwork: false,
hostnameAllowlist: limits.urlAllowlist
},
auditContext: "openresponses.input_file"
});
const parsed = parseContentType(result.contentType);
mimeType = parsed.mimeType;
charset = parsed.charset;
buffer = result.buffer;
}
return await extractFileContentFromBuffer({
buffer,
filename,
mimeType,
charset,
limits,
config: params.config
});
}
/** Extracts owned bytes after shared size and MIME checks; no source encoding is required. */
async function extractFileContentFromBuffer(params) {
const { buffer, limits } = params;
const filename = params.filename || "file";
if (buffer.byteLength > limits.maxBytes) throw new Error(`File too large: ${buffer.byteLength} bytes (limit: ${limits.maxBytes} bytes)`);
const classification = params.classification ?? await classifyAttachmentBytes({
buffer,
declaredMime: params.mimeType
});
const mimeType = classification.mime;
const charset = classification.charset ?? params.charset;
if (!mimeType) throw new Error("input_file missing media type");
if (!limits.allowedMimes.has(mimeType)) throw new Error(`Unsupported file MIME type: ${mimeType}`);
if (mimeType === "application/pdf") {
const extracted = await withInputFileTimeout({
label: "PDF extraction",
timeoutMs: limits.timeoutMs,
task: extractPdfContent({
buffer,
maxPages: limits.pdf.maxPages,
maxPixels: limits.pdf.maxPixels,
minTextChars: limits.pdf.minTextChars,
...params.config ? { config: params.config } : {},
onImageExtractionError: (err) => {
logWarn(`media: PDF image extraction skipped, ${String(err)}`);
}
})
});
return {
filename,
text: extracted.text ? truncateUtf16Safe(extracted.text, limits.maxChars) : "",
images: extracted.images.length > 0 ? extracted.images : void 0
};
}
return {
filename,
text: truncateUtf16Safe(decodeTextContent(buffer, charset), limits.maxChars)
};
}
//#endregion
export { extractFileContentFromSource as a, normalizeMimeList as c, extractFileContentFromBuffer as i, resolveInputFileLimits as l, DEFAULT_INPUT_IMAGE_MIMES as n, extractImageContentFromSource as o, DEFAULT_INPUT_TIMEOUT_MS as r, normalizeInputImageBuffer as s, DEFAULT_INPUT_IMAGE_MAX_BYTES as t };