sd-server (stable-diffusion.cpp, Vulkan) serving Qwen-Image 2.1 on this machine. It already speaks OpenAI's image API, which is the vocabulary `ImageRequest` is written in, so there is no translation layer — only the parts that are true of a local GPU and not of a hosted one. ── keyless is a category, not an exception ── `credentials.ts` decided a provider was usable by asking whether a key existed, so a service that takes none read as unconfigured forever. The fix is an adapter declaring `keyless`, not a dummy secret: a dummy would record a credential that does not exist and make the secret store lie about what is configured, and that store is the one place meant to be the truth about them. A keyless adapter never reaches `keyFor` at all. That function's own comment says why it uses `getSecret` rather than `getKey` — so a missing FOREIGN credential is never minted — and a provider with no credential by design has no business in that path. whisper, OCR and TTS already run on this machine, so this will not be the last. ── measured, not assumed ── The integration doc said "tens of seconds, not two". The server's own log says 512x512 completes in ~185s and 1024x1024 samples at ~41.4s/step over 20 steps, about 14 minutes. And it serialises strictly: a second caller waits for the first to finish entirely, so a queued 512 can wait 17 minutes before a byte comes back. So: 512 is the default and the substitute for `auto`, 1024 is offered with the cost in its label, and `timeout: false` is a requirement rather than caution — every default anyone would reach for is far under 17 minutes. The caller's `signal` still aborts, because a user cancelling is not a client giving up. ── the model id moved, because only it could ── `GET /v1/models` returns `sd-cpp-local`, hardcoded in sd-server with no override. The catalogue now agrees with the server and carries "Qwen-Image 2.1 (local)" in the label, so requests name a model the server knows while the UI still says something meaningful. ── not shipped: edit() ── Same GPU, same queue. It doubles the ways generation can be blocked while the generate path is unproven in anger, and `ImageAdapter` makes absence the way to say so — the route answers 400 instead of the queue finding out late. Verified: /v1/models answers, provider statuses report qwen-local as configured/keyless/usable, and the shipped entry survives the merge with the stored catalogue.json. NOT verified end to end — a generation is occupying the GPU and I left it alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
118 lines
4.5 KiB
TypeScript
118 lines
4.5 KiB
TypeScript
/**
|
|
* 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;
|
|
/**
|
|
* This adapter authenticates with nothing, because it talks to a service of OURS rather than a
|
|
* vendor's. Availability is then a fact about the code, not about what is in the secret store.
|
|
*
|
|
* The alternative was storing a dummy secret so the existing "has a key?" test would pass. That would
|
|
* make the store lie about what is configured, and the store is the one place that is supposed to be
|
|
* the truth about credentials. A flag that says "this question does not apply" is smaller and honest.
|
|
*
|
|
* A category rather than an exception: whisper, OCR and TTS already run on this machine too.
|
|
*/
|
|
readonly keyless?: boolean;
|
|
/**
|
|
* 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;
|
|
};
|