import type { ClassifierContext, ClassifierQuestion, ImageContent, JsonObject, JsonValue } from "@earendil-works/pi-ai"; function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function json(value: unknown, depth = 0): value is JsonValue { if (depth > 64) return false; if (value === null || typeof value === "string" || typeof value === "boolean") return true; if (typeof value === "number") return Number.isFinite(value); if (Array.isArray(value)) return value.every((item) => json(item, depth + 1)); return record(value) && Object.values(value).every((item) => json(item, depth + 1)); } function state(value: unknown): value is JsonObject { return record(value) && json(value); } function strings(value: unknown): value is Record { return record(value) && Object.values(value).every((item) => typeof item === "string"); } /** Convert the unchanged public Sai question envelope to upstream's typed classifier context. */ export function toClassifierContext(payload: unknown): ClassifierContext | undefined { if (!record(payload) || !state(payload.state) || !record(payload.questions)) return undefined; const questions: Record = {}; for (const [id, question] of Object.entries(payload.questions)) { if (!record(question)) return undefined; const raw = question.instructions; const instructions = typeof raw === "string" ? raw : record(raw) && typeof raw.question === "string" ? raw.question + (typeof raw.focus === "string" ? `\nFocus: ${raw.focus}` : "") : undefined; if (instructions === undefined) return undefined; if (question.type === "choice" && strings(question.criteria)) { questions[id] = { type: "choice", instructions, criteria: question.criteria }; } else if (question.type === "score" && Array.isArray(question.criteria) && question.criteria.every((item): item is string => typeof item === "string")) { questions[id] = { type: "score", instructions, criteria: question.criteria }; } else if (question.type === "noul" || question.type === "bool") { const criteria = question.criteria; const yes = record(criteria) ? criteria.true : undefined; const no = record(criteria) ? criteria.false : undefined; if (criteria !== undefined && (!strings(criteria) || typeof yes !== "string" || typeof no !== "string")) return undefined; questions[id] = { type: "bool", instructions, criteria: typeof yes === "string" && typeof no === "string" ? { true: yes, false: no } : { true: "True", false: "False" } }; } else return undefined; } return { state: payload.state, questions }; } /** Deployed TokenIn accepts string state; preserve typed upstream context via onPayload. */ export function tokenInWirePayload(payload: unknown): unknown { if (!record(payload)) throw new Error("TokenIn classifier requires an object payload"); if (typeof payload.state === "string") return payload; if (!state(payload.state)) throw new Error("TokenIn classifier requires JSON state"); return { ...payload, state: JSON.stringify(payload.state) }; } export function fitsClassifierRequest(payload: unknown, maxBytes: number): boolean { try { // Include string-state escaping, not just the smaller in-memory JSON object. const wire = record(payload) && "state" in payload ? tokenInWirePayload(payload) : payload; const text = JSON.stringify(wire); return typeof text === "string" && Number.isFinite(maxBytes) && maxBytes > 0 && Buffer.byteLength(text, "utf8") <= Math.min(maxBytes, 64 * 1024); } catch { return false; } } // --------------------------------------------------------------------------- // Shared image gate // --------------------------------------------------------------------------- /** The image formats the TokenIn decisions gateway accepts. */ export const SAI_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const; export type SaiImageMimeType = (typeof SAI_IMAGE_MIME_TYPES)[number]; /** At most this many images may accompany one decisions request. */ export const MAX_CLASSIFIER_IMAGES = 4; /** * Aggregate cap on the encoded (base64) image data in one request. Counted as base64 characters * before any decoding, and deliberately separate from the text budget: images never count toward * the 64 KiB text/route budget. ponytail: the most checked against the live gateway; raise once * larger ones are measured. */ export const MAX_IMAGE_BYTES = 2 * 1024 * 1024; /** Which rule refused a set of images. Content-free: it names the rule, never the data. */ export type SaiImageRejection = "count" | "size" | "mime" | "base64" | "signature"; /** The images to send, copied for upstream's mutable typing, or the rule that refused the set. */ export type SaiImageValidation = | { ok: true; images: ImageContent[] } | { ok: false; reason: SaiImageRejection }; function isSaiImageMimeType(value: string): value is SaiImageMimeType { return (SAI_IMAGE_MIME_TYPES as readonly string[]).includes(value); } /** Base64 as a real encoder writes it: a full quartet, canonical padding, no trailing bits. */ function decodeImageBase64(data: string): Buffer | undefined { if (data.length === 0 || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) return undefined; const bytes = Buffer.from(data, "base64"); return bytes.toString("base64") === data ? bytes : undefined; } /** The leading bytes each accepted format carries; no pixel decoding. */ function imageSignatureMatches(mimeType: SaiImageMimeType, bytes: Buffer): boolean { switch (mimeType) { case "image/png": return bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); case "image/jpeg": return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; case "image/webp": return bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP"; } } /** * The one image gate every decisions caller shares. Count and aggregate encoded size are checked * before any decoding; each image must then be a supported MIME type, valid base64, and carry that * format's signature. A refusal rejects the whole set: nothing is clipped, omitted, or downgraded * to a text-only request, and the reason never carries image data or credentials. */ export function validateSaiImages(images: readonly ImageContent[]): SaiImageValidation { if (images.length > MAX_CLASSIFIER_IMAGES) return { ok: false, reason: "count" }; let encoded = 0; for (const image of images) encoded += image.data.length; if (encoded > MAX_IMAGE_BYTES) return { ok: false, reason: "size" }; const validated: ImageContent[] = []; for (const image of images) { if (!isSaiImageMimeType(image.mimeType)) return { ok: false, reason: "mime" }; const bytes = decodeImageBase64(image.data); if (!bytes) return { ok: false, reason: "base64" }; if (!imageSignatureMatches(image.mimeType, bytes)) return { ok: false, reason: "signature" }; validated.push({ type: "image", data: image.data, mimeType: image.mimeType }); } return { ok: true, images: validated }; } /** An actionable, content-free transport error for the rule that refused an image set. */ export function saiImageRejectionMessage(reason: SaiImageRejection): string { switch (reason) { case "count": return `TokenIn accepts at most ${MAX_CLASSIFIER_IMAGES} images per request`; case "size": return `TokenIn images exceed the ${MAX_IMAGE_BYTES / 1024 / 1024} MiB aggregate encoded cap`; case "mime": return "TokenIn accepts only PNG, JPEG, or WebP images"; case "base64": return "TokenIn image data is not valid base64"; case "signature": return "TokenIn image data does not match its declared MIME type"; } }