refactor(providers): a provider is a vendor and a key, and the table is gone

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.
This commit is contained in:
2026-08-20 23:02:31 +01:00
parent 3c59bf4d0c
commit 19e94129a0
17 changed files with 441 additions and 766 deletions
+9 -24
View File
@@ -8,15 +8,16 @@ export const QUALITIES = ['auto', 'low', 'medium', 'high'] as const;
export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const;
/**
* The form. Providers are passed IN rather than fetched here — `useProviders` owns them, because the
* dropdown above this form and the model list inside it have to be the same list. They were two
* fetches, and adding a provider left the form showing the old models until the page was reloaded.
* The form.
*
* The MODEL is owned above, by the provider/model pair at the top of the panel — it is a choice about
* what to generate with, made in the same place the provider is chosen, and duplicating it here gave
* two answers to one question. Everything else on the form is this hook's.
*/
export const useGenerateManager = (providers: ProviderStatus[]) => {
export const useGenerateManager = (providers: ProviderStatus[], model: string) => {
const client = useAtelierClient();
const [, bumpRefresh] = useAtelierRefresh();
const [model, setModel] = useState('');
const [prompt, setPrompt] = useState('');
const [n, setN] = useState(1);
const [size, setSize] = useState<string>('auto');
@@ -27,22 +28,9 @@ export const useGenerateManager = (providers: ProviderStatus[]) => {
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
// Follow the provider list: pick the default's model, and only overwrite a choice the user made if
// the provider behind it has gone away.
useEffect(() => {
const usable = providers.filter((row) => row.configured && row.enabled);
if (usable.length === 0) return;
if (model && usable.some((row) => row.model === model)) return;
setModel((usable.find((row) => row.is_default) ?? usable[0]!).model);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providers]);
// Derived in the hook, not in the component.
const usableProviders = providers.filter((provider) => provider.configured && provider.enabled);
// One model per configured provider, deduped: two providers on the same model is legitimate (two
// keys, two accounts) and the request picks between them by model alone.
const models = [...new Set(usableProviders.map((provider) => provider.model))];
const ready = Boolean(prompt.trim()) && Boolean(model) && !running;
// A provider we hold a key for and have code for. Both are required and neither implies the other.
const usableProviders = providers.filter((provider) => provider.configured && provider.usable);
const ready = Boolean(prompt.trim()) && Boolean(model) && usableProviders.length > 0 && !running;
async function generate() {
if (!ready) return;
@@ -82,9 +70,6 @@ export const useGenerateManager = (providers: ProviderStatus[]) => {
return {
providers,
usableProviders,
models,
model,
setModel,
prompt,
setPrompt,
n,