Files
pastilhas f23ec1283b manifest, thin proxy router, and the sidecar backend
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.
2026-08-20 03:53:53 +00:00

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 };
}