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,127 @@
|
||||
import { getSecret } from 'officerdb/secret-store';
|
||||
import { getProvider, getProviderByName, listProviders, resolveProviderForModel } from '../db';
|
||||
import type { Provider } from '../db';
|
||||
import { decrypt } from '../crypto';
|
||||
import { ADAPTERS, adapterByName } from './index';
|
||||
import type { ImageAdapter } from './types';
|
||||
|
||||
/**
|
||||
* The key for a provider: its own, then the adapter's bootstrap credential.
|
||||
*
|
||||
* That order because the row is where the owner put a key deliberately. The bootstrap credential is
|
||||
* how a key gets in before there is a UI to put one in, and how an install supplies one without a
|
||||
* database write. Several providers sharing an adapter therefore also share the fallback, which is
|
||||
* right: it is the same account.
|
||||
*/
|
||||
export async function keyForProvider(provider: Provider): Promise<string | undefined> {
|
||||
if (provider.apiKeyEncrypted) {
|
||||
try {
|
||||
return decrypt(provider.apiKeyEncrypted);
|
||||
} catch (error) {
|
||||
// A key that will not decrypt is broken, not absent. Falling through to the bootstrap credential
|
||||
// would hide that the stored credential is unusable.
|
||||
console.error(`[atelier:providers] stored key for "${provider.name}" failed to decrypt:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return bootstrapKeyFor(provider.adapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter's bootstrap credential — the secret store first, `.env` second.
|
||||
*
|
||||
* `getSecret`, never `getKey`: this is a foreign credential (OpenAI's, not ours), and `getKey` mints a
|
||||
* random value for an absent purpose — for a foreign credential that means silently authenticating
|
||||
* with a fake. `putSecret`/`getSecret` landed 2026-08-19; `bun run secret:set atelier-openai-key`
|
||||
* writes it. Falls back to `<ADAPTER>_API_KEY` in `.env` so an install that predates the secret store
|
||||
* keeps working unchanged.
|
||||
*
|
||||
* Keyed per-adapter (`atelier-<adapter>-key`), matching `envVarFor`'s own per-adapter shape below —
|
||||
* several providers on one adapter already share one fallback, and this preserves that.
|
||||
*/
|
||||
function bootstrapKeyFor(adapter: string): string | undefined {
|
||||
try {
|
||||
const stored = getSecret(`atelier-${adapter}-key`);
|
||||
if (stored) return stored;
|
||||
} catch {
|
||||
// Unreadable store — fall through to env, same as every other secret-store caller in this codebase.
|
||||
}
|
||||
return process.env[envVarFor(adapter)];
|
||||
}
|
||||
|
||||
/** `openai` -> `OPENAI_API_KEY`. Per adapter, not per provider: it is one account. */
|
||||
export const envVarFor = (adapter: string) => `${adapter.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
|
||||
|
||||
export type ResolvedProvider = { provider: Provider; adapter: ImageAdapter; apiKey: string };
|
||||
|
||||
/** Everything needed to run a generation, or a reason it cannot run. */
|
||||
export async function resolveForRequest(options: {
|
||||
providerRef?: string;
|
||||
model?: string;
|
||||
}): Promise<ResolvedProvider | { error: string; status: 400 | 404 | 503 }> {
|
||||
let provider: Provider | undefined;
|
||||
|
||||
if (options.providerRef) {
|
||||
// Named explicitly — a uuid or the provider's name. An unknown one is the caller's mistake and
|
||||
// must not silently fall back to a different provider that will bill them.
|
||||
provider = /^[0-9a-f-]{36}$/i.test(options.providerRef)
|
||||
? await getProvider(options.providerRef)
|
||||
: await getProviderByName(options.providerRef);
|
||||
|
||||
if (!provider) return { error: `No provider named "${options.providerRef}".`, status: 404 };
|
||||
if (!provider.enabled) return { error: `Provider "${provider.name}" is disabled.`, status: 503 };
|
||||
} else {
|
||||
provider = await resolveProviderForModel(options.model);
|
||||
if (!provider) {
|
||||
return { error: 'No provider is configured. Add one in Settings → Integrations → Atelier.', status: 503 };
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = adapterByName(provider.adapter);
|
||||
if (!adapter)
|
||||
return { error: `Provider "${provider.name}" uses unknown adapter "${provider.adapter}".`, status: 400 };
|
||||
|
||||
const apiKey = await keyForProvider(provider);
|
||||
if (!apiKey) return { error: `Provider "${provider.name}" has no API key.`, status: 503 };
|
||||
|
||||
return { provider, adapter, apiKey };
|
||||
}
|
||||
|
||||
/** What the providers screen renders. Never includes a key, in any form. */
|
||||
export async function providerStatuses() {
|
||||
const rows = await listProviders();
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
adapter: row.adapter,
|
||||
model: row.model,
|
||||
enabled: row.enabled,
|
||||
is_default: row.isDefault,
|
||||
configured: Boolean(await keyForProvider(row)),
|
||||
// Where the key came from, so the UI can say "set as a bootstrap credential" rather than showing
|
||||
// an empty field next to a provider that works.
|
||||
key_source: row.apiKeyEncrypted
|
||||
? ('database' as const)
|
||||
: (await keyForProvider(row))
|
||||
? ('environment' as const)
|
||||
: null,
|
||||
env_var: envVarFor(row.adapter),
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** The adapters a provider can be built on, for the "add provider" form. */
|
||||
export const adapterCatalogue = () =>
|
||||
ADAPTERS.map((adapter) => ({
|
||||
name: adapter.name,
|
||||
label: adapter.label,
|
||||
models: adapter.models,
|
||||
default_model: adapter.defaultModel,
|
||||
env_var: envVarFor(adapter.name),
|
||||
supports_edit: typeof adapter.edit === 'function',
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
import { openaiAdapter } from './openai';
|
||||
import type { ImageAdapter } from './types';
|
||||
|
||||
export * from './types';
|
||||
|
||||
/**
|
||||
* Every adapter Atelier can speak — the CODE side.
|
||||
*
|
||||
* An adapter knows a vendor's HTTP API. It is written once and rarely changes. What model to use,
|
||||
* whose key, and whether it is switched on are not its business: those are a `providers` row, made
|
||||
* and edited at runtime. Several providers can share one adapter, which is the entire point — OpenAI
|
||||
* with gpt-image-1 and OpenAI with dall-e-3 are two configurations of one piece of code.
|
||||
*
|
||||
* A list in one place, the way `hono.ts` keeps its mounts: adding an adapter is a new file plus a
|
||||
* line here.
|
||||
*/
|
||||
export const ADAPTERS: ImageAdapter[] = [openaiAdapter];
|
||||
|
||||
export function adapterByName(name: string): ImageAdapter | undefined {
|
||||
return ADAPTERS.find((adapter) => adapter.name === name);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { ImageAdapter, ImageEditRequest, ImageRequest, ImageUsage, ProviderResult, UploadedImage } from './types';
|
||||
|
||||
const GENERATIONS_ENDPOINT = 'https://api.openai.com/v1/images/generations';
|
||||
const EDITS_ENDPOINT = 'https://api.openai.com/v1/images/edits';
|
||||
|
||||
/**
|
||||
* USD per million tokens, from OpenAI's published pricing.
|
||||
*
|
||||
* A table rather than a computed rate, and one that is allowed to be incomplete: a model missing here
|
||||
* yields a null cost, which is honest. Guessing a price for a model we do not know produces a number
|
||||
* that looks authoritative and sums into a total that is wrong. Every row also stores its raw `usage`,
|
||||
* so any cost here can be recomputed later if these figures move.
|
||||
*/
|
||||
const PRICING: Record<string, { textInput: number; imageInput: number; imageOutput: number }> = {
|
||||
'gpt-image-1': { textInput: 5, imageInput: 10, imageOutput: 40 },
|
||||
'gpt-image-1-mini': { textInput: 2, imageInput: 2.5, imageOutput: 8 },
|
||||
'gpt-image-1.5': { textInput: 5, imageInput: 8, imageOutput: 32 },
|
||||
};
|
||||
|
||||
/** Only the parameters OpenAI accepts, and only those the caller actually set. */
|
||||
function body(request: ImageRequest, n: number): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { prompt: request.prompt, model: request.model, n };
|
||||
|
||||
for (const key of [
|
||||
'quality',
|
||||
'size',
|
||||
'response_format',
|
||||
'output_format',
|
||||
'output_compression',
|
||||
'background',
|
||||
'moderation',
|
||||
'style',
|
||||
'user',
|
||||
] as const) {
|
||||
if (request[key] !== undefined) out[key] = request[key];
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export const openaiAdapter: ImageAdapter = {
|
||||
name: 'openai',
|
||||
label: 'OpenAI',
|
||||
models: ['gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini', 'dall-e-3', 'dall-e-2'],
|
||||
defaultModel: 'gpt-image-1',
|
||||
|
||||
async generate(request, apiKey, signal): Promise<ProviderResult> {
|
||||
// One image per call, even when the caller asked for several. Each is its own row with its own
|
||||
// cost and duration, and one failure out of four should not discard the three that worked.
|
||||
const response = await fetch(GENERATIONS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify(body(request, 1)),
|
||||
signal,
|
||||
});
|
||||
|
||||
return decodeImagesResponse(response, request.output_format, signal);
|
||||
},
|
||||
|
||||
async edit(request, images, mask, apiKey, signal): Promise<ProviderResult> {
|
||||
// multipart/form-data, because that is what the endpoint takes. Built rather than translated:
|
||||
// this is a bridge, and every field goes across under the name OpenAI gave it.
|
||||
const form = new FormData();
|
||||
form.append('prompt', request.prompt);
|
||||
form.append('n', '1');
|
||||
|
||||
// Repeated `image[]` — the wire form of an array of files, and what the API expects for the GPT
|
||||
// image models. One image still goes as `image[]`; the endpoint accepts either.
|
||||
for (const image of images) {
|
||||
form.append('image[]', new Blob([image.bytes as BlobPart], { type: image.type }), image.filename);
|
||||
}
|
||||
if (mask) form.append('mask', new Blob([mask.bytes as BlobPart], { type: mask.type }), mask.filename);
|
||||
|
||||
for (const key of [
|
||||
'model',
|
||||
'quality',
|
||||
'size',
|
||||
'response_format',
|
||||
'output_format',
|
||||
'output_compression',
|
||||
'background',
|
||||
'input_fidelity',
|
||||
'user',
|
||||
] as const) {
|
||||
const value = request[key];
|
||||
if (value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
|
||||
// No content-type header: fetch sets it, with the multipart boundary that only it knows.
|
||||
const response = await fetch(EDITS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${apiKey}` },
|
||||
body: form,
|
||||
signal,
|
||||
});
|
||||
|
||||
return decodeImagesResponse(response, request.output_format, signal);
|
||||
},
|
||||
|
||||
costUsd(model, usage) {
|
||||
const price = PRICING[model];
|
||||
if (!price || !usage) return null;
|
||||
|
||||
const textTokens = usage.input_tokens_details?.text_tokens ?? usage.input_tokens ?? 0;
|
||||
const imageTokens = usage.input_tokens_details?.image_tokens ?? 0;
|
||||
const outputTokens = usage.output_tokens ?? 0;
|
||||
|
||||
const total =
|
||||
(textTokens * price.textInput + imageTokens * price.imageInput + outputTokens * price.imageOutput) / 1_000_000;
|
||||
|
||||
return total.toFixed(6);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn OpenAI's ImagesResponse into bytes, whichever endpoint produced it.
|
||||
*
|
||||
* Shared by generate and edit because the response shape is identical — only the request differs.
|
||||
*/
|
||||
async function decodeImagesResponse(
|
||||
response: Response,
|
||||
requestedFormat: string | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProviderResult> {
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
// Surface OpenAI's own message where there is one — "content policy violation" is worth keeping.
|
||||
let detail = text.slice(0, 500);
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { error?: { message?: string } };
|
||||
if (parsed.error?.message) detail = parsed.error.message;
|
||||
} catch {}
|
||||
throw new Error(`OpenAI ${response.status}: ${detail}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
data?: { b64_json?: string; url?: string; revised_prompt?: string }[];
|
||||
usage?: ImageUsage;
|
||||
background?: string;
|
||||
output_format?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
};
|
||||
|
||||
const format = payload.output_format ?? requestedFormat ?? 'png';
|
||||
|
||||
const images = await Promise.all(
|
||||
(payload.data ?? []).map(async (item) => {
|
||||
if (item.b64_json) {
|
||||
return { bytes: Buffer.from(item.b64_json, 'base64'), format, revisedPrompt: item.revised_prompt };
|
||||
}
|
||||
// dall-e-2 with the default response_format hands back a URL that expires in an hour. Fetch it
|
||||
// now: the whole point of Atelier is that the image outlives the request.
|
||||
if (item.url) {
|
||||
const bytes = new Uint8Array(await (await fetch(item.url, { signal })).arrayBuffer());
|
||||
return { bytes, format, revisedPrompt: item.revised_prompt };
|
||||
}
|
||||
throw new Error('OpenAI returned neither b64_json nor url');
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
images,
|
||||
usage: payload.usage,
|
||||
echoed: {
|
||||
background: payload.background,
|
||||
output_format: payload.output_format,
|
||||
size: payload.size,
|
||||
quality: payload.quality,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* The request Atelier accepts, which is OpenAI's `POST /v1/images/generations` body verbatim.
|
||||
*
|
||||
* Kept as their vocabulary rather than a translated one of our own: a caller that already knows the
|
||||
* OpenAI API knows this, and a provider added later is the one that has to do the translating —
|
||||
* which is where the knowledge of that provider already lives.
|
||||
*/
|
||||
export type ImageRequest = {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
n?: number;
|
||||
quality?: 'auto' | 'high' | 'medium' | 'low' | 'hd' | 'standard';
|
||||
size?: string;
|
||||
response_format?: 'url' | 'b64_json';
|
||||
output_format?: 'png' | 'jpeg' | 'webp';
|
||||
output_compression?: number;
|
||||
background?: 'transparent' | 'opaque' | 'auto';
|
||||
moderation?: 'low' | 'auto';
|
||||
style?: 'vivid' | 'natural';
|
||||
stream?: boolean;
|
||||
partial_images?: number;
|
||||
user?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenAI's `POST /v1/images/edits` body, minus the files themselves.
|
||||
*
|
||||
* A separate type rather than flags on ImageRequest, because the parameter sets genuinely differ:
|
||||
* edits take `input_fidelity` and no `style` or `moderation`, its `quality` has no `hd`, and its
|
||||
* model default is gpt-image-1.5 where generations default to dall-e-2.
|
||||
*/
|
||||
export type ImageEditRequest = {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
n?: number;
|
||||
quality?: 'auto' | 'high' | 'medium' | 'low' | 'standard';
|
||||
size?: string;
|
||||
response_format?: 'url' | 'b64_json';
|
||||
output_format?: 'png' | 'jpeg' | 'webp';
|
||||
output_compression?: number;
|
||||
background?: 'transparent' | 'opaque' | 'auto';
|
||||
input_fidelity?: 'high' | 'low';
|
||||
stream?: boolean;
|
||||
partial_images?: number;
|
||||
user?: string;
|
||||
};
|
||||
|
||||
/** An uploaded file on its way to a provider. */
|
||||
export type UploadedImage = { filename: string; bytes: Uint8Array; type: string };
|
||||
|
||||
/** OpenAI's usage object. Stored raw so a cost computed with the wrong price can be recomputed. */
|
||||
export type ImageUsage = {
|
||||
total_tokens?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
input_tokens_details?: { text_tokens?: number; image_tokens?: number };
|
||||
};
|
||||
|
||||
/** One generated image, as the provider returned it. */
|
||||
export type ProviderImage = {
|
||||
/** Raw bytes. Providers hand back base64; decoding is theirs to do, so the queue only writes files. */
|
||||
bytes: Uint8Array;
|
||||
/** png / jpeg / webp — decides the extension on disk and the served content type. */
|
||||
format: string;
|
||||
revisedPrompt?: string;
|
||||
};
|
||||
|
||||
export type ProviderResult = {
|
||||
images: ProviderImage[];
|
||||
usage?: ImageUsage;
|
||||
/** Echoed back by the provider: the size, quality and background actually used. */
|
||||
echoed: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An adapter: how to talk to one vendor's image API.
|
||||
*
|
||||
* Deliberately holds no key, no chosen model and no enabled flag. Those live in a `providers` row,
|
||||
* because they are decisions rather than knowledge, and several rows share one adapter.
|
||||
*/
|
||||
export type ImageAdapter = {
|
||||
readonly name: string;
|
||||
/** For display — "OpenAI" rather than "openai", used to name providers built from it. */
|
||||
readonly label: string;
|
||||
/** Models this adapter is known to serve. Advisory — an unknown model is passed through. */
|
||||
readonly models: readonly string[];
|
||||
readonly defaultModel: string;
|
||||
/**
|
||||
* The key is passed in rather than read here: it can come from the database or the environment, and
|
||||
* which one is a question about configuration, not about how to talk to this provider.
|
||||
*/
|
||||
generate(request: ImageRequest, apiKey: string, signal?: AbortSignal): Promise<ProviderResult>;
|
||||
/**
|
||||
* Edit. Optional: a provider that cannot do it says so by not having it, and the route answers 400
|
||||
* rather than the queue discovering it after the row is written.
|
||||
*/
|
||||
edit?(
|
||||
request: ImageEditRequest,
|
||||
images: UploadedImage[],
|
||||
mask: UploadedImage | undefined,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProviderResult>;
|
||||
/** USD for the reported usage, or null when this model's price is not known. */
|
||||
costUsd(model: string, usage: ImageUsage | undefined): string | null;
|
||||
};
|
||||
Reference in New Issue
Block a user