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
+29 -10
View File
@@ -10,7 +10,7 @@ import {
} from '../db';
import type { NewGeneration } from '../db';
import { createRouter } from '../create-router';
import { resolveForRequest } from '../providers/credentials';
import { providerStatuses, resolveForRequest } from '../providers/credentials';
import { validateImageEditRequest, validateImageRequest } from '../generations/validate';
import { INPUTS_DIR } from '../env';
import { pump } from '../generations/queue';
@@ -39,6 +39,12 @@ export const imagesRouter = createRouter();
*/
const MAX_WAIT_SECONDS = 240;
/** The catalogue's default model for a provider, for a request that named one without the other. */
async function defaultModelFor(provider: string): Promise<string | undefined> {
const status = (await providerStatuses()).find((entry) => entry.name === provider);
return status?.default_model ?? undefined;
}
/**
* Resolve `?collection=` to a collection, creating one if the value is a name we have not seen.
*
@@ -87,13 +93,21 @@ imagesRouter.post('/generations', async (ctx) => {
const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error';
return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status);
}
const { provider, adapter } = resolvedProvider;
const { providerName, adapter } = resolvedProvider;
const resolved = await resolveCollection(ctx.req.query('collection'));
if (resolved.error) return ctx.json(openaiError(resolved.error, 'not_found', 'collection'), 404);
// The request's model wins over the provider's, so a provider can serve a model nobody configured.
const model = request.model ?? provider.model;
// The request's model wins; otherwise the catalogue's default for this provider. A provider no longer
// carries a model of its own — that was configuration standing in for a per-generation choice — so
// when the catalogue has nothing to default to either, that is a 400 rather than a row with no model.
const model = request.model ?? (await defaultModelFor(providerName));
if (!model) {
return ctx.json(
openaiError(`No model given, and no default known for "${providerName}".`, 'invalid_request_error', 'model'),
400,
);
}
const requestId = randomUUID();
const count = request.n ?? 1;
@@ -105,8 +119,7 @@ imagesRouter.post('/generations', async (ctx) => {
requestId,
index,
status: 'queued' as const,
provider: adapter.name,
providerId: provider.id,
provider: providerName,
model,
prompt,
options,
@@ -233,7 +246,7 @@ imagesRouter.post('/edits', async (ctx) => {
const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error';
return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status);
}
const { provider, adapter } = resolvedProvider;
const { providerName, adapter } = resolvedProvider;
if (!adapter.edit) {
return ctx.json(openaiError(`Adapter "${adapter.name}" cannot edit images.`, 'invalid_request_error'), 400);
@@ -252,7 +265,14 @@ imagesRouter.post('/edits', async (ctx) => {
const resolved = await resolveCollection(ctx.req.query('collection'));
if (resolved.error) return ctx.json(openaiError(resolved.error, 'not_found', 'collection'), 404);
const model = request.model ?? provider.model;
const model = request.model ?? (await defaultModelFor(providerName));
if (!model) {
return ctx.json(
openaiError(`No model given, and no default known for "${providerName}".`, 'invalid_request_error', 'model'),
400,
);
}
const requestId = randomUUID();
const count = request.n ?? 1;
@@ -264,8 +284,7 @@ imagesRouter.post('/edits', async (ctx) => {
index,
operation: 'edit' as const,
status: 'queued' as const,
provider: adapter.name,
providerId: provider.id,
provider: providerName,
model,
prompt,
options,
+57 -110
View File
@@ -1,141 +1,88 @@
import { createProvider, deleteProvider, getProvider, getProviderByName, updateProvider } from '../db';
import { createRouter } from '../create-router';
import { encrypt } from '../crypto';
import { adapterByName } from '../providers';
import { adapterCatalogue, providerStatuses } from '../providers/credentials';
import { clearKey, keySource, providerStatuses, setKey } from '../providers/credentials';
import { refreshCatalogue, refreshableProviders } from '../catalogue/refresh';
import { openaiError, UUID_PATTERN } from '../errors';
import { openaiError } from '../errors';
export const providersRouter = createRouter();
const notFound = (id: string) => openaiError(`No provider with id "${id}".`, 'not_found');
/**
* Providers are rows; adapters are code.
* Providers: the curated list, plus whether we hold a key for each.
*
* `GET /api/providers` lists what is configured — several may share an adapter, which is the point.
* `GET /api/providers/adapters` lists what a new one can be built on.
* There is no create and no delete, because there is nothing to create — the providers are the ones
* Officer curates, and they are all listed whether set up or not. The only thing that changes is a
* key, which is a PUT and a DELETE on one provider, and a refresh of what models it offers.
*
* No response includes a key, in any form. Not masked either: a mask still leaks length and shape,
* and nothing needs it. The only questions are whether one is set and where it came from.
* No response includes a key, in any form.
*/
providersRouter.get('/', async (ctx) => ctx.json({ data: await providerStatuses() }));
providersRouter.get('/adapters', async (ctx) => ctx.json({ data: await adapterCatalogue() }));
const known = async (name: string) => (await providerStatuses()).some((provider) => provider.name === name);
/** Set this provider's key. Written to the platform's secret store, never to Postgres. */
providersRouter.put('/:name/key', async (ctx) => {
const name = ctx.req.param('name');
if (!(await known(name))) return ctx.json(openaiError(`No provider named "${name}".`, 'not_found'), 404);
const body = (await ctx.req.json().catch(() => null)) as { apiKey?: unknown } | null;
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey.trim() : '';
if (!apiKey) {
return ctx.json(openaiError("'apiKey' must be a non-empty string.", 'invalid_request_error', 'apiKey'), 400);
}
setKey(name, apiKey);
return ctx.json({ data: await providerStatuses() });
});
/**
* Refresh one provider's models and prices, now.
* Forget this provider's key.
*
* The only trigger there is — no schedule. `?provider=openai` refreshes that one; omitted, it does
* every provider a refresher exists for. A vendor we cannot refresh is a 400 rather than a silent
* success, since "nothing happened" and "nothing could happen" look identical from a button.
* A key that came from the environment cannot be forgotten here and says so: it belongs to the install
* rather than to this screen, and a delete that appeared to work and did not is worse than a refusal.
*/
providersRouter.delete('/:name/key', async (ctx) => {
const name = ctx.req.param('name');
if (!(await known(name))) return ctx.json(openaiError(`No provider named "${name}".`, 'not_found'), 404);
if (keySource(name) === 'environment') {
return ctx.json(
openaiError(`"${name}" takes its key from the environment; remove it there.`, 'invalid_request_error'),
409,
);
}
clearKey(name);
return ctx.json({ data: await providerStatuses() });
});
/**
* Re-read one provider's models and prices from the vendor, or every provider when omitted.
*
* Answers with the models it ended up with, so the caller can render the result without a second
* round trip to the catalogue it just changed.
* The only trigger there is — no schedule. A provider with no refresher answers 400 rather than a
* silent success: from a button, "nothing happened" and "nothing could happen" look identical.
*
* `changed` is the honest part. A refresh with no key reaches nothing and leaves the shipped catalogue
* standing, which used to report exactly like a successful pass.
*/
providersRouter.post('/catalogue/refresh', async (ctx) => {
const name = ctx.req.query('provider') || undefined;
try {
const before = await providerStatuses();
const catalogue = await refreshCatalogue(name);
const provider = name ? catalogue.providers.find((entry) => entry.name === name) : undefined;
const after = await providerStatuses();
return ctx.json({
data: {
updated_at: catalogue.updatedAt,
refreshable: refreshableProviders(),
models: (provider?.models ?? catalogue.providers.flatMap((entry) => entry.models)).length,
providers: after,
changed: JSON.stringify(before.map((p) => p.model_details)) !== JSON.stringify(after.map((p) => p.model_details)),
},
});
} catch (err) {
return ctx.json(openaiError(err instanceof Error ? err.message : String(err), 'invalid_request_error', 'provider'), 400);
return ctx.json(
openaiError(err instanceof Error ? err.message : String(err), 'invalid_request_error', 'provider'),
400,
);
}
});
providersRouter.post('/', async (ctx) => {
const body = (await ctx.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
const adapterName = typeof body.adapter === 'string' ? body.adapter : '';
const adapter = adapterByName(adapterName);
if (!adapter) {
return ctx.json(openaiError(`Unknown adapter "${adapterName}".`, 'invalid_request_error', 'adapter'), 400);
}
const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : adapter.defaultModel;
// Named after what it is, unless the caller had a better idea. Two providers on one adapter need
// distinguishable names, and "OpenAI" alone stops being one the moment there are two.
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : `${adapter.label} · ${model}`;
if (await getProviderByName(name)) {
return ctx.json(openaiError(`A provider named "${name}" already exists.`, 'invalid_request_error', 'name'), 409);
}
const row = await createProvider({
name,
adapter: adapter.name,
model,
apiKeyEncrypted: typeof body.apiKey === 'string' && body.apiKey.trim() ? encrypt(body.apiKey.trim()) : null,
enabled: body.enabled === undefined ? true : Boolean(body.enabled),
isDefault: Boolean(body.isDefault),
});
return ctx.json({ data: await providerStatuses(), created: row.id }, 201);
});
providersRouter.patch('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getProvider(id))) return ctx.json(notFound(id), 404);
const body = (await ctx.req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400);
const values: Record<string, unknown> = {};
// Undefined means "leave alone" and null means "clear" — the UI sends `enabled` on its own when
// toggling, and a naive assignment would blank the credential every time someone flipped a switch.
if (body.apiKey !== undefined) {
if (body.apiKey === null || body.apiKey === '') values.apiKeyEncrypted = null;
else if (typeof body.apiKey === 'string') values.apiKeyEncrypted = encrypt(body.apiKey.trim());
else return ctx.json(openaiError("'apiKey' must be a string or null.", 'invalid_request_error', 'apiKey'), 400);
}
if (body.model !== undefined) {
if (typeof body.model !== 'string' || !body.model.trim()) {
return ctx.json(openaiError("'model' must be a non-empty string.", 'invalid_request_error', 'model'), 400);
}
values.model = body.model.trim();
}
if (body.name !== undefined) {
if (typeof body.name !== 'string' || !body.name.trim()) {
return ctx.json(openaiError("'name' must be a non-empty string.", 'invalid_request_error', 'name'), 400);
}
const clash = await getProviderByName(body.name.trim());
if (clash && clash.id !== id) {
return ctx.json(
openaiError(`A provider named "${body.name.trim()}" already exists.`, 'invalid_request_error', 'name'),
409,
);
}
values.name = body.name.trim();
}
if (body.enabled !== undefined) values.enabled = Boolean(body.enabled);
if (body.isDefault !== undefined) values.isDefault = Boolean(body.isDefault);
if (Object.keys(values).length === 0)
return ctx.json(openaiError('Nothing to update.', 'invalid_request_error'), 400);
await updateProvider(id, values);
return ctx.json({ data: await providerStatuses() });
});
/** Removes the configuration. Images it made keep their adapter and model as text and are unaffected. */
providersRouter.delete('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getProvider(id))) return ctx.json(notFound(id), 404);
await deleteProvider(id);
return ctx.json({ data: await providerStatuses() });
});