UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

293 lines (292 loc) 11.7 kB
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 };