import type { ImageAdapter, ImageRequest, ImageUsage, ProviderResult } from './types'; /** * Qwen-Image 2.1 on our own GPU, via `sd-server` from stable-diffusion.cpp with the Vulkan backend. * * It already speaks OpenAI's image API, which is the vocabulary `ImageRequest` is written in, so there * is no translation layer — only the things below that are true of a local GPU and not of a hosted API. * * ── It is slow, and the number matters ── * * Measured on this machine from the server's own log, not estimated: 512x512 completes in ~185s, and * 1024x1024 samples at ~41.4s per step over 20 steps, so about 14 minutes. The catalogue therefore * defaults to 512 and offers 1024 labelled for what it costs — a default nobody chose should not spend a * quarter of an hour of the only GPU there is. * * ── `timeout: false`, and it is not caution ── * * The server serialises strictly: a second caller waits for the first to finish ENTIRELY before its own * first step. So a queued 512 request can wait its own 3 minutes plus a 14-minute job ahead of it — 17 * minutes before a single byte comes back. Bun's fetch has a default timeout, and every default anyone * would reach for is far under that. The caller's `signal` is still honoured, because a user cancelling * is a different thing from a client giving up. * * ── No `edit()` in this version ── * * Deliberate. It is the same GPU and the same serialisation, so it doubles the ways the queue can be * blocked while the generate path is still unproven in anger. `ImageAdapter` makes absence the way to * say a provider cannot do something, and the route answers 400 rather than the queue finding out late. */ /** * Where the server is. Overridable because the same service answers on the tailnet * (`http://100.64.0.1:8188`), and a second machine should not need a code change to reach it. */ const BASE = process.env.ATELIER_QWEN_LOCAL_URL ?? 'http://127.0.0.1:8188'; /** * What `GET /v1/models` actually returns, which is not what the model IS. * * sd-server hardcodes this id with no override, so the catalogue is the only side that can move: the id * stays `sd-cpp-local` so the two agree, and the catalogue carries "Qwen-Image 2.1 (local)" as the * label so the UI still says something meaningful. A catalogue that disagreed with `/v1/models` would * make every request name a model the server denies knowing. */ const MODEL_ID = 'sd-cpp-local'; /** Sent when the caller names no size. See the timings above for why it is not 1024. */ const DEFAULT_SIZE = '512x512'; type OpenAiImagesResponse = { data?: { b64_json?: string; revised_prompt?: string }[]; usage?: ImageUsage; size?: string; quality?: string; background?: string; }; /** * Only what this server understands. * * `quality`, `style`, `moderation` and `background` are OpenAI concepts with no Qwen equivalent — they * are dropped rather than passed, because sd-server ignores unknown fields silently and a parameter * that appears to have been honoured is worse than one that was visibly not offered. What was actually * used comes back in `echoed`. */ function body(request: ImageRequest): Record { return { prompt: request.prompt, model: MODEL_ID, n: 1, size: request.size && request.size !== 'auto' ? request.size : DEFAULT_SIZE, response_format: 'b64_json', ...(request.output_format ? { output_format: request.output_format } : {}), }; } export const qwenLocalAdapter: ImageAdapter = { name: 'qwen-local', label: 'Qwen-Image (local)', models: [MODEL_ID], defaultModel: MODEL_ID, keyless: true, async generate(request, _apiKey, signal): Promise { const sent = body(request); const response = await fetch(`${BASE}/v1/images/generations`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), signal, // Bun extension. See the block comment: the honest worst case is 17 minutes. timeout: false, } as RequestInit); if (!response.ok) { const detail = await response.text().catch(() => ''); throw new Error(`qwen-local ${response.status}: ${detail.slice(0, 400) || response.statusText}`); } const payload = (await response.json()) as OpenAiImagesResponse; const entries = payload.data ?? []; if (entries.length === 0) throw new Error('qwen-local returned no images'); const format = request.output_format ?? 'png'; const images = entries .filter((entry) => typeof entry.b64_json === 'string') .map((entry) => ({ bytes: Uint8Array.from(atob(entry.b64_json!), (char) => char.charCodeAt(0)), format, ...(entry.revised_prompt ? { revisedPrompt: entry.revised_prompt } : {}), })); if (images.length === 0) throw new Error('qwen-local returned images with no b64_json'); return { images, usage: payload.usage, // What was actually used, including the size we substituted — the caller asked for `auto` or for // nothing and got 512, and the row should record that rather than the absence. echoed: { model: MODEL_ID, size: sent.size, provider: 'qwen-local', ...(payload.quality ? { quality: payload.quality } : {}) }, }; }, /** * Zero, and that is a fact rather than a gap. * * `null` here means "this model's price is not known", which is what the OpenAI adapter returns for * models it has no row for. Our own GPU costs no dollars per image, so zero is the true answer and * saying "unknown" would put this provider in the same bucket as one we simply failed to price. */ costUsd(_model: string, _usage: ImageUsage | undefined): string { return '0.00'; }, };