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.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export type ImageFacts = {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
hasAlpha: boolean | null;
|
||||
opaque: boolean | null;
|
||||
};
|
||||
|
||||
const UNKNOWN: ImageFacts = { width: null, height: null, hasAlpha: null, opaque: null };
|
||||
|
||||
/**
|
||||
* Measure the file that was actually written.
|
||||
*
|
||||
* Read from disk rather than echoed from the request, because the request is what was asked for and
|
||||
* this is what arrived — and those are precisely the two things that come apart. The caller is an
|
||||
* agent that cannot look at the image, so anything it is not told, it cannot know.
|
||||
*
|
||||
* `hasAlpha` and `opaque` answer different questions and the gap between them is the point:
|
||||
*
|
||||
* hasAlpha: false no alpha channel at all
|
||||
* hasAlpha: true, opaque: false transparency present and used
|
||||
* hasAlpha: true, opaque: true an alpha channel the model never used
|
||||
*
|
||||
* The third is the failure worth catching. `background: "transparent"` is accepted, the job succeeds,
|
||||
* and every pixel comes back fully opaque — which for an app icon is the difference between usable and
|
||||
* a black square, and is invisible from both ends without this field.
|
||||
*
|
||||
* Never throws. A generation that produced real bytes must not be recorded as failed because the
|
||||
* measurement of it did; nulls say "not known" and the image is still there.
|
||||
*/
|
||||
export async function inspectImage(path: string): Promise<ImageFacts> {
|
||||
try {
|
||||
const image = sharp(path);
|
||||
const metadata = await image.metadata();
|
||||
|
||||
// stats() decodes the pixels — the only way to answer "is anything actually transparent", since
|
||||
// the header can only say whether a channel exists. Skipped when there is no alpha channel at
|
||||
// all, where the answer is already known and the decode would be waste.
|
||||
const opaque = metadata.hasAlpha ? (await image.stats()).isOpaque : true;
|
||||
|
||||
return {
|
||||
width: metadata.width ?? null,
|
||||
height: metadata.height ?? null,
|
||||
hasAlpha: metadata.hasAlpha ?? null,
|
||||
opaque,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[inspect] could not read ${path}:`, error instanceof Error ? error.message : error);
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { join } from 'node:path';
|
||||
import { claimableGenerations, failInterrupted, getProvider, markFailed, markRunning, markSucceeded } from '../db';
|
||||
import type { Generation } from '../db';
|
||||
import { IMAGES_DIR, INPUTS_DIR, MAX_CONCURRENT_GENERATIONS } from '../env';
|
||||
import { adapterByName } from '../providers';
|
||||
import { keyForProvider } from '../providers/credentials';
|
||||
import { inspectImage } from './inspect';
|
||||
import type { ImageEditRequest, ImageRequest, UploadedImage } from '../providers';
|
||||
|
||||
/**
|
||||
* The worker pool.
|
||||
*
|
||||
* Postgres holds the queue, not memory. A row is the job, so a restart loses nothing that was waiting
|
||||
* and `failInterrupted()` can tell the caller the truth about what was mid-flight. The only in-memory
|
||||
* state is how many workers are awake, which is exactly the state that should not survive a restart.
|
||||
*/
|
||||
let running = 0;
|
||||
let sweeping = false;
|
||||
|
||||
/** Wake the pool. Safe to call on every enqueue and after every completion; it settles on its own. */
|
||||
export function pump(): void {
|
||||
if (sweeping) return;
|
||||
sweeping = true;
|
||||
void sweep().finally(() => {
|
||||
sweeping = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function sweep(): Promise<void> {
|
||||
while (running < MAX_CONCURRENT_GENERATIONS) {
|
||||
const free = MAX_CONCURRENT_GENERATIONS - running;
|
||||
const candidates = await claimableGenerations(free);
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
let started = 0;
|
||||
for (const candidate of candidates) {
|
||||
// markRunning only transitions from 'queued', so two sweeps racing for one row cannot both win
|
||||
// and the image is never paid for twice.
|
||||
const claimed = await markRunning(candidate.id);
|
||||
if (!claimed) continue;
|
||||
|
||||
started += 1;
|
||||
running += 1;
|
||||
void run(claimed).finally(() => {
|
||||
running -= 1;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
// Everything on offer was taken by someone else; nothing to do until the next enqueue.
|
||||
if (started === 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(row: Generation): Promise<void> {
|
||||
const started = Date.now();
|
||||
|
||||
try {
|
||||
const adapter = adapterByName(row.provider);
|
||||
if (!adapter) throw new Error(`Unknown adapter "${row.provider}"`);
|
||||
|
||||
// The provider row is looked up by id, but the adapter and model come from the ROW's own columns:
|
||||
// a provider renamed or repointed after this was queued must not change what gets generated.
|
||||
const provider = row.providerId ? await getProvider(row.providerId) : undefined;
|
||||
const apiKey = provider
|
||||
? await keyForProvider(provider)
|
||||
: await keyForProvider({ adapter: row.provider, apiKeyEncrypted: null, name: row.provider } as never);
|
||||
if (!apiKey) throw new Error(`No API key for adapter "${row.provider}"`);
|
||||
|
||||
const result =
|
||||
row.operation === 'edit'
|
||||
? await runEdit(row, adapter, apiKey)
|
||||
: await adapter.generate(
|
||||
{ ...(row.options ?? {}), prompt: row.prompt, model: row.model, n: 1 } as ImageRequest,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const image = result.images[0];
|
||||
if (!image) throw new Error('Provider returned no image');
|
||||
|
||||
// The row id is the filename: one lookup gets you from a served URL back to the prompt that made it.
|
||||
const filename = `${row.id}.${image.format}`;
|
||||
const path = join(IMAGES_DIR, filename);
|
||||
await Bun.write(path, image.bytes);
|
||||
|
||||
// Measured after the write, from the file itself.
|
||||
const facts = await inspectImage(path);
|
||||
|
||||
await markSucceeded(row.id, {
|
||||
imagePath: filename,
|
||||
...facts,
|
||||
costUsd: adapter.costUsd(row.model, result.usage),
|
||||
usage: result.usage ?? null,
|
||||
result: { ...result.echoed, revised_prompt: image.revisedPrompt },
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
} catch (error) {
|
||||
await markFailed(row.id, error instanceof Error ? error.message : String(error), Date.now() - started);
|
||||
}
|
||||
}
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
};
|
||||
|
||||
/** Read the uploads back off disk and hand them to the provider. */
|
||||
async function runEdit(row: Generation, adapter: NonNullable<ReturnType<typeof adapterByName>>, apiKey: string) {
|
||||
if (!adapter.edit) throw new Error(`Adapter "${adapter.name}" cannot edit images`);
|
||||
|
||||
const load = async (name: string): Promise<UploadedImage> => {
|
||||
const file = Bun.file(join(INPUTS_DIR, name));
|
||||
if (!(await file.exists())) throw new Error(`Input image ${name} is missing`);
|
||||
const extension = name.slice(name.lastIndexOf('.')).toLowerCase();
|
||||
return { filename: name, bytes: new Uint8Array(await file.arrayBuffer()), type: MIME[extension] ?? 'image/png' };
|
||||
};
|
||||
|
||||
const images = await Promise.all((row.inputImages ?? []).map(load));
|
||||
const mask = row.maskImage ? await load(row.maskImage) : undefined;
|
||||
|
||||
const request = { ...(row.options ?? {}), prompt: row.prompt, model: row.model } as ImageEditRequest;
|
||||
return adapter.edit(request, images, mask, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once at boot, before the pool starts.
|
||||
*
|
||||
* Rows left `running` have no worker behind them — whatever was going to finish them died with the
|
||||
* last process. Left alone they stay running forever and a caller polling one waits on an answer that
|
||||
* is never coming.
|
||||
*/
|
||||
export async function startQueue(): Promise<void> {
|
||||
// Once per process, matching the standalone repo's own guard against `bun --hot` double-starting the
|
||||
// sweep loop. This process is not hot-reloaded under PM2, but the guard is free and correct either way.
|
||||
const globalForQueue = globalThis as unknown as { __atelierQueueStarted?: boolean };
|
||||
if (globalForQueue.__atelierQueueStarted) return;
|
||||
globalForQueue.__atelierQueueStarted = true;
|
||||
|
||||
const interrupted = await failInterrupted();
|
||||
if (interrupted > 0) console.log(`[atelier:queue] failed ${interrupted} generation(s) interrupted by a restart`);
|
||||
pump();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Generation } from '../db';
|
||||
import { imagesResponse, isSettled, jobStatus } from './response';
|
||||
|
||||
const row = (over: Partial<Generation> = {}): Generation =>
|
||||
({
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
requestId: '22222222-2222-2222-2222-222222222222',
|
||||
index: 0,
|
||||
status: 'succeeded',
|
||||
provider: 'openai',
|
||||
model: 'gpt-image-1',
|
||||
prompt: 'a voxel duck',
|
||||
options: {},
|
||||
imagePath: '11111111-1111-1111-1111-111111111111.png',
|
||||
costUsd: '0.010000',
|
||||
usage: {
|
||||
total_tokens: 100,
|
||||
input_tokens: 20,
|
||||
output_tokens: 80,
|
||||
input_tokens_details: { text_tokens: 20, image_tokens: 0 },
|
||||
},
|
||||
result: {},
|
||||
durationMs: 4200,
|
||||
error: null,
|
||||
createdAt: new Date('2026-08-16T12:00:00Z'),
|
||||
startedAt: new Date('2026-08-16T12:00:01Z'),
|
||||
finishedAt: new Date('2026-08-16T12:00:05Z'),
|
||||
...over,
|
||||
}) as Generation;
|
||||
|
||||
describe('jobStatus', () => {
|
||||
test('running beats queued', () =>
|
||||
expect(jobStatus([row({ status: 'queued' }), row({ status: 'running' })])).toBe('running'));
|
||||
test('queued while any is waiting', () => expect(jobStatus([row(), row({ status: 'queued' })])).toBe('queued'));
|
||||
test('succeeded when all did', () => expect(jobStatus([row(), row()])).toBe('succeeded'));
|
||||
test('failed when all did', () =>
|
||||
expect(jobStatus([row({ status: 'failed' }), row({ status: 'failed' })])).toBe('failed'));
|
||||
|
||||
// The case n>1 exists for: three images made, one refused. Calling that "failed" discards work that
|
||||
// was paid for; calling it "succeeded" hides a failure.
|
||||
test('partial when some failed and some did not', () =>
|
||||
expect(jobStatus([row(), row({ status: 'failed' })])).toBe('partial'));
|
||||
});
|
||||
|
||||
describe('isSettled', () => {
|
||||
test.each(['succeeded', 'failed', 'partial'] as const)('%s is settled', (s) => expect(isSettled(s)).toBe(true));
|
||||
test.each(['queued', 'running'] as const)('%s is not', (s) => expect(isSettled(s)).toBe(false));
|
||||
});
|
||||
|
||||
describe('imagesResponse', () => {
|
||||
test('shapes an OpenAI response, with url populated', async () => {
|
||||
const body = await imagesResponse([row()], false);
|
||||
|
||||
expect(body.created).toBe(Math.floor(new Date('2026-08-16T12:00:00Z').getTime() / 1000));
|
||||
expect(body.data).toHaveLength(1);
|
||||
// The divergence that is the point of Atelier: OpenAI never returns url for GPT image models.
|
||||
expect(body.data[0]!.url).toMatch(/\/files\/11111111-1111-1111-1111-111111111111\.png$/);
|
||||
expect(body.data[0]).not.toHaveProperty('b64_json');
|
||||
});
|
||||
|
||||
test('omits failed images from data but keeps them in the atelier block', async () => {
|
||||
const body = await imagesResponse([row(), row({ status: 'failed', imagePath: null, error: 'refused' })], false);
|
||||
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.atelier.images).toHaveLength(2);
|
||||
expect(body.atelier.status).toBe('partial');
|
||||
expect(body.atelier.images[1]!.error).toBe('refused');
|
||||
});
|
||||
|
||||
test('sums usage and cost across images', async () => {
|
||||
const body = await imagesResponse([row(), row({ costUsd: '0.020000' })], false);
|
||||
|
||||
expect(body.usage.total_tokens).toBe(200);
|
||||
expect(body.usage.output_tokens).toBe(160);
|
||||
expect(body.atelier.cost_usd).toBe('0.030000');
|
||||
});
|
||||
|
||||
test('reports revised_prompt when the provider rewrote it', async () => {
|
||||
const body = await imagesResponse([row({ result: { revised_prompt: 'a cheerful voxel duck' } })], false);
|
||||
expect(body.data[0]!.revised_prompt).toBe('a cheerful voxel duck');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { join } from 'node:path';
|
||||
import type { Generation } from '../db';
|
||||
import { IMAGES_DIR } from '../env';
|
||||
import { serializeGenerationImage } from '../serialize';
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'partial';
|
||||
|
||||
/**
|
||||
* The status of a request, from the rows of its images.
|
||||
*
|
||||
* `partial` exists because `n` images can end differently: three succeed and one trips the content
|
||||
* filter. Reporting that as `failed` throws away three images that exist and were paid for, and
|
||||
* reporting it as `succeeded` hides a failure the caller should see.
|
||||
*/
|
||||
export function jobStatus(rows: Generation[]): JobStatus {
|
||||
if (rows.some((row) => row.status === 'running')) return 'running';
|
||||
if (rows.some((row) => row.status === 'queued')) return 'queued';
|
||||
|
||||
const failed = rows.filter((row) => row.status === 'failed').length;
|
||||
if (failed === 0) return 'succeeded';
|
||||
if (failed === rows.length) return 'failed';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
export const isSettled = (status: JobStatus) => status === 'succeeded' || status === 'failed' || status === 'partial';
|
||||
|
||||
/** `/files/<name>`, relative from this plugin's own mount. See serialize.ts for why. */
|
||||
export const imageUrl = (row: Generation) => `/files/${row.imagePath}`;
|
||||
|
||||
/**
|
||||
* OpenAI's `ImagesResponse`, built from our rows.
|
||||
*
|
||||
* Deliberate divergence from OpenAI's own shape, and the reason Atelier exists: `url` is always
|
||||
* populated, including for the GPT image models, where OpenAI returns base64 only. `b64_json` is
|
||||
* included only when the caller asked for it, so the common response stays small.
|
||||
*/
|
||||
export async function imagesResponse(rows: Generation[], includeBase64: boolean) {
|
||||
const succeeded = rows.filter((row) => row.status === 'succeeded' && row.imagePath);
|
||||
|
||||
const data = await Promise.all(
|
||||
succeeded.map(async (row) => {
|
||||
const item: Record<string, unknown> = { url: imageUrl(row) };
|
||||
|
||||
const revised = (row.result as { revised_prompt?: string } | null)?.revised_prompt;
|
||||
if (revised) item.revised_prompt = revised;
|
||||
|
||||
if (includeBase64) {
|
||||
const bytes = await Bun.file(join(IMAGES_DIR, row.imagePath!)).arrayBuffer();
|
||||
item.b64_json = Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
|
||||
const first = rows[0];
|
||||
const echoed = (first?.result ?? {}) as Record<string, unknown>;
|
||||
|
||||
const totalCost = rows.reduce((sum, row) => sum + Number(row.costUsd ?? 0), 0);
|
||||
|
||||
return {
|
||||
created: Math.floor((first?.createdAt?.getTime() ?? Date.now()) / 1000),
|
||||
data,
|
||||
...(echoed.background ? { background: echoed.background } : {}),
|
||||
...(echoed.output_format ? { output_format: echoed.output_format } : {}),
|
||||
...(echoed.size ? { size: echoed.size } : {}),
|
||||
...(echoed.quality ? { quality: echoed.quality } : {}),
|
||||
usage: mergeUsage(rows),
|
||||
// Not OpenAI's, and namespaced so it cannot collide with a field they add later.
|
||||
atelier: {
|
||||
id: first?.requestId,
|
||||
status: jobStatus(rows),
|
||||
model: first?.model,
|
||||
provider: first?.provider,
|
||||
cost_usd: totalCost > 0 ? totalCost.toFixed(6) : null,
|
||||
duration_ms: rows.reduce((max, row) => Math.max(max, row.durationMs ?? 0), 0),
|
||||
// Through the shared serializer, so a field here and the same field in /images cannot drift
|
||||
// into two spellings again.
|
||||
images: rows.map(serializeGenerationImage),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum the per-image usage back into one object shaped like OpenAI's. */
|
||||
function mergeUsage(rows: Generation[]) {
|
||||
const totals = { total_tokens: 0, input_tokens: 0, output_tokens: 0, text_tokens: 0, image_tokens: 0 };
|
||||
|
||||
for (const row of rows) {
|
||||
const usage = row.usage as {
|
||||
total_tokens?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
input_tokens_details?: Record<string, number>;
|
||||
} | null;
|
||||
if (!usage) continue;
|
||||
totals.total_tokens += usage.total_tokens ?? 0;
|
||||
totals.input_tokens += usage.input_tokens ?? 0;
|
||||
totals.output_tokens += usage.output_tokens ?? 0;
|
||||
totals.text_tokens += usage.input_tokens_details?.text_tokens ?? 0;
|
||||
totals.image_tokens += usage.input_tokens_details?.image_tokens ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
total_tokens: totals.total_tokens,
|
||||
input_tokens: totals.input_tokens,
|
||||
output_tokens: totals.output_tokens,
|
||||
input_tokens_details: { text_tokens: totals.text_tokens, image_tokens: totals.image_tokens },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user