import type { ImageEditRequest, ImageRequest } from '../providers'; /** * Validate a request body against OpenAI's `POST /v1/images/generations` schema. * * Every parameter they accept is accepted here, with their enums and their bounds. What is NOT done is * rejecting a combination that only some models allow — `style` on `gpt-image-1`, `background` on * `dall-e-3`. Those rules change per model and per release, and a stale copy of them here would refuse * requests the provider would have served. The provider is the authority; its error comes back intact. */ const QUALITIES = ['auto', 'high', 'medium', 'low', 'hd', 'standard'] as const; const RESPONSE_FORMATS = ['url', 'b64_json'] as const; const OUTPUT_FORMATS = ['png', 'jpeg', 'webp'] as const; const BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const; const MODERATIONS = ['low', 'auto'] as const; const STYLES = ['vivid', 'natural'] as const; /** `auto`, one of the named sizes, or `WIDTHxHEIGHT` — which gpt-image-2 accepts arbitrarily. */ const SIZE_PATTERN = /^(auto|\d{2,5}x\d{2,5})$/; export type ValidationError = { message: string; param?: string }; export function validateImageRequest(input: unknown): { request: ImageRequest } | { error: ValidationError } { if (typeof input !== 'object' || input === null) { return { error: { message: 'Request body must be a JSON object.' } }; } const body = input as Record; const request: ImageRequest = { prompt: '' }; if (typeof body.prompt !== 'string' || body.prompt.length === 0) { return { error: { message: "'prompt' is required and must be a non-empty string.", param: 'prompt' } }; } request.prompt = body.prompt; if (body.model !== undefined && body.model !== null) { if (typeof body.model !== 'string') return { error: { message: "'model' must be a string.", param: 'model' } }; request.model = body.model; } if (body.n !== undefined && body.n !== null) { if (typeof body.n !== 'number' || !Number.isInteger(body.n) || body.n < 1 || body.n > 10) { return { error: { message: "'n' must be an integer between 1 and 10.", param: 'n' } }; } request.n = body.n; } if (body.size !== undefined && body.size !== null) { if (typeof body.size !== 'string' || !SIZE_PATTERN.test(body.size)) { return { error: { message: "'size' must be 'auto' or a WIDTHxHEIGHT string.", param: 'size' } }; } request.size = body.size; } if (body.output_compression !== undefined && body.output_compression !== null) { const value = body.output_compression; if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100) { return { error: { message: "'output_compression' must be an integer between 0 and 100.", param: 'output_compression' }, }; } request.output_compression = value; } if (body.partial_images !== undefined && body.partial_images !== null) { const value = body.partial_images; if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 3) { return { error: { message: "'partial_images' must be an integer between 0 and 3.", param: 'partial_images' } }; } request.partial_images = value; } if (body.stream !== undefined && body.stream !== null) { if (typeof body.stream !== 'boolean') { return { error: { message: "'stream' must be a boolean.", param: 'stream' } }; } // Accepted and ignored. Generation here is a job the caller polls, so there is no response stream // to put partial images on; saying so beats pretending the flag did something. request.stream = body.stream; } if (body.user !== undefined && body.user !== null) { if (typeof body.user !== 'string') return { error: { message: "'user' must be a string.", param: 'user' } }; request.user = body.user; } const enums = [ ['quality', QUALITIES], ['response_format', RESPONSE_FORMATS], ['output_format', OUTPUT_FORMATS], ['background', BACKGROUNDS], ['moderation', MODERATIONS], ['style', STYLES], ] as const; for (const [param, allowed] of enums) { const value = body[param]; if (value === undefined || value === null) continue; if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) { return { error: { message: `'${param}' must be one of: ${allowed.join(', ')}.`, param } }; } (request as Record)[param] = value; } return { request }; } // ── Edits ── // // A separate validator, not a flag on the one above, because OpenAI's two endpoints genuinely differ: // edits take `input_fidelity`, have no `style` or `moderation`, and their `quality` has no `hd`. // Sharing one validator would mean accepting each endpoint's parameters on the other. const EDIT_QUALITIES = ['auto', 'high', 'medium', 'low', 'standard'] as const; const INPUT_FIDELITIES = ['high', 'low'] as const; /** Fields arrive as form strings, so numbers and booleans come in as text. */ export function validateImageEditRequest( fields: Record, ): { request: ImageEditRequest } | { error: ValidationError } { const request: ImageEditRequest = { prompt: '' }; if (!fields.prompt) { return { error: { message: "'prompt' is required.", param: 'prompt' } }; } request.prompt = fields.prompt; if (fields.model !== undefined) request.model = fields.model; if (fields.user !== undefined) request.user = fields.user; const integers = [ ['n', 1, 10], ['output_compression', 0, 100], ['partial_images', 0, 3], ] as const; for (const [param, min, max] of integers) { const raw = fields[param]; if (raw === undefined) continue; const value = Number(raw); if (!Number.isInteger(value) || value < min || value > max) { return { error: { message: `'${param}' must be an integer between ${min} and ${max}.`, param } }; } (request as Record)[param] = value; } if (fields.size !== undefined) { if (!SIZE_PATTERN.test(fields.size)) { return { error: { message: "'size' must be 'auto' or a WIDTHxHEIGHT string.", param: 'size' } }; } request.size = fields.size; } if (fields.stream !== undefined) { if (fields.stream !== 'true' && fields.stream !== 'false') { return { error: { message: "'stream' must be true or false.", param: 'stream' } }; } request.stream = fields.stream === 'true'; } const enums = [ ['quality', EDIT_QUALITIES], ['response_format', RESPONSE_FORMATS], ['output_format', OUTPUT_FORMATS], ['background', BACKGROUNDS], ['input_fidelity', INPUT_FIDELITIES], ] as const; for (const [param, allowed] of enums) { const value = fields[param]; if (value === undefined) continue; if (!(allowed as readonly string[]).includes(value)) { return { error: { message: `'${param}' must be one of: ${allowed.join(', ')}.`, param } }; } (request as Record)[param] = value; } return { request }; }