ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only adapter today), the generation queue, collections, and the database layer — kept on its own Postgres database rather than folded into officer_db, since both collections and provider_settings had rows on the machine this was extracted from. what changed rather than just moved: - images are served from an authenticated GET /files/:filename instead of an unauthenticated PUBLIC_URL-prefixed static route - the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter - provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has no callers left anywhere in the server code, only a migration script that also did not move ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers liveness. not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not been written, so this is not yet a discoverable plugin.
181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>)[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<string, string>,
|
|
): { 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<string, unknown>)[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<string, unknown>)[param] = value;
|
|
}
|
|
|
|
return { request };
|
|
}
|