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
+22 -44
View File
@@ -1,29 +1,26 @@
import { useCallback, useEffect, useState } from 'react';
import { useAtelierClient } from '../lib/api';
import type { Adapter, ProviderStatus } from '../lib/api';
import type { ProviderStatus } from '../lib/api';
/**
* Every provider, and the verbs that change them.
* The providers Officer curates, and which of them we hold a key for.
*
* Owned here rather than inside useGenerateManager because two things need the same list and it must
* not exist twice: the dropdown at the top of the panel, and the form below it whose model choices are
* derived from whichever providers actually have a key. Adding one used to mean reloading the page
* before the form noticed.
* One list, one call. There used to be two — the configured rows and the adapter catalogue — and they
* were different shapes answering overlapping questions, which is most of why the old screen was
* confusing. `/providers` now returns the curated list with each entry's key status and its models
* attached, so "what can I use" and "what have I set up" are one answer.
*/
export const useProviders = () => {
const client = useAtelierClient();
const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [adapters, setAdapters] = useState<Adapter[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const [rows, catalogue] = await Promise.all([client.listProviders(), client.listAdapters()]);
setProviders(rows);
setAdapters(catalogue);
setProviders(await client.listProviders());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -52,37 +49,21 @@ export const useProviders = () => {
};
/**
* Create, then set the key if one was typed.
* Re-read a provider's models from the vendor, or every provider when omitted.
*
* Two calls because they are two things on the server: a provider row is public configuration, a key
* is an encrypted secret that is never sent back. A provider added without a key is legitimate — the
* key may live in the environment — so the second call is skipped rather than required.
* Its own busy flag, separate from `busy`: this is a live fetch to a third party and takes seconds,
* where setting a key is a local write. One flag would grey out the screen for a network round trip.
*/
const add = async (adapter: string, model: string, apiKey?: string) =>
guard(async () => {
const rows = await client.createProvider(adapter, model);
const created = rows.find((row) => row.adapter === adapter && row.model === model);
if (!apiKey?.trim() || !created) return rows;
return client.updateProvider(created.id, { apiKey: apiKey.trim() });
});
/**
* Re-read the catalogue from the vendor, then re-read our copy of it.
*
* Separate busy flag from `busy`: this one is a live fetch against OpenAI and takes seconds, where
* everything else here is a local database write. Sharing one flag would grey out the whole dialog
* for the length of a network round trip to a third party.
*/
const refreshCatalogue = async (provider?: string) => {
const refreshCatalogue = async (name?: string) => {
setRefreshing(true);
try {
await client.refreshCatalogue(provider);
setAdapters(await client.listAdapters());
const result = await client.refreshCatalogue(name);
setProviders(result.providers);
setError(null);
return true;
return result;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
return null;
} finally {
setRefreshing(false);
}
@@ -90,20 +71,17 @@ export const useProviders = () => {
return {
providers,
adapters,
loading,
busy,
refreshing,
refreshCatalogue,
error,
refresh,
add,
setKey: (id: string, apiKey: string) => guard(() => client.updateProvider(id, { apiKey: apiKey.trim() })),
/** The one a generation will use when the request names only a model. */
makeDefault: (id: string) => guard(() => client.updateProvider(id, { isDefault: true, enabled: true })),
remove: (id: string) => guard(() => client.deleteProvider(id)),
modelsFor: (adapter: string) => adapters.find((entry) => entry.name === adapter)?.models ?? [],
adapterFor: (adapter: string) => adapters.find((entry) => entry.name === adapter),
refreshCatalogue,
setKey: (name: string, apiKey: string) => guard(() => client.setProviderKey(name, apiKey)),
clearKey: (name: string) => guard(() => client.clearProviderKey(name)),
byName: (name: string) => providers.find((provider) => provider.name === name),
/** The one a generation would use if you pressed Generate now: your choice, else the first with a key. */
firstUsable: () => providers.find((provider) => provider.configured && provider.usable),
};
};