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
This commit is contained in:
2026-09-21 13:21:24 +01:00
co-authored by Claude Opus 5
parent d7f3223b6a
commit 5aab279b98
+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'