3 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 5aab279b98 the runner demanded a key the provider says it does not need
Reported against qwen-local: /providers answers `keyless: true, configured:
true, usable: true`, the POST is accepted, and the row then fails with
`No API key for provider "qwen-local"` and `duration_ms: 0` — refused at
dispatch, before inference, on a credential that cannot exist.

`queue.ts` read the key unconditionally at run time. I taught
`resolveForRequest` about keyless in the previous commit and never checked
whether anything else asked the same question. Something did.

The defect is not that one check was missed, it is that there were TWO of them.
The catalogue path and the execution path each decided independently whether a
provider was usable, from the same secret store, and disagreed the moment
"usable" stopped meaning "has a key". That is why the answer now lives on the
adapter: `adapter.keyless` is a fact about the code, so every site asks the
same question of the same source instead of re-deriving it.

The run-time read itself stays, and its reasoning is untouched: a key rotated
between queueing and running should be the new one. It just no longer applies
to a provider that has nothing to rotate. `undefined` is now the failure, not
falsiness — a keyless adapter passes an empty string deliberately.

Verified end to end, which the previous commit explicitly had not been: a
512x512 through the real queue returned `status: succeeded`, `duration_ms:
186199`, `provider: qwen-local`, and a valid 512x512 RGBA PNG on disk. The
server's own log agrees at 186.13s.

Found by the owner from the API's behaviour alone, without the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-09-21 13:21:24 +01:00
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
pastilhasandClaude Opus 5 bc11b3ea9d sharp ^0.35.3, to meet av-club
plugin npm dependencies are INTERSECTED into one plugins/node_modules, and
^0.33 and ^0.35 do not overlap — a 0.x caret pins the minor, so the install
failed outright rather than picking one. av-club could not come down: its
vocabulary exposes autoOrient as an input option, which is sharp 0.34+.

atelier calls exactly one sharp method, .metadata(), in
sidecar/generations/inspect.ts. resolved to 0.35.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:36:07 +01:00
10 changed files with 272 additions and 19 deletions
+7 -1
View File
@@ -35,7 +35,13 @@ export const manifest: PluginManifest = {
// `plugins/node_modules/`, so the next plugin that wants sharp shares this copy rather than adding // `plugins/node_modules/`, so the next plugin that wants sharp shares this copy rather than adding
// another 50 MB of platform binaries. See src/servers/plugins/npm-deps.ts. // another 50 MB of platform binaries. See src/servers/plugins/npm-deps.ts.
npmDependencies: { npmDependencies: {
sharp: '^0.33.5', // ^0.35 since 2026-08-28, moved up to meet av-club. These are INTERSECTED across every plugin into
// one `plugins/node_modules/`, and `^0.33` and `^0.35` do not overlap — a 0.x caret pins the minor,
// so the two ranges could not both be satisfied and the install failed outright. av-club could not
// come down: its vocabulary exposes `autoOrient` as an INPUT option, which is sharp 0.34+, and the
// owner asked for the current release on both rather than a floor. Atelier calls exactly one
// sharp method, `.metadata()`, in `sidecar/generations/inspect.ts`.
sharp: '^0.35.3',
}, },
permissions: [ permissions: [
+44
View File
@@ -83,5 +83,49 @@ export const SHIPPED_CATALOGUE: Catalogue = {
}, },
], ],
}, },
{
/**
* The first LOCAL provider: sd-server on this machine's GPU, not a vendor.
*
* No `envVar`, because there is no key and never will be — see `keyless` on the adapter. It is
* `configured` by existing.
*
* The id is `sd-cpp-local` and not `qwen-image-2.1` because that is what `GET /v1/models`
* returns, hardcoded in sd-server with no override. The catalogue is the only side that can
* move, so it moves: the id agrees with the server, and the LABEL carries what the thing
* actually is.
*/
name: 'qwen-local',
label: 'Qwen-Image (local)',
defaultModel: 'sd-cpp-local',
models: [
{
id: 'sd-cpp-local',
/**
* The timings are in the label because there is nowhere else for them, and a user choosing
* 1024 should know before they wait rather than after. Measured from the server's own log on
* this machine: 512 completes in ~185s; 1024 samples at ~41.4s/step over 20 steps.
*
* A `note` field on CatalogueModel would be the cleaner home for this. Not added on a guess —
* it needs the UI to render it, and a field nothing displays is worse than a long label.
*/
label: 'Qwen-Image 2.1 (local) — 512 takes ~3 min, 1024 ~14 min',
kind: 'image',
/**
* 512 first, and the adapter substitutes it for `auto` or for nothing. 1024 is offered
* because it is genuinely better and sometimes worth the wait — but a default nobody chose
* should not spend a quarter of an hour of the only GPU there is.
*/
sizes: ['512x512', '768x768', '1024x1024'],
transparency: false,
/** No `edit()` in this version — same GPU, same queue, and the generate path is unproven. */
edit: false,
/** Zero is the true answer, not a missing one: it runs on hardware we already own. */
pricing: { unit: 'image', perImage: 0 },
status: 'current',
source: 'shipped',
},
],
},
], ],
}; };
+8 -2
View File
@@ -71,8 +71,14 @@ export type CatalogueProvider = {
/** Matches an adapter's `name` when code for it exists. A provider with no adapter is a listing, not an offer. */ /** Matches an adapter's `name` when code for it exists. A provider with no adapter is a listing, not an offer. */
name: string; name: string;
label: string; label: string;
/** The bootstrap credential's variable, for a connection with no stored key. */ /**
envVar: string; * The bootstrap credential's variable, for a connection with no stored key.
*
* Optional since the first LOCAL provider: a service of ours, on this machine, authenticates with
* nothing, and naming an environment variable for a key that does not exist would describe a way to
* configure it that does not work. See `keyless` on `ImageAdapter`.
*/
envVar?: string;
defaultModel?: string; defaultModel?: string;
models: CatalogueModel[]; models: CatalogueModel[];
}; };
+12 -2
View File
@@ -62,8 +62,18 @@ async function run(row: Generation): Promise<void> {
// The adapter and model come from the ROW's own columns, and the key is read at RUN time: a key // The adapter and model come from the ROW's own columns, and the key is read at RUN time: a key
// rotated between queueing and running should be the new one, and a row queued before a key existed // rotated between queueing and running should be the new one, and a row queued before a key existed
// at all runs the moment there is one. // at all runs the moment there is one.
const apiKey = keyFor(row.provider); //
if (!apiKey) throw new Error(`No API key for provider "${row.provider}"`); // Unless the adapter is KEYLESS, in which case there is nothing to read and never will be. This
// check used to be unconditional, and it is why a local model advertising `keyless: true,
// configured: true, usable: true` still failed at dispatch with `duration_ms: 0` — refused on a
// credential that cannot exist, before inference ever started.
//
// The defect was not the check, it was that there were TWO of them. `resolveForRequest` learned
// about keyless and this one did not, so the catalogue said yes and the runner said no. Anything
// that decides whether a provider is usable has to ask the same question, which is why the answer
// now lives on the adapter rather than being re-derived from the secret store at each site.
const apiKey = adapter.keyless ? '' : keyFor(row.provider);
if (apiKey === undefined) throw new Error(`No API key for provider "${row.provider}"`);
const result = const result =
row.operation === 'edit' row.operation === 'edit'
+30 -6
View File
@@ -23,6 +23,19 @@ import type { ImageAdapter } from './types';
* The catalogue says which providers exist; the secret store says which of them you can use. * The catalogue says which providers exist; the secret store says which of them you can use.
*/ */
/**
* Does this provider authenticate with anything at all?
*
* Asked of the ADAPTER, because it is a fact about the code rather than about configuration: a local
* service of ours takes no key and never will, so there is nothing a user could set or forget. Every
* "is it configured?" test below has to consult this FIRST — otherwise a keyless provider reads as
* unconfigured forever and can never be selected.
*/
export const isKeyless = (provider: string): boolean => adapterByName(provider)?.keyless === true;
/** Usable means: no key needed, or a key we hold. The single test the resolver and the UI both want. */
export const isUsable = (provider: string): boolean => isKeyless(provider) || Boolean(keyFor(provider));
/** `openai` -> `atelier-openai-key`. The store creates purposes on first use, so nothing is provisioned. */ /** `openai` -> `atelier-openai-key`. The store creates purposes on first use, so nothing is provisioned. */
export const secretPurposeFor = (provider: string) => `atelier-${provider}-key`; export const secretPurposeFor = (provider: string) => `atelier-${provider}-key`;
@@ -47,7 +60,8 @@ export function keyFor(provider: string): string | undefined {
} }
/** Where a key came from, for a UI that should say "from the environment" rather than show an empty field. */ /** Where a key came from, for a UI that should say "from the environment" rather than show an empty field. */
export function keySource(provider: string): 'store' | 'environment' | null { export function keySource(provider: string): 'store' | 'environment' | 'keyless' | null {
if (isKeyless(provider)) return 'keyless';
try { try {
if (hasKey(secretPurposeFor(provider))) return 'store'; if (hasKey(secretPurposeFor(provider))) return 'store';
} catch { } catch {
@@ -101,16 +115,21 @@ export async function resolveForRequest(options: {
// A model nobody in the catalogue claims is not refused: the catalogue is advisory, and a model // A model nobody in the catalogue claims is not refused: the catalogue is advisory, and a model
// released today should work before we have shipped a file that mentions it. The first provider we // released today should work before we have shipped a file that mentions it. The first provider we
// hold a key for gets it, and the vendor gives the real answer. // hold a key for gets it, and the vendor gives the real answer.
if (!name) name = catalogue.providers.find((entry) => keyFor(entry.name))?.name; if (!name) name = catalogue.providers.find((entry) => isUsable(entry.name))?.name;
} else { } else {
name = catalogue.providers.find((entry) => keyFor(entry.name))?.name; name = catalogue.providers.find((entry) => isUsable(entry.name))?.name;
} }
if (!name) return { error: 'No provider has an API key yet. Add one in Atelier.', status: 503 }; if (!name) return { error: 'No provider is usable yet. Add an API key in Atelier.', status: 503 };
const adapter = adapterByName(name); const adapter = adapterByName(name);
if (!adapter) return { error: `Provider "${name}" has no adapter in this build.`, status: 400 }; if (!adapter) return { error: `Provider "${name}" has no adapter in this build.`, status: 400 };
// A keyless adapter must not reach `keyFor` at all. The comment on it is the reason: it uses
// `getSecret` rather than `getKey` precisely so a missing FOREIGN credential is never minted, and a
// provider that has no credential by design has no business in that code path.
if (adapter.keyless) return { providerName: name, adapter, apiKey: '' };
const apiKey = keyFor(name); const apiKey = keyFor(name);
if (!apiKey) return { error: `Provider "${name}" has no API key.`, status: 503 }; if (!apiKey) return { error: `Provider "${name}" has no API key.`, status: 503 };
@@ -133,12 +152,17 @@ export async function providerStatuses() {
const models = provider.models.filter((model) => model.status !== 'deprecated'); const models = provider.models.filter((model) => model.status !== 'deprecated');
const source = keySource(provider.name); const source = keySource(provider.name);
const keyless = isKeyless(provider.name);
return { return {
name: provider.name, name: provider.name,
label: provider.label, label: provider.label,
configured: Boolean(keyFor(provider.name)), /** Keyless is configured by existing. There is nothing to add, so nothing can be missing. */
configured: keyless || Boolean(keyFor(provider.name)),
keyless,
key_source: source, key_source: source,
env_var: envVarFor(provider.name), /** Absent for a keyless provider: naming a variable nobody can usefully set would be a wrong hint. */
env_var: keyless ? null : envVarFor(provider.name),
/** False for a catalogue entry we have no code for — listed, but not yet usable. */ /** False for a catalogue entry we have no code for — listed, but not yet usable. */
usable: Boolean(adapter), usable: Boolean(adapter),
supports_edit: typeof adapter?.edit === 'function', supports_edit: typeof adapter?.edit === 'function',
+2 -1
View File
@@ -1,4 +1,5 @@
import { openaiAdapter } from './openai'; import { openaiAdapter } from './openai';
import { qwenLocalAdapter } from './qwen-local';
import type { ImageAdapter } from './types'; import type { ImageAdapter } from './types';
export * from './types'; export * from './types';
@@ -14,7 +15,7 @@ export * from './types';
* A list in one place, the way `hono.ts` keeps its mounts: adding an adapter is a new file plus a * A list in one place, the way `hono.ts` keeps its mounts: adding an adapter is a new file plus a
* line here. * line here.
*/ */
export const ADAPTERS: ImageAdapter[] = [openaiAdapter]; export const ADAPTERS: ImageAdapter[] = [openaiAdapter, qwenLocalAdapter];
export function adapterByName(name: string): ImageAdapter | undefined { export function adapterByName(name: string): ImageAdapter | undefined {
return ADAPTERS.find((adapter) => adapter.name === name); return ADAPTERS.find((adapter) => adapter.name === name);
+135
View File
@@ -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';
},
};
+11
View File
@@ -85,6 +85,17 @@ export type ImageAdapter = {
/** Models this adapter is known to serve. Advisory — an unknown model is passed through. */ /** Models this adapter is known to serve. Advisory — an unknown model is passed through. */
readonly models: readonly string[]; readonly models: readonly string[];
readonly defaultModel: 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 * 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. * which one is a question about configuration, not about how to talk to this provider.
+11 -1
View File
@@ -74,7 +74,11 @@ export const ProvidersBar = ({
{entry.label} {entry.label}
{/* Said here rather than discovered at Generate: a vendor with no key is listed {/* 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. */} 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> </span>
</SelectItem> </SelectItem>
))} ))}
@@ -103,10 +107,16 @@ export const ProvidersBar = ({
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* 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}> <Button size="sm" variant="outline" onClick={() => provider && setKeyFor(provider)} disabled={!provider}>
<KeyRound className="mr-1 size-3.5" /> <KeyRound className="mr-1 size-3.5" />
{provider?.configured ? 'Change key' : 'Add key'} {provider?.configured ? 'Change key' : 'Add key'}
</Button> </Button>
)}
<Button <Button
size="sm" size="sm"
+8 -2
View File
@@ -18,8 +18,14 @@ export type ProviderStatus = {
name: string; name: string;
label: string; label: string;
configured: boolean; configured: boolean;
key_source: 'store' | 'environment' | null; /**
env_var: string; * This provider authenticates with nothing — a service of ours rather than a vendor's. It is
* `configured` by existing, so anywhere that offers to add or change a key must skip it.
*/
keyless: boolean;
key_source: 'store' | 'environment' | 'keyless' | null;
/** Null for a keyless provider: naming a variable nobody can usefully set would be a wrong hint. */
env_var: string | null;
/** False when the catalogue lists a vendor this build has no adapter for. */ /** False when the catalogue lists a vendor this build has no adapter for. */
usable: boolean; usable: boolean;
supports_edit: boolean; supports_edit: boolean;