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:
2026-09-21 13:00:05 +01:00
co-authored by Claude Opus 5
parent bc11b3ea9d
commit d7f3223b6a
8 changed files with 253 additions and 16 deletions
+15 -5
View File
@@ -74,7 +74,11 @@ export const ProvidersBar = ({
{entry.label}
{/* Said here rather than discovered at Generate: a vendor with no key is listed
precisely so you can add one, so the state has to be visible in the list. */}
{!entry.configured && <span className="text-xs text-destructive">no key</span>}
{entry.keyless ? (
<span className="text-xs text-muted-foreground">local</span>
) : !entry.configured ? (
<span className="text-xs text-destructive">no key</span>
) : null}
</span>
</SelectItem>
))}
@@ -103,10 +107,16 @@ export const ProvidersBar = ({
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => provider && setKeyFor(provider)} disabled={!provider}>
<KeyRound className="mr-1 size-3.5" />
{provider?.configured ? 'Change key' : 'Add key'}
</Button>
{/* Hidden rather than disabled for a keyless provider. A greyed-out "Add key" invites the
question of what would enable it, and the answer is nothing — there is no key to add. */}
{provider?.keyless ? (
<span className="text-xs text-muted-foreground">Runs on this machine — no key needed</span>
) : (
<Button size="sm" variant="outline" onClick={() => provider && setKeyFor(provider)} disabled={!provider}>
<KeyRound className="mr-1 size-3.5" />
{provider?.configured ? 'Change key' : 'Add key'}
</Button>
)}
<Button
size="sm"