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
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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';
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user