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
+4 -1
View File
@@ -5,6 +5,10 @@
// themselves live beside the code that queries them, in `sidecar/db/`, and are re-exported here rather // themselves live beside the code that queries them, in `sidecar/db/`, and are re-exported here rather
// than duplicated. // than duplicated.
// //
// Two tables, not three: `atelier_providers` is gone. A provider is a curated vendor plus a key in the
// platform's secret store, so there is no provider state in Postgres to keep — see
// sidecar/providers/credentials.ts.
//
// Everything is `atelier_`-prefixed and lives in officer_db, exactly like every other plugin. The // Everything is `atelier_`-prefixed and lives in officer_db, exactly like every other plugin. The
// extraction originally kept Atelier's own `atelier` database, on the reasoning that its tables already // extraction originally kept Atelier's own `atelier` database, on the reasoning that its tables already
// had rows. That was reversed on 2026-08-20: the plugin starts EMPTY here, and the standalone service's // had rows. That was reversed on 2026-08-20: the plugin starts EMPTY here, and the standalone service's
@@ -12,4 +16,3 @@
// from every other plugin is a plugin nobody can reason about. // from every other plugin is a plugin nobody can reason about.
export * from '../sidecar/db/collections/schema'; export * from '../sidecar/db/collections/schema';
export * from '../sidecar/db/generations/schema'; export * from '../sidecar/db/generations/schema';
export * from '../sidecar/db/providers/schema';
+29 -10
View File
@@ -10,7 +10,7 @@ import {
} from '../db'; } from '../db';
import type { NewGeneration } from '../db'; import type { NewGeneration } from '../db';
import { createRouter } from '../create-router'; import { createRouter } from '../create-router';
import { resolveForRequest } from '../providers/credentials'; import { providerStatuses, resolveForRequest } from '../providers/credentials';
import { validateImageEditRequest, validateImageRequest } from '../generations/validate'; import { validateImageEditRequest, validateImageRequest } from '../generations/validate';
import { INPUTS_DIR } from '../env'; import { INPUTS_DIR } from '../env';
import { pump } from '../generations/queue'; import { pump } from '../generations/queue';
@@ -39,6 +39,12 @@ export const imagesRouter = createRouter();
*/ */
const MAX_WAIT_SECONDS = 240; 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. * 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'; const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error';
return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status); 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')); const resolved = await resolveCollection(ctx.req.query('collection'));
if (resolved.error) return ctx.json(openaiError(resolved.error, 'not_found', 'collection'), 404); 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. // The request's model wins; otherwise the catalogue's default for this provider. A provider no longer
const model = request.model ?? provider.model; // 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 requestId = randomUUID();
const count = request.n ?? 1; const count = request.n ?? 1;
@@ -105,8 +119,7 @@ imagesRouter.post('/generations', async (ctx) => {
requestId, requestId,
index, index,
status: 'queued' as const, status: 'queued' as const,
provider: adapter.name, provider: providerName,
providerId: provider.id,
model, model,
prompt, prompt,
options, options,
@@ -233,7 +246,7 @@ imagesRouter.post('/edits', async (ctx) => {
const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error'; const type = resolvedProvider.status === 404 ? 'not_found' : 'configuration_error';
return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status); return ctx.json(openaiError(resolvedProvider.error, type), resolvedProvider.status);
} }
const { provider, adapter } = resolvedProvider; const { providerName, adapter } = resolvedProvider;
if (!adapter.edit) { if (!adapter.edit) {
return ctx.json(openaiError(`Adapter "${adapter.name}" cannot edit images.`, 'invalid_request_error'), 400); 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')); const resolved = await resolveCollection(ctx.req.query('collection'));
if (resolved.error) return ctx.json(openaiError(resolved.error, 'not_found', 'collection'), 404); 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 requestId = randomUUID();
const count = request.n ?? 1; const count = request.n ?? 1;
@@ -264,8 +284,7 @@ imagesRouter.post('/edits', async (ctx) => {
index, index,
operation: 'edit' as const, operation: 'edit' as const,
status: 'queued' as const, status: 'queued' as const,
provider: adapter.name, provider: providerName,
providerId: provider.id,
model, model,
prompt, prompt,
options, options,
+55 -108
View File
@@ -1,141 +1,88 @@
import { createProvider, deleteProvider, getProvider, getProviderByName, updateProvider } from '../db';
import { createRouter } from '../create-router'; import { createRouter } from '../create-router';
import { encrypt } from '../crypto'; import { clearKey, keySource, providerStatuses, setKey } from '../providers/credentials';
import { adapterByName } from '../providers';
import { adapterCatalogue, providerStatuses } from '../providers/credentials';
import { refreshCatalogue, refreshableProviders } from '../catalogue/refresh'; import { refreshCatalogue, refreshableProviders } from '../catalogue/refresh';
import { openaiError, UUID_PATTERN } from '../errors'; import { openaiError } from '../errors';
export const providersRouter = createRouter(); 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. * There is no create and no delete, because there is nothing to create — the providers are the ones
* `GET /api/providers/adapters` lists what a new one can be built on. * 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, * No response includes a key, in any form.
* and nothing needs it. The only questions are whether one is set and where it came from.
*/ */
providersRouter.get('/', async (ctx) => ctx.json({ data: await providerStatuses() })); 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 * A key that came from the environment cannot be forgotten here and says so: it belongs to the install
* every provider a refresher exists for. A vendor we cannot refresh is a 400 rather than a silent * rather than to this screen, and a delete that appeared to work and did not is worse than a refusal.
* success, since "nothing happened" and "nothing could happen" look identical from a button. */
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 * The only trigger there is — no schedule. A provider with no refresher answers 400 rather than a
* round trip to the catalogue it just changed. * 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) => { providersRouter.post('/catalogue/refresh', async (ctx) => {
const name = ctx.req.query('provider') || undefined; const name = ctx.req.query('provider') || undefined;
try { try {
const before = await providerStatuses();
const catalogue = await refreshCatalogue(name); const catalogue = await refreshCatalogue(name);
const provider = name ? catalogue.providers.find((entry) => entry.name === name) : undefined; const after = await providerStatuses();
return ctx.json({ return ctx.json({
data: { data: {
updated_at: catalogue.updatedAt, updated_at: catalogue.updatedAt,
refreshable: refreshableProviders(), 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) { } catch (err) {
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( return ctx.json(
openaiError(`A provider named "${body.name.trim()}" already exists.`, 'invalid_request_error', 'name'), openaiError(err instanceof Error ? err.message : String(err), 'invalid_request_error', 'provider'),
409, 400,
); );
} }
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() });
}); });
+4 -12
View File
@@ -1,5 +1,4 @@
import { listProviders } from '../db'; import { keyFor } from '../providers/credentials';
import { keyForProvider } from '../providers/credentials';
import { projectEnv } from '../env'; import { projectEnv } from '../env';
import type { Catalogue, CatalogueModel } from './types'; import type { Catalogue, CatalogueModel } from './types';
import { CATALOGUE_REVISION } from './types'; import { CATALOGUE_REVISION } from './types';
@@ -33,15 +32,8 @@ const PRICING_URL = projectEnv('ATELIER_OPENAI_PRICING_URL') ?? 'https://platfor
*/ */
const IMAGE_MODEL = /^(gpt-image|dall-e)/i; const IMAGE_MODEL = /^(gpt-image|dall-e)/i;
/** A key from any connection, or the bootstrap environment variable. Refresh is read-only, so any will do. */ /** OpenAI's key: the secret store, then the environment. `keyFor` already looks in both, in that order. */
async function anyKey(): Promise<string | undefined> { const anyKey = (): string | undefined => keyFor('openai');
for (const provider of await listProviders()) {
if (provider.adapter !== 'openai') continue;
const key = await keyForProvider(provider);
if (key) return key;
}
return projectEnv('OPENAI_API_KEY');
}
async function fetchModelIds(apiKey: string): Promise<string[] | null> { async function fetchModelIds(apiKey: string): Promise<string[] | null> {
try { try {
@@ -118,7 +110,7 @@ async function fetchPricing(modelIds: string[]): Promise<Map<string, number[]>>
* can tell a complete listing from a missing one. * can tell a complete listing from a missing one.
*/ */
export async function refreshOpenAI(): Promise<{ catalogue: Catalogue | null; listedIds: string[] | null }> { export async function refreshOpenAI(): Promise<{ catalogue: Catalogue | null; listedIds: string[] | null }> {
const apiKey = await anyKey(); const apiKey = anyKey();
if (!apiKey) { if (!apiKey) {
console.log('[atelier:catalogue] no OpenAI key available — nothing to refresh, shipped catalogue stands'); console.log('[atelier:catalogue] no OpenAI key available — nothing to refresh, shipped catalogue stands');
return { catalogue: null, listedIds: null }; return { catalogue: null, listedIds: null };
+5 -7
View File
@@ -55,18 +55,16 @@ export const generations = pgTable('atelier_generations',
status: text('status').notNull().default('queued').$type<GenerationStatus>(), status: text('status').notNull().default('queued').$type<GenerationStatus>(),
/** /**
* The adapter and model, as text, denormalised on purpose. * The provider and model, as text, and now the only record of either.
* *
* `providerId` below points at the configured provider, but that row can be renamed, repointed at * There was a `provider_id` beside these, pointing at a configured provider row. Both the column
* another model, or deleted — and none of that may change what an existing image was made with. * and the table it referenced are gone: a provider is a curated vendor plus a key in the secret
* The row is a pointer to configuration; these two are the historical fact. * store, so there is no row to point at. These two were always the historical fact — what this
* image was actually made with, immune to configuration changing afterwards — and they still are.
*/ */
provider: text('provider').notNull(), provider: text('provider').notNull(),
model: text('model').notNull(), model: text('model').notNull(),
/** The provider row this came from, when there was one. Null for rows made before providers existed. */
providerId: uuid('provider_id'),
prompt: text('prompt').notNull(), prompt: text('prompt').notNull(),
/** Whatever else the caller asked for — size, quality, background. Provider-shaped, so opaque here. */ /** Whatever else the caller asked for — size, quality, background. Provider-shaped, so opaque here. */
-1
View File
@@ -7,7 +7,6 @@
// stay in the `atelier` database, untouched. See PLUGIN.md. // stay in the `atelier` database, untouched. See PLUGIN.md.
export * from './generations'; export * from './generations';
export * from './collections'; export * from './collections';
export * from './providers';
// The connection itself, so callers do not reach past the package for it. // The connection itself, so callers do not reach past the package for it.
export { db, waitForDatabase } from './db'; export { db, waitForDatabase } from './db';
-2
View File
@@ -1,2 +0,0 @@
export * from './schema';
export * from './queries';
-100
View File
@@ -1,100 +0,0 @@
import { and, asc, eq, ne } from 'drizzle-orm';
import { db } from '../db';
import { providers } from './schema';
export type Provider = typeof providers.$inferSelect;
export type NewProvider = typeof providers.$inferInsert;
export async function listProviders(): Promise<Provider[]> {
return db.select().from(providers).orderBy(asc(providers.name));
}
export async function getProvider(id: string): Promise<Provider | undefined> {
const [row] = await db.select().from(providers).where(eq(providers.id, id)).limit(1);
return row;
}
export async function getProviderByName(name: string): Promise<Provider | undefined> {
const [row] = await db.select().from(providers).where(eq(providers.name, name)).limit(1);
return row;
}
/**
* Which provider serves a request, in the order a caller would expect.
*
* 1. the model, if exactly one enabled provider offers it
* 2. the model, preferring the default when several do — two keys for the same model is an
* ordinary thing and the tie has to break somewhere stable
* 3. the default provider, for a model nothing is configured for
*
* Step 3 is deliberate rather than a 404. Vendors ship models faster than rows get added, and the
* request still names a model the adapter will happily pass along — refusing it would make Atelier
* the reason a brand-new model cannot be used.
*/
export async function resolveProviderForModel(model: string | undefined): Promise<Provider | undefined> {
if (model) {
const candidates = await db
.select()
.from(providers)
.where(and(eq(providers.model, model), eq(providers.enabled, true)))
.orderBy(asc(providers.createdAt));
const match = candidates.find((row) => row.isDefault) ?? candidates[0];
if (match) return match;
}
return defaultProvider();
}
export async function defaultProvider(): Promise<Provider | undefined> {
const [byFlag] = await db
.select()
.from(providers)
.where(and(eq(providers.isDefault, true), eq(providers.enabled, true)))
.limit(1);
if (byFlag) return byFlag;
// No flag set — the oldest enabled row, so a single-provider install needs no configuration at all.
const [oldest] = await db
.select()
.from(providers)
.where(eq(providers.enabled, true))
.orderBy(asc(providers.createdAt))
.limit(1);
return oldest;
}
export async function createProvider(values: NewProvider): Promise<Provider> {
const [row] = await db.insert(providers).values(values).returning();
if (row?.isDefault) await clearOtherDefaults(row.id);
return row!;
}
export async function updateProvider(id: string, values: Partial<NewProvider>): Promise<Provider | undefined> {
const [row] = await db
.update(providers)
.set({ ...values, updatedAt: new Date() })
.where(eq(providers.id, id))
.returning();
if (row?.isDefault) await clearOtherDefaults(id);
return row;
}
export async function deleteProvider(id: string): Promise<void> {
await db.delete(providers).where(eq(providers.id, id));
}
/**
* Exactly one default.
*
* Enforced by clearing the others on write rather than by a partial unique index, because the write
* that sets a new default would otherwise have to clear the old one first — two statements racing to
* leave zero defaults instead of two.
*/
async function clearOtherDefaults(keepId: string): Promise<void> {
await db
.update(providers)
.set({ isDefault: false })
.where(and(eq(providers.isDefault, true), ne(providers.id, keepId)));
}
-54
View File
@@ -1,54 +0,0 @@
import { pgTable, uuid, text, boolean, timestamp, index } from 'drizzle-orm/pg-core';
/**
* A configured connection to a model: adapter + model + credentials.
*
* ── Why this is a row and the adapter is not ──
*
* "OpenAI" is not a thing you can generate with. "OpenAI's gpt-image-1, with this key" is. The two
* differ in how often they change: an ADAPTER is code that learns a vendor's HTTP API and is written
* once; a PROVIDER is a decision — which model, whose key, is it on — and you make several of those
* per adapter and change them at runtime.
*
* So `openai + gpt-image-1` and `openai + dall-e-3` are two rows sharing one adapter and possibly one
* key. The previous design keyed configuration on the adapter name, which made that unsayable: there
* was exactly one OpenAI, and its model was a property of it rather than the point of it.
*
* Nothing here constrains `model` against the adapter. Vendors ship models faster than a list in this
* repo is updated, and refusing a new one on release day is a worse failure than passing it through
* and letting the vendor answer.
*/
export const providers = pgTable('atelier_providers',
{
id: uuid('id').primaryKey().defaultRandom(),
/** What a human calls it — "OpenAI · gpt-image-1". Unique, so it can be named in a request. */
name: text('name').notNull().unique(),
/** Which adapter drives it. Text, not an enum: adding an adapter must not be a schema change. */
adapter: text('adapter').notNull(),
model: text('model').notNull(),
/** AES-256-GCM via servers/crypto.ts. Never leaves the server and never appears in a response. */
apiKeyEncrypted: text('api_key_encrypted'),
enabled: boolean('enabled').notNull().default(true),
/**
* The one to use when a request names no provider and no model, or names a model nothing serves.
*
* A column rather than "the oldest row" because it is a decision the owner makes and should be
* able to change without deleting and recreating rows. Exactly one row should carry it; the
* queries below enforce that by clearing the others when one is set.
*/
isDefault: boolean('is_default').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// Resolving a request by model is the hot path: every generation does it once.
index('atelier_providers_model_idx').on(table.model, table.enabled),
],
);
+7 -7
View File
@@ -1,10 +1,10 @@
// What drizzle-kit creates, and the runtime's view of the tables — in the SEPARATE `atelier` database, // What drizzle-kit creates, and the runtime's view of the tables.
// not officer_db. See PLUGIN.md for why this plugin keeps its own database rather than folding into
// officer_db's <appname>_-prefixed convention: both `collections` and `provider_settings` had rows on
// the machine this was extracted from, and losing someone's generated images to a schema decision is
// not a trade to make unattended.
// //
// `provider_settings` is deliberately not exported here — see ./index.ts. // Two tables. `atelier_providers` is gone: a provider is a vendor Officer curates plus a key in the
// platform's secret store, so there is no provider state in Postgres to keep — see
// sidecar/providers/credentials.ts. The five rows that table held had been minted by a migration, one
// per model in a hard-coded list, and read as five accounts nobody had configured.
//
// Everything is `atelier_`-prefixed and lives in officer_db, exactly like every other plugin.
export * from './generations/schema'; export * from './generations/schema';
export * from './collections/schema'; export * from './collections/schema';
export * from './providers/schema';
+7 -9
View File
@@ -1,9 +1,9 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { claimableGenerations, failInterrupted, getProvider, markFailed, markRunning, markSucceeded } from '../db'; import { claimableGenerations, failInterrupted, markFailed, markRunning, markSucceeded } from '../db';
import type { Generation } from '../db'; import type { Generation } from '../db';
import { IMAGES_DIR, INPUTS_DIR, MAX_CONCURRENT_GENERATIONS } from '../env'; import { IMAGES_DIR, INPUTS_DIR, MAX_CONCURRENT_GENERATIONS } from '../env';
import { adapterByName } from '../providers'; import { adapterByName } from '../providers';
import { keyForProvider } from '../providers/credentials'; import { keyFor } from '../providers/credentials';
import { inspectImage } from './inspect'; import { inspectImage } from './inspect';
import type { ImageEditRequest, ImageRequest, UploadedImage } from '../providers'; import type { ImageEditRequest, ImageRequest, UploadedImage } from '../providers';
@@ -59,13 +59,11 @@ async function run(row: Generation): Promise<void> {
const adapter = adapterByName(row.provider); const adapter = adapterByName(row.provider);
if (!adapter) throw new Error(`Unknown adapter "${row.provider}"`); if (!adapter) throw new Error(`Unknown adapter "${row.provider}"`);
// The provider row is looked up by id, but the adapter and model come from the ROW's own columns: // The adapter and model come from the ROW's own columns, and the key is read at RUN time: a key
// a provider renamed or repointed after this was queued must not change what gets generated. // rotated between queueing and running should be the new one, and a row queued before a key existed
const provider = row.providerId ? await getProvider(row.providerId) : undefined; // at all runs the moment there is one.
const apiKey = provider const apiKey = keyFor(row.provider);
? await keyForProvider(provider) if (!apiKey) throw new Error(`No API key for provider "${row.provider}"`);
: await keyForProvider({ adapter: row.provider, apiKeyEncrypted: null, name: row.provider } as never);
if (!apiKey) throw new Error(`No API key for adapter "${row.provider}"`);
const result = const result =
row.operation === 'edit' row.operation === 'edit'
+105 -109
View File
@@ -1,156 +1,152 @@
import { getSecret } from 'officerdb/secret-store'; import { getSecret, hasKey, putSecret } from 'officerdb/secret-store';
import { getProvider, getProviderByName, listProviders, resolveProviderForModel } from '../db'; import { adapterByName } from './index';
import type { Provider } from '../db';
import { decrypt } from '../crypto';
import { ADAPTERS, adapterByName } from './index';
import { getCatalogue } from '../catalogue/store'; import { getCatalogue } from '../catalogue/store';
import { refreshableProviders } from '../catalogue/refresh'; import { refreshableProviders } from '../catalogue/refresh';
import type { ImageAdapter } from './types'; import type { ImageAdapter } from './types';
/** /**
* The key for a provider: its own, then the adapter's bootstrap credential. * Which vendors Atelier can generate with, and whether we hold a key for each.
* *
* That order because the row is where the owner put a key deliberately. The bootstrap credential is * ── There is no provider table any more ──
* how a key gets in before there is a UI to put one in, and how an install supplies one without a *
* database write. Several providers sharing an adapter therefore also share the fallback, which is * There used to be one: a row per adapter+model+key, called a "provider", and the model was part of the
* right: it is the same account. * stored thing. That made "which model" a piece of persistent configuration when it is really a choice
* made per generation, and it produced five rows nobody had created — one per model in a hard-coded
* list — which read as five configured accounts.
*
* With the model out of it, what remains of a row is exactly one fact: the key for OpenAI. Officer
* already stores keys, in `secrets/officer-keys.db`, one active value per purpose, 0600 inside a 0700
* directory, outside the backup tarball. So that is where they live, and Atelier keeps no provider
* state in Postgres at all. "Configured" means the secret store has `atelier-<provider>-key`, or the
* environment has `<PROVIDER>_API_KEY`. Nothing else can be true or false about a provider.
*
* The catalogue says which providers exist; the secret store says which of them you can use.
*/ */
export async function keyForProvider(provider: Provider): Promise<string | undefined> {
if (provider.apiKeyEncrypted) { /** `openai` -> `atelier-openai-key`. The store creates purposes on first use, so nothing is provisioned. */
export const secretPurposeFor = (provider: string) => `atelier-${provider}-key`;
/** `openai` -> `OPENAI_API_KEY`. The bootstrap path, for an install that supplies a key without a UI. */
export const envVarFor = (provider: string) => `${provider.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
/**
* The key for a provider: the secret store, then `.env`.
*
* `getSecret`, never `getKey`: this is a FOREIGN credential — OpenAI's, not ours — and `getKey` mints a
* random value for a purpose it does not find, which for a foreign credential means authenticating with
* something invented. The distinction is the whole reason both functions exist.
*/
export function keyFor(provider: string): string | undefined {
try { try {
return decrypt(provider.apiKeyEncrypted); const stored = getSecret(secretPurposeFor(provider));
} catch (error) { if (stored) return stored;
// A key that will not decrypt is broken, not absent. Falling through to the bootstrap credential } catch {
// would hide that the stored credential is unusable. // Unreadable store — fall through to env, as every other secret-store caller in this codebase does.
console.error(`[atelier:providers] stored key for "${provider.name}" failed to decrypt:`, error);
return undefined;
} }
return process.env[envVarFor(provider)];
} }
return bootstrapKeyFor(provider.adapter); /** Where a key came from, for a UI that should say "from the environment" rather than show an empty field. */
export function keySource(provider: string): 'store' | 'environment' | null {
try {
if (hasKey(secretPurposeFor(provider))) return 'store';
} catch {
/* unreadable store */
}
return process.env[envVarFor(provider)] ? 'environment' : null;
}
/** Save a key. Trimmed, because a pasted key routinely arrives with a newline attached. */
export function setKey(provider: string, apiKey: string): void {
putSecret(secretPurposeFor(provider), apiKey.trim());
} }
/** /**
* The adapter's bootstrap credential — the secret store first, `.env` second. * Forget a key.
* *
* `getSecret`, never `getKey`: this is a foreign credential (OpenAI's, not ours), and `getKey` mints a * Only the stored one can be forgotten: a key in `.env` belongs to the install, not to this screen, and
* random value for an absent purpose — for a foreign credential that means silently authenticating * a button that appeared to delete it and did not would be worse than no button.
* with a fake. `putSecret`/`getSecret` landed 2026-08-19; `bun run secret:set atelier-openai-key`
* writes it. Falls back to `<ADAPTER>_API_KEY` in `.env` so an install that predates the secret store
* keeps working unchanged.
*
* Keyed per-adapter (`atelier-<adapter>-key`), matching `envVarFor`'s own per-adapter shape below —
* several providers on one adapter already share one fallback, and this preserves that.
*/ */
function bootstrapKeyFor(adapter: string): string | undefined { export function clearKey(provider: string): void {
try { putSecret(secretPurposeFor(provider), '');
const stored = getSecret(`atelier-${adapter}-key`);
if (stored) return stored;
} catch {
// Unreadable store — fall through to env, same as every other secret-store caller in this codebase.
}
return process.env[envVarFor(adapter)];
} }
/** `openai` -> `OPENAI_API_KEY`. Per adapter, not per provider: it is one account. */ export type ResolvedProvider = { providerName: string; adapter: ImageAdapter; apiKey: string };
export const envVarFor = (adapter: string) => `${adapter.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
export type ResolvedProvider = { provider: Provider; adapter: ImageAdapter; apiKey: string }; /**
* Everything needed to run a generation, or the reason it cannot run.
/** Everything needed to run a generation, or a reason it cannot run. */ *
* Resolution is by MODEL now — the caller names a model, the catalogue says whose it is. That replaces
* a database lookup that asked which stored row served a model, which is the same question asked of a
* worse source: the catalogue is the list of what exists, and the rows were only ever a subset of it
* that someone happened to have created.
*
* `provider` may still be named explicitly, and an unknown one is an error rather than a fallback: a
* silent substitution bills a different account than the caller asked for.
*/
export async function resolveForRequest(options: { export async function resolveForRequest(options: {
providerRef?: string; providerRef?: string;
model?: string; model?: string;
}): Promise<ResolvedProvider | { error: string; status: 400 | 404 | 503 }> { }): Promise<ResolvedProvider | { error: string; status: 400 | 404 | 503 }> {
let provider: Provider | undefined; const catalogue = await getCatalogue();
if (options.providerRef) { let name = options.providerRef;
// Named explicitly — a uuid or the provider's name. An unknown one is the caller's mistake and
// must not silently fall back to a different provider that will bill them.
provider = /^[0-9a-f-]{36}$/i.test(options.providerRef)
? await getProvider(options.providerRef)
: await getProviderByName(options.providerRef);
if (!provider) return { error: `No provider named "${options.providerRef}".`, status: 404 }; if (name) {
if (!provider.enabled) return { error: `Provider "${provider.name}" is disabled.`, status: 503 }; if (!catalogue.providers.some((entry) => entry.name === name)) {
return { error: `No provider named "${name}".`, status: 404 };
}
} else if (options.model) {
name = catalogue.providers.find((entry) => entry.models.some((model) => model.id === options.model))?.name;
// A model nobody in the catalogue claims is not refused: the catalogue is advisory, and a model
// released today should work before we have shipped a file that mentions it. The first provider we
// hold a key for gets it, and the vendor gives the real answer.
if (!name) name = catalogue.providers.find((entry) => keyFor(entry.name))?.name;
} else { } else {
provider = await resolveProviderForModel(options.model); name = catalogue.providers.find((entry) => keyFor(entry.name))?.name;
if (!provider) {
return { error: 'No provider is configured. Add one in Settings → Integrations → Atelier.', status: 503 };
}
} }
const adapter = adapterByName(provider.adapter); if (!name) return { error: 'No provider has an API key yet. Add one in Atelier.', status: 503 };
if (!adapter)
return { error: `Provider "${provider.name}" uses unknown adapter "${provider.adapter}".`, status: 400 };
const apiKey = await keyForProvider(provider); const adapter = adapterByName(name);
if (!apiKey) return { error: `Provider "${provider.name}" has no API key.`, status: 503 }; if (!adapter) return { error: `Provider "${name}" has no adapter in this build.`, status: 400 };
return { provider, adapter, apiKey }; const apiKey = keyFor(name);
} if (!apiKey) return { error: `Provider "${name}" has no API key.`, status: 503 };
/** What the providers screen renders. Never includes a key, in any form. */ return { providerName: name, adapter, apiKey };
export async function providerStatuses() {
const rows = await listProviders();
return Promise.all(
rows.map(async (row) => ({
id: row.id,
name: row.name,
adapter: row.adapter,
model: row.model,
enabled: row.enabled,
is_default: row.isDefault,
configured: Boolean(await keyForProvider(row)),
// Where the key came from, so the UI can say "set as a bootstrap credential" rather than showing
// an empty field next to a provider that works.
key_source: row.apiKeyEncrypted
? ('database' as const)
: (await keyForProvider(row))
? ('environment' as const)
: null,
env_var: envVarFor(row.adapter),
created_at: row.createdAt,
updated_at: row.updatedAt,
})),
);
} }
/** /**
* What a new connection can be built on: the CATALOGUE, filtered to adapters that exist as code. * What the UI lists: every provider in the catalogue, one per vendor, whether or not it is set up.
* *
* The model list used to come off the adapter itself — a hard-coded array beside the HTTP client — * All of them, always — a vendor you have no key for is precisely what you need to see in order to add
* which meant adding a model was a code change and a release. It comes from the curated catalogue * one. Never includes a key, in any form; not masked either, since a mask still leaks length and shape
* now, refreshed daily, and the adapter keeps the one thing only it knows: whether it can edit. * and the only questions are whether one is set and where it came from.
*
* A catalogue provider with no adapter is skipped rather than offered. Listing a vendor we cannot
* actually call is an offer that fails at the moment of use, which is worse than not being offered.
*/ */
export const adapterCatalogue = async () => { export async function providerStatuses() {
const catalogue = await getCatalogue(); const catalogue = await getCatalogue();
const refreshable = refreshableProviders();
return catalogue.providers.flatMap((provider) => { return catalogue.providers.map((provider) => {
const adapter = adapterByName(provider.name); const adapter = adapterByName(provider.name);
if (!adapter) return [];
// Deprecated models stay in the catalogue so old rows keep their labels; they are not offered.
const models = provider.models.filter((model) => model.status !== 'deprecated'); const models = provider.models.filter((model) => model.status !== 'deprecated');
const source = keySource(provider.name);
return [ return {
{
name: provider.name, name: provider.name,
label: provider.label, label: provider.label,
configured: Boolean(keyFor(provider.name)),
key_source: source,
env_var: envVarFor(provider.name),
/** False for a catalogue entry we have no code for — listed, but not yet usable. */
usable: Boolean(adapter),
supports_edit: typeof adapter?.edit === 'function',
refreshable: refreshable.includes(provider.name),
default_model: provider.defaultModel ?? adapter?.defaultModel ?? models[0]?.id ?? null,
models: models.map((model) => model.id), models: models.map((model) => model.id),
/** The full records, for a UI that offers per-model sizes and qualities rather than one list. */
model_details: models, model_details: models,
default_model: provider.defaultModel ?? adapter.defaultModel,
env_var: provider.envVar || envVarFor(provider.name),
supports_edit: typeof adapter.edit === 'function',
/** When this provider's models were last refreshed; null while only the shipped file has been read. */
updated_at: catalogue.updatedAt ?? null, updated_at: catalogue.updatedAt ?? null,
refreshable: refreshableProviders().includes(provider.name),
},
];
});
}; };
});
}
+8 -22
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -11,17 +12,17 @@ import { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerate
export const Generate = () => { export const Generate = () => {
const providers = useProviders(); const providers = useProviders();
const manager = useGenerateManager(providers.providers); // The model lives here, between the two: chosen in the bar above, used by the form below.
const [model, setModel] = useState('');
const manager = useGenerateManager(providers.providers, model);
return ( return (
<div className="h-full space-y-6 overflow-y-auto p-4"> <div className="h-full space-y-6 overflow-y-auto p-4">
{/* Which provider, then what to make with it — one panel, in that order. */} {/* Which provider and model, then what to make with it — one panel, in that order. */}
<ProvidersBar manager={providers} /> <ProvidersBar manager={providers} model={model} onModelChange={setModel} />
{providers.providers.length > 0 && manager.usableProviders.length === 0 && ( {!providers.loading && manager.usableProviders.length === 0 && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">No provider has a key yet — add one above.</p>
No provider is ready. Set an API key from the dropdown above.
</p>
)} )}
<div className="space-y-2"> <div className="space-y-2">
@@ -36,21 +37,6 @@ export const Generate = () => {
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field label="Model" id="atelier-model">
<Select value={manager.model} onValueChange={manager.setModel}>
<SelectTrigger id="atelier-model" className="w-full">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{manager.models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Count" id="atelier-n"> <Field label="Count" id="atelier-n">
<Input <Input
id="atelier-n" id="atelier-n"
+144 -208
View File
@@ -1,13 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Check, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { KeyRound, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -23,189 +16,66 @@ import type { ProviderStatus } from '../lib/api';
import type { ProvidersManager } from './useProviders'; import type { ProvidersManager } from './useProviders';
/** /**
* Providers, as one line at the top of the Generate panel. * Provider and model, at the top of Generate.
* *
* It was a whole panel — a card per provider, each with its own key field and three buttons, filling * Two dropdowns and nothing else. The provider list is the curated file — one entry per vendor,
* a quarter of the screen for something set once and then ignored. What it actually has to answer is * whether or not you have a key — and the models under it are that provider's entry in the same file.
* "which one is this generation going to use", and that is a dropdown.
* *
* Adding one opens a dialog rather than expanding the menu: it is three fields, one of them a secret, * What this replaced was a menu of "connections": a row per adapter+model+key, which made choosing a
* and a menu that turns into a form cannot be dismissed by clicking away without losing what was typed. * model an act of configuration, put a key icon and a delete button inside a menu item, and listed
* five accounts nobody had created. A provider is a vendor; the key is a property of it; the model is
* a choice you make each time.
*/ */
export const ProvidersBar = ({ manager }: { manager: ProvidersManager }) => { export const ProvidersBar = ({
const [dialog, setDialog] = useState<null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus }>(null);
const active =
manager.providers.find((row) => row.is_default && row.configured && row.enabled) ??
manager.providers.find((row) => row.configured && row.enabled) ??
manager.providers[0];
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="min-w-0 flex-1 justify-start">
<span className="truncate">{active ? label(active) : 'No provider'}</span>
{active && !active.configured && <span className="ml-auto shrink-0 text-xs text-destructive">no key</span>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{manager.providers.length === 0 && (
<DropdownMenuItem disabled>No providers configured</DropdownMenuItem>
)}
{manager.providers.map((provider) => (
<DropdownMenuItem
key={provider.id}
// Choosing one makes it the default, which is what "use this" means to the server: a
// request names a model, and the default provider is the row that answers for it.
onSelect={() => void manager.makeDefault(provider.id)}
className="gap-2"
>
{provider.id === active?.id ? <Check className="size-3.5 shrink-0" /> : <span className="w-3.5" />}
<span className="min-w-0 flex-1 truncate">{label(provider)}</span>
{!provider.configured && (
<button
type="button"
title="Set the API key"
className="shrink-0 rounded p-1 text-destructive hover:bg-destructive/10"
onClick={(ev) => {
// The row's own onSelect would close the menu and make this the default instead.
ev.preventDefault();
ev.stopPropagation();
setDialog({ mode: 'key', provider });
}}
>
<KeyRound className="size-3.5" />
</button>
)}
<button
type="button"
title="Delete this provider"
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
void manager.remove(provider.id);
}}
>
<Trash2 className="size-3.5" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setDialog({ mode: 'add' })} className="gap-2">
<Plus className="size-3.5" />
Add provider
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{manager.error && <span className="shrink-0 text-xs text-destructive">{manager.error}</span>}
<ProviderDialog state={dialog} manager={manager} onClose={() => setDialog(null)} />
</div>
);
};
const label = (provider: ProviderStatus) => `${provider.adapter} · ${provider.model}`;
/** "never refreshed" is a fact worth showing: it means these models are exactly what the plugin shipped. */
const freshness = (updatedAt?: string | null): string => {
if (!updatedAt) return 'as shipped';
const days = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 86_400_000);
if (days <= 0) return 'refreshed today';
return `${days}d old`;
};
type DialogState = null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus };
/** Add a provider, or set the key on one that has none. Same dialog, since the key field is shared. */
const ProviderDialog = ({
state,
manager, manager,
onClose, model,
onModelChange,
}: { }: {
state: DialogState;
manager: ProvidersManager; manager: ProvidersManager;
onClose: () => void; model: string;
onModelChange: (model: string) => void;
}) => { }) => {
const [adapter, setAdapter] = useState(''); const [selected, setSelected] = useState('');
const [model, setModel] = useState(''); const [keyFor, setKeyFor] = useState<ProviderStatus | null>(null);
const [apiKey, setApiKey] = useState('');
// Seeded when the dialog opens, not on every render: the adapter list arrives asynchronously, and // Default to a provider we can actually use, once the list arrives.
// resetting on each render would fight the user's own choice as soon as it did.
useEffect(() => { useEffect(() => {
if (state?.mode !== 'add') return; if (selected && manager.byName(selected)) return;
const first = manager.adapters[0]; const next = manager.firstUsable() ?? manager.providers[0];
setAdapter(first?.name ?? ''); if (next) setSelected(next.name);
setModel(first?.default_model ?? ''); // eslint-disable-next-line react-hooks/exhaustive-deps
setApiKey(''); }, [manager.providers]);
}, [state, manager.adapters]);
const provider = manager.byName(selected);
const models = provider?.model_details ?? [];
// Keep the model honest: when the provider changes, or a refresh retires what was chosen, fall back
// to that provider's default rather than leaving a model the vendor no longer lists selected.
useEffect(() => { useEffect(() => {
if (state?.mode === 'key') setApiKey(''); if (!provider) return;
}, [state]); if (model && models.some((entry) => entry.id === model)) return;
onModelChange(provider.default_model ?? models[0]?.id ?? '');
const submit = async () => { // eslint-disable-next-line react-hooks/exhaustive-deps
if (!state) return; }, [provider?.name, provider?.model_details]);
const ok =
state.mode === 'add'
? await manager.add(adapter, model, apiKey)
: apiKey.trim()
? await manager.setKey(state.provider.id, apiKey)
: false;
if (ok) onClose();
};
return ( return (
<Dialog open={!!state} onOpenChange={(next) => !next && onClose()}> <div className="grid gap-3">
<DialogContent className="sm:max-w-md"> <div className="grid grid-cols-2 gap-3">
<DialogHeader>
<DialogTitle>{state?.mode === 'key' ? 'Set API key' : 'Add a provider'}</DialogTitle>
<DialogDescription>
{state?.mode === 'key'
? `${label(state.provider)} — the key is stored encrypted and never sent back.`
: 'An adapter and a model. The same adapter can back several providers — two keys, two accounts.'}
</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
>
{state?.mode === 'add' && (
<>
{/*
The provider list is the CURATED FILE — the providers Officer decided are worth
offering — and the models under it are that file's current answer for the one chosen.
Neither is read from the vendor here: the file is the offer, and refreshing it is a
deliberate act with its own button rather than something that happens on a timer.
*/}
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-xs text-muted-foreground">Provider</span> <span className="text-xs text-muted-foreground">Provider</span>
<Select <Select value={selected} onValueChange={setSelected}>
value={adapter}
onValueChange={(value) => {
setAdapter(value);
setModel(manager.adapterFor(value)?.default_model ?? '');
}}
>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Choose" /> <SelectValue placeholder="Choose" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{manager.adapters.map((entry) => ( {manager.providers.map((entry) => (
<SelectItem key={entry.name} value={entry.name}> <SelectItem key={entry.name} value={entry.name}>
<span className="flex w-full items-center gap-2">
{entry.label} {entry.label}
{/* Said here rather than discovered at Generate: a vendor with no key is listed
precisely so you can add one, so the state has to be visible in the list. */}
{!entry.configured && <span className="text-xs text-destructive">no key</span>}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -215,26 +85,14 @@ const ProviderDialog = ({
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="flex items-center gap-2 text-xs text-muted-foreground"> <span className="flex items-center gap-2 text-xs text-muted-foreground">
Model Model
<span className="ml-auto flex items-center gap-1"> <span className="ml-auto text-muted-foreground/60">{freshness(provider?.updated_at)}</span>
<span className="text-muted-foreground/60">{freshness(manager.adapterFor(adapter)?.updated_at)}</span>
{/* This provider's models, from the vendor, now. Slow — it is a live fetch. */}
<button
type="button"
title={`Refresh ${manager.adapterFor(adapter)?.label ?? 'this provider'}'s models and prices`}
disabled={manager.refreshing || !manager.adapterFor(adapter)?.refreshable}
onClick={() => void manager.refreshCatalogue(adapter)}
className="rounded p-1 hover:bg-accent disabled:opacity-40"
>
<RefreshCw className={manager.refreshing ? 'size-3 animate-spin' : 'size-3'} />
</button>
</span> </span>
</span> <Select value={model} onValueChange={onModelChange} disabled={models.length === 0}>
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Choose" /> <SelectValue placeholder="Choose" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(manager.adapterFor(adapter)?.model_details ?? []).map((entry) => ( {models.map((entry) => (
<SelectItem key={entry.id} value={entry.id}> <SelectItem key={entry.id} value={entry.id}>
{entry.label ?? entry.id} {entry.label ?? entry.id}
</SelectItem> </SelectItem>
@@ -242,46 +100,124 @@ const ProviderDialog = ({
</SelectContent> </SelectContent>
</Select> </Select>
</Label> </Label>
</div>
<button <div className="flex flex-wrap items-center gap-2">
type="button" <Button size="sm" variant="outline" onClick={() => provider && setKeyFor(provider)} disabled={!provider}>
<KeyRound className="mr-1 size-3.5" />
{provider?.configured ? 'Change key' : 'Add key'}
</Button>
<Button
size="sm"
variant="ghost"
disabled={manager.refreshing || !provider?.refreshable}
onClick={() => void manager.refreshCatalogue(selected)}
title={`Re-read ${provider?.label ?? 'this provider'}'s models from the vendor`}
>
<RefreshCw className={manager.refreshing ? 'mr-1 size-3.5 animate-spin' : 'mr-1 size-3.5'} />
Update models
</Button>
<Button
size="sm"
variant="ghost"
disabled={manager.refreshing} disabled={manager.refreshing}
onClick={() => void manager.refreshCatalogue()} onClick={() => void manager.refreshCatalogue()}
className="justify-self-start text-xs text-muted-foreground hover:text-foreground disabled:opacity-40" className="text-muted-foreground"
> >
{manager.refreshing ? 'Refreshing…' : 'Refresh all providers'} All providers
</button> </Button>
</>
)}
<Label className="grid gap-2"> {manager.error && <span className="text-xs text-destructive">{manager.error}</span>}
<span className="text-xs text-muted-foreground"> </div>
API key {state?.mode === 'add' && <span className="text-muted-foreground/60">— optional</span>}
</span> <KeyDialog provider={keyFor} manager={manager} onClose={() => setKeyFor(null)} />
</div>
);
};
/** "as shipped" is a fact worth showing: these models are exactly what the plugin was released with. */
const freshness = (updatedAt?: string | null): string => {
if (!updatedAt) return 'as shipped';
const days = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 86_400_000);
return days <= 0 ? 'updated today' : `${days}d old`;
};
const KeyDialog = ({
provider,
manager,
onClose,
}: {
provider: ProviderStatus | null;
manager: ProvidersManager;
onClose: () => void;
}) => {
const [apiKey, setApiKey] = useState('');
useEffect(() => {
setApiKey('');
}, [provider]);
const fromEnvironment = provider?.key_source === 'environment';
const save = async () => {
if (!provider || !apiKey.trim()) return;
if (await manager.setKey(provider.name, apiKey)) onClose();
};
return (
<Dialog open={!!provider} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{provider?.label} API key</DialogTitle>
<DialogDescription>
{fromEnvironment
? `Currently taken from ${provider?.env_var} in the environment. A key saved here takes precedence.`
: 'Stored in Officer’s secret store, encrypted at rest and never sent back — so this field always starts empty.'}
</DialogDescription>
</DialogHeader>
{/* A form, so Enter saves. Pasting a key and pressing Enter is the strongest habit there is in
a password field, and outside a form it silently does nothing. */}
<form
className="grid gap-4"
onSubmit={(ev) => {
ev.preventDefault();
void save();
}}
>
<Input <Input
autoFocus
type="password" type="password"
autoComplete="off" autoComplete="off"
placeholder="sk-…" placeholder="sk-…"
value={apiKey} value={apiKey}
onChange={(ev) => setApiKey(ev.target.value)} onChange={(ev) => setApiKey(ev.target.value)}
/> />
<span className="text-xs text-muted-foreground">
{state?.mode === 'add'
? 'Leave empty if the key is already in the environment for this adapter.'
: 'Stored encrypted, so this field always starts empty.'}
</span>
</Label>
<DialogFooter> <DialogFooter className="gap-2 sm:justify-between">
{provider?.configured && !fromEnvironment ? (
<Button
type="button"
variant="ghost"
className="text-destructive"
disabled={manager.busy}
onClick={() => void manager.clearKey(provider.name).then((ok) => ok && onClose())}
>
Forget key
</Button>
) : (
<span />
)}
<span className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose}> <Button type="button" variant="ghost" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" disabled={manager.busy || !apiKey.trim()}>
type="submit" Save
disabled={manager.busy || (state?.mode === 'add' ? !adapter || !model : !apiKey.trim())}
>
{state?.mode === 'key' ? 'Save key' : 'Add'}
</Button> </Button>
</span>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
+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; export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const;
/** /**
* The form. Providers are passed IN rather than fetched here — `useProviders` owns them, because the * The form.
* 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 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 client = useAtelierClient();
const [, bumpRefresh] = useAtelierRefresh(); const [, bumpRefresh] = useAtelierRefresh();
const [model, setModel] = useState('');
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [n, setN] = useState(1); const [n, setN] = useState(1);
const [size, setSize] = useState<string>('auto'); const [size, setSize] = useState<string>('auto');
@@ -27,22 +28,9 @@ export const useGenerateManager = (providers: ProviderStatus[]) => {
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null); 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 // A provider we hold a key for and have code for. Both are required and neither implies the other.
// the provider behind it has gone away. const usableProviders = providers.filter((provider) => provider.configured && provider.usable);
useEffect(() => { const ready = Boolean(prompt.trim()) && Boolean(model) && usableProviders.length > 0 && !running;
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;
async function generate() { async function generate() {
if (!ready) return; if (!ready) return;
@@ -82,9 +70,6 @@ export const useGenerateManager = (providers: ProviderStatus[]) => {
return { return {
providers, providers,
usableProviders, usableProviders,
models,
model,
setModel,
prompt, prompt,
setPrompt, setPrompt,
n, n,
+22 -44
View File
@@ -1,29 +1,26 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useAtelierClient } from '../lib/api'; 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 * One list, one call. There used to be two — the configured rows and the adapter catalogue — and they
* not exist twice: the dropdown at the top of the panel, and the form below it whose model choices are * were different shapes answering overlapping questions, which is most of why the old screen was
* derived from whichever providers actually have a key. Adding one used to mean reloading the page * confusing. `/providers` now returns the curated list with each entry's key status and its models
* before the form noticed. * attached, so "what can I use" and "what have I set up" are one answer.
*/ */
export const useProviders = () => { export const useProviders = () => {
const client = useAtelierClient(); const client = useAtelierClient();
const [providers, setProviders] = useState<ProviderStatus[]>([]); const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [adapters, setAdapters] = useState<Adapter[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const [rows, catalogue] = await Promise.all([client.listProviders(), client.listAdapters()]); setProviders(await client.listProviders());
setProviders(rows);
setAdapters(catalogue);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(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 * Its own busy flag, separate from `busy`: this is a live fetch to a third party and takes seconds,
* is an encrypted secret that is never sent back. A provider added without a key is legitimate — the * where setting a key is a local write. One flag would grey out the screen for a network round trip.
* key may live in the environment — so the second call is skipped rather than required.
*/ */
const add = async (adapter: string, model: string, apiKey?: string) => const refreshCatalogue = async (name?: 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) => {
setRefreshing(true); setRefreshing(true);
try { try {
await client.refreshCatalogue(provider); const result = await client.refreshCatalogue(name);
setAdapters(await client.listAdapters()); setProviders(result.providers);
setError(null); setError(null);
return true; return result;
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
return false; return null;
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
@@ -90,20 +71,17 @@ export const useProviders = () => {
return { return {
providers, providers,
adapters,
loading, loading,
busy, busy,
refreshing, refreshing,
refreshCatalogue,
error, error,
refresh, refresh,
add, refreshCatalogue,
setKey: (id: string, apiKey: string) => guard(() => client.updateProvider(id, { apiKey: apiKey.trim() })), setKey: (name: string, apiKey: string) => guard(() => client.setProviderKey(name, apiKey)),
/** The one a generation will use when the request names only a model. */ clearKey: (name: string) => guard(() => client.clearProviderKey(name)),
makeDefault: (id: string) => guard(() => client.updateProvider(id, { isDefault: true, enabled: true })), byName: (name: string) => providers.find((provider) => provider.name === name),
remove: (id: string) => guard(() => client.deleteProvider(id)), /** The one a generation would use if you pressed Generate now: your choice, else the first with a key. */
modelsFor: (adapter: string) => adapters.find((entry) => entry.name === adapter)?.models ?? [], firstUsable: () => providers.find((provider) => provider.configured && provider.usable),
adapterFor: (adapter: string) => adapters.find((entry) => entry.name === adapter),
}; };
}; };
+25 -31
View File
@@ -7,19 +7,27 @@ import { getHeaders, useClient } from 'hooks/useClient';
const ATELIER_API = '/api/atelier'; const ATELIER_API = '/api/atelier';
/** A configured connection: adapter + model + key. Several may share one adapter. */ /**
* A provider: a vendor Officer curates, and whether we hold a key for it.
*
* Every provider in the catalogue is listed, set up or not — a vendor you have no key for is exactly
* what you need to see in order to add one. There is no "configured provider" as a separate thing;
* `configured` is a fact about the key, and the model is chosen per generation.
*/
export type ProviderStatus = { export type ProviderStatus = {
id: string;
name: string; name: string;
adapter: string; label: string;
model: string;
enabled: boolean;
is_default: boolean;
configured: boolean; configured: boolean;
key_source: 'database' | 'environment' | null; key_source: 'store' | 'environment' | null;
env_var: string; env_var: string;
created_at: string; /** False when the catalogue lists a vendor this build has no adapter for. */
updated_at: string; usable: boolean;
supports_edit: boolean;
refreshable: boolean;
default_model: string | null;
models: string[];
model_details: CatalogueModel[];
updated_at: string | null;
}; };
/** One model in the catalogue — what it makes, what it accepts, what it costs. */ /** One model in the catalogue — what it makes, what it accepts, what it costs. */
@@ -36,26 +44,6 @@ export type CatalogueModel = {
source?: 'shipped' | 'api' | 'scraped'; source?: 'shipped' | 'api' | 'scraped';
}; };
/**
* What a new connection can be built on: a catalogue provider that has an adapter behind it.
*
* `models` is the flat list of ids; `model_details` carries the per-model facts, which is what lets a
* form offer dall-e-2's three sizes rather than gpt-image-1's four. The catalogue is refreshed daily
* in the sidecar — `updated_at` says when, and is null while only the shipped file has been read.
*/
export type Adapter = {
name: string;
label: string;
models: string[];
model_details: CatalogueModel[];
default_model: string;
env_var: string;
supports_edit: boolean;
updated_at: string | null;
/** Whether the sidecar has a refresher for this provider — i.e. whether the refresh button does anything. */
refreshable: boolean;
};
/** Snake case, matching the server — one spelling per field across every endpoint. */ /** Snake case, matching the server — one spelling per field across every endpoint. */
export type GenerationRow = { export type GenerationRow = {
id: string; id: string;
@@ -128,7 +116,13 @@ export function useAtelierClient() {
...client, ...client,
listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').then((r) => r.data), listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').then((r) => r.data),
listAdapters: () => client.get<{ data: Adapter[] }>('providers/adapters').then((r) => r.data),
/** Save a provider's key. Goes to the platform's secret store; nothing ever reads it back. */
setProviderKey: (name: string, apiKey: string) =>
client.put<{ data: ProviderStatus[] }>(`providers/${name}/key`, { apiKey }).then((r) => r.data),
/** Forget a stored key. Refused for a key that came from the environment — that one is the install's. */
clearProviderKey: (name: string) =>
client.delete<{ data: ProviderStatus[] }>(`providers/${name}/key`).then((r) => r.data),
/** /**
* Re-read one provider's models and prices from the vendor, or every provider when omitted. * Re-read one provider's models and prices from the vendor, or every provider when omitted.
@@ -138,7 +132,7 @@ export function useAtelierClient() {
*/ */
refreshCatalogue: (provider?: string) => refreshCatalogue: (provider?: string) =>
client client
.post<{ data: { updated_at: string | null; models: number } }>( .post<{ data: { updated_at: string | null; providers: ProviderStatus[]; changed: boolean } }>(
`providers/catalogue/refresh${provider ? `?provider=${encodeURIComponent(provider)}` : ''}`, `providers/catalogue/refresh${provider ? `?provider=${encodeURIComponent(provider)}` : ''}`,
{}, {},
) )