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
The dropdown was listing "connections" — a row per adapter + model + key — which
made choosing a model an act of configuration, put a key icon and a bin inside a
menu item, and showed five accounts nobody had created.
A provider is now a vendor Officer curates. Every one is listed, set up or not,
because a vendor you have no key for is exactly what you need to see in order to
add one. The model is chosen per generation, from that provider's entry in the
catalogue, beside it.
With the model off the row, all that remained of a "provider" was the key for
OpenAI — and Officer already stores keys: one active value per purpose, 0600
inside a 0700 directory, outside the backup tarball, created on first use so
nothing needs provisioning. So `atelier_providers` is dropped, along with
`generations.provider_id` that pointed into it. `provider` and `model` stay as
text on each generation, which were always the historical fact rather than a
pointer to configuration.
`getSecret`, never `getKey`: this is a foreign credential, and getKey mints a
random value for a purpose it cannot find — which for someone else's API key
means authenticating with something invented.
Resolution follows: a request names a model, and the catalogue says whose it is,
rather than a database being asked which stored row served it. A model nobody
in the catalogue claims is still accepted — the catalogue is advisory, and a
model released today should work before we ship a file that mentions it.
Two routes replace five: PUT and DELETE on /providers/:name/key. Deleting a key
that came from the environment is refused rather than faked — it belongs to the
install, not to this screen.
ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only
adapter today), the generation queue, collections, and the database layer — kept on its own
Postgres database rather than folded into officer_db, since both collections and provider_settings
had rows on the machine this was extracted from.
what changed rather than just moved:
- images are served from an authenticated GET /files/:filename instead of an unauthenticated
PUBLIC_URL-prefixed static route
- the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter
- provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has
no callers left anywhere in the server code, only a migration script that also did not move
ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers
liveness.
not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not
been written, so this is not yet a discoverable plugin.