Files
atelier/sidecar/providers/qwen-local.ts
T
pastilhasandClaude Opus 5 d7f3223b6a qwen-local: the first provider that runs on our own GPU
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
2026-09-21 13:00:05 +01:00

136 lines
5.7 KiB
TypeScript

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<string, unknown> {
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<ProviderResult> {
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';
},
};