/** * Shared normalization/validation of user image attachments. * * The same coercion existed in four places (relay dispatcher, prompt-queue * handler, storage, session-stream queue drain) and every copy assumed * `data` was mandatory. Attachments now travel as a hosted `url` by default * (S3-backed, see `src/backend/generated-images.ts`) and inline `data` is * only a legacy fallback with a hard size cap — so the logic lives here once. * * Keep this file dependency-free (same constraint as `wire.ts`). * * @module @spectral/server/image-attachments */ import type { ImageAttachment } from "./wire.js"; /** * Hard cap for inline base64 attachments, measured on the base64 STRING * (`data.length`) — i.e. on what actually travels through the relay as JSON. * * The base64/decoded relation is easy to get backwards: base64 is ~1.37× * LONGER than the bytes it encodes, so a cap expressed in decoded bytes * always produces a bigger wire payload than the cap suggests. 768 KiB of * base64 is ~576 KiB of decoded image, which leaves headroom under * `MAX_BYTES_PER_CHUNK` (1 MiB, `src/relay/history-chunker.ts`) so a * compliant attachment still fits in a single history chunk instead of * overflowing into an oversized solo chunk. * * Enforced at every ingress point (relay dispatcher, prompt queue, HTTP * handlers) so the relay never carries megabytes per message. */ export declare const MAX_INLINE_IMAGE_BASE64_CHARS: number; /** * Wire cost of an inline payload: the number of characters that travel as * JSON. Base64 has no characters that JSON escapes, so `data.length` is an * exact (upper-bound for legacy `data:` URIs) wire size. */ export declare function inlineImageWireLength(data: string): number; /** * Decode-length of a base64 payload without allocating: base64 encodes 3 * bytes per 4 characters, minus one byte per `=` pad character. Tolerates a * `data:` URI prefix (legacy) and whitespace. */ export declare function inlineImageByteLength(data: string): number; /** Human-readable size for error messages, e.g. `"2.4 MiB"`. */ export declare function formatImageBytes(bytes: number): string; export interface CoerceImagesOptions { /** Called for every dropped attachment with a human-readable reason. */ onReject?: (reason: string) => void; /** * Accept inline base64 of ANY size. Only for the READ path (rows already * persisted): legacy sessions are never migrated, so enforcing the * ingress cap here would silently delete images the user can still see. * New/ingress messages always enforce `MAX_INLINE_IMAGE_BASE64_CHARS`. */ allowOversizeInline?: boolean; } /** * Coerce a raw wire `images` payload into `ImageAttachment[]` (or `undefined` * when nothing usable survives). * * Rules: * - `mimeType` is required; everything else is optional. * - `url` (or `data`) must be present — an attachment with neither is * dropped, it can never be rendered or sent to a provider. * - inline `data` longer than `MAX_INLINE_IMAGE_BASE64_CHARS` is dropped: * the client is expected to upload those and send a `url`. * - when both `url` and `data` are present the url wins and only the * payload is dropped — a hosted attachment is never rejected just because * it also carries an oversized (or stale) base64 payload. * - `url` and `imageId` are preserved so the attachment survives a * round-trip through `messages.images_json` / `prompt_queue.images_json`. */ export declare function coerceImages(raw: unknown, options?: CoerceImagesOptions): ImageAttachment[] | undefined; /** Check whether a raw wire `images` array has at least one usable entry. */ export declare function hasImages(raw: unknown): boolean; export interface ParseImagesJsonOptions { /** Called for every dropped attachment (defaults to a `console.warn`). */ onReject?: (reason: string) => void; } /** * Best-effort parse of an `images_json` column into `ImageAttachment[]`. * * READ path — deliberately lenient: `allowOversizeInline` is always on, so * legacy rows predating the inline cap keep their base64 instead of being * dropped on every read (we never rewrite old sessions). Only structurally * broken entries (no mimeType, no url and no data) disappear, and they are * reported through `onReject` so nothing vanishes without a trace. */ export declare function parseImagesJson(raw: string, options?: ParseImagesJsonOptions): ImageAttachment[] | undefined; /** Flat token cost billed for an inline base64 image. */ export declare const INLINE_IMAGE_TOKENS = 2000; /** * Floor for a hosted (`url`) image. Providers bill a real image — hundreds * to ~2 000 tokens depending on size — no matter how short its url is, so * replay/compaction estimates must never treat a screenshot as a few * characters of text (a dozen of those silently overflow the context). */ export declare const HOSTED_IMAGE_TOKEN_FLOOR = 1200; /** * Approximate token cost of an attachment for replay/compaction estimates. * * Inline base64 is billed at a flat ~2 000 tokens (provider image tokens are * size-dependent and always expensive); a hosted url still costs a real * image's worth of tokens, so it never falls below * `HOSTED_IMAGE_TOKEN_FLOOR` however short the url is. */ export declare function estimateImageTokens(image: ImageAttachment): number; /** Sum of `estimateImageTokens()` over a message's attachments. */ export declare function estimateImagesTokens(images: ImageAttachment[] | undefined): number; //# sourceMappingURL=image-attachments.d.ts.map