diff --git a/db/schema.ts b/db/schema.ts index 32cff99..72059a4 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -5,6 +5,10 @@ // themselves live beside the code that queries them, in `sidecar/db/`, and are re-exported here rather // 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 // 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 @@ -12,4 +16,3 @@ // from every other plugin is a plugin nobody can reason about. export * from '../sidecar/db/collections/schema'; export * from '../sidecar/db/generations/schema'; -export * from '../sidecar/db/providers/schema'; diff --git a/sidecar/api/images.ts b/sidecar/api/images.ts index 61ea2c8..9b9c7fa 100644 --- a/sidecar/api/images.ts +++ b/sidecar/api/images.ts @@ -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 { + 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, diff --git a/sidecar/api/providers.ts b/sidecar/api/providers.ts index e2bbff2..4e60826 100644 --- a/sidecar/api/providers.ts +++ b/sidecar/api/providers.ts @@ -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 | 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 | null; - if (!body) return ctx.json(openaiError('Request body must be a JSON object.', 'invalid_request_error'), 400); - - const values: Record = {}; - - // 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() }); -}); diff --git a/sidecar/catalogue/openai-refresh.ts b/sidecar/catalogue/openai-refresh.ts index 98722f2..ff51ddb 100644 --- a/sidecar/catalogue/openai-refresh.ts +++ b/sidecar/catalogue/openai-refresh.ts @@ -1,5 +1,4 @@ -import { listProviders } from '../db'; -import { keyForProvider } from '../providers/credentials'; +import { keyFor } from '../providers/credentials'; import { projectEnv } from '../env'; import type { Catalogue, CatalogueModel } 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; -/** A key from any connection, or the bootstrap environment variable. Refresh is read-only, so any will do. */ -async function anyKey(): Promise { - 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'); -} +/** OpenAI's key: the secret store, then the environment. `keyFor` already looks in both, in that order. */ +const anyKey = (): string | undefined => keyFor('openai'); async function fetchModelIds(apiKey: string): Promise { try { @@ -118,7 +110,7 @@ async function fetchPricing(modelIds: string[]): Promise> * can tell a complete listing from a missing one. */ export async function refreshOpenAI(): Promise<{ catalogue: Catalogue | null; listedIds: string[] | null }> { - const apiKey = await anyKey(); + const apiKey = anyKey(); if (!apiKey) { console.log('[atelier:catalogue] no OpenAI key available — nothing to refresh, shipped catalogue stands'); return { catalogue: null, listedIds: null }; diff --git a/sidecar/db/generations/schema.ts b/sidecar/db/generations/schema.ts index ca27867..4e51d76 100644 --- a/sidecar/db/generations/schema.ts +++ b/sidecar/db/generations/schema.ts @@ -55,18 +55,16 @@ export const generations = pgTable('atelier_generations', status: text('status').notNull().default('queued').$type(), /** - * 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 - * another model, or deleted — and none of that may change what an existing image was made with. - * The row is a pointer to configuration; these two are the historical fact. + * There was a `provider_id` beside these, pointing at a configured provider row. Both the column + * and the table it referenced are gone: a provider is a curated vendor plus a key in the secret + * 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(), 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(), /** Whatever else the caller asked for — size, quality, background. Provider-shaped, so opaque here. */ diff --git a/sidecar/db/index.ts b/sidecar/db/index.ts index 5381313..c0ade69 100644 --- a/sidecar/db/index.ts +++ b/sidecar/db/index.ts @@ -7,7 +7,6 @@ // stay in the `atelier` database, untouched. See PLUGIN.md. export * from './generations'; export * from './collections'; -export * from './providers'; // The connection itself, so callers do not reach past the package for it. export { db, waitForDatabase } from './db'; diff --git a/sidecar/db/providers/index.ts b/sidecar/db/providers/index.ts deleted file mode 100644 index eb27163..0000000 --- a/sidecar/db/providers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './schema'; -export * from './queries'; diff --git a/sidecar/db/providers/queries.ts b/sidecar/db/providers/queries.ts deleted file mode 100644 index eb9f0fe..0000000 --- a/sidecar/db/providers/queries.ts +++ /dev/null @@ -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 { - return db.select().from(providers).orderBy(asc(providers.name)); -} - -export async function getProvider(id: string): Promise { - const [row] = await db.select().from(providers).where(eq(providers.id, id)).limit(1); - return row; -} - -export async function getProviderByName(name: string): Promise { - 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 { - 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 { - 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 { - 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): Promise { - 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 { - 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 { - await db - .update(providers) - .set({ isDefault: false }) - .where(and(eq(providers.isDefault, true), ne(providers.id, keepId))); -} diff --git a/sidecar/db/providers/schema.ts b/sidecar/db/providers/schema.ts deleted file mode 100644 index 98c94b5..0000000 --- a/sidecar/db/providers/schema.ts +++ /dev/null @@ -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), - ], -); diff --git a/sidecar/db/schema.ts b/sidecar/db/schema.ts index 18cf027..9459b45 100644 --- a/sidecar/db/schema.ts +++ b/sidecar/db/schema.ts @@ -1,10 +1,10 @@ -// What drizzle-kit creates, and the runtime's view of the tables — in the SEPARATE `atelier` database, -// not officer_db. See PLUGIN.md for why this plugin keeps its own database rather than folding into -// officer_db's _-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. +// What drizzle-kit creates, and the runtime's view of the tables. // -// `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 './collections/schema'; -export * from './providers/schema'; diff --git a/sidecar/generations/queue.ts b/sidecar/generations/queue.ts index 612c498..90021db 100644 --- a/sidecar/generations/queue.ts +++ b/sidecar/generations/queue.ts @@ -1,9 +1,9 @@ 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 { IMAGES_DIR, INPUTS_DIR, MAX_CONCURRENT_GENERATIONS } from '../env'; import { adapterByName } from '../providers'; -import { keyForProvider } from '../providers/credentials'; +import { keyFor } from '../providers/credentials'; import { inspectImage } from './inspect'; import type { ImageEditRequest, ImageRequest, UploadedImage } from '../providers'; @@ -59,13 +59,11 @@ async function run(row: Generation): Promise { const adapter = adapterByName(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: - // a provider renamed or repointed after this was queued must not change what gets generated. - const provider = row.providerId ? await getProvider(row.providerId) : undefined; - const apiKey = provider - ? await keyForProvider(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}"`); + // The adapter and model come from the ROW's own columns, and the key is read at RUN time: a key + // rotated between queueing and running should be the new one, and a row queued before a key existed + // at all runs the moment there is one. + const apiKey = keyFor(row.provider); + if (!apiKey) throw new Error(`No API key for provider "${row.provider}"`); const result = row.operation === 'edit' diff --git a/sidecar/providers/credentials.ts b/sidecar/providers/credentials.ts index f3aa9af..da17c34 100644 --- a/sidecar/providers/credentials.ts +++ b/sidecar/providers/credentials.ts @@ -1,156 +1,152 @@ -import { getSecret } from 'officerdb/secret-store'; -import { getProvider, getProviderByName, listProviders, resolveProviderForModel } from '../db'; -import type { Provider } from '../db'; -import { decrypt } from '../crypto'; -import { ADAPTERS, adapterByName } from './index'; +import { getSecret, hasKey, putSecret } from 'officerdb/secret-store'; +import { adapterByName } from './index'; import { getCatalogue } from '../catalogue/store'; import { refreshableProviders } from '../catalogue/refresh'; 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 - * 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 - * right: it is the same account. + * ── There is no provider table any more ── + * + * There used to be one: a row per adapter+model+key, called a "provider", and the model was part of the + * 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--key`, or the + * environment has `_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 { - if (provider.apiKeyEncrypted) { - try { - return decrypt(provider.apiKeyEncrypted); - } catch (error) { - // A key that will not decrypt is broken, not absent. Falling through to the bootstrap credential - // would hide that the stored credential is unusable. - console.error(`[atelier:providers] stored key for "${provider.name}" failed to decrypt:`, error); - return undefined; - } - } - return bootstrapKeyFor(provider.adapter); +/** `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 { + const stored = getSecret(secretPurposeFor(provider)); + if (stored) return stored; + } catch { + // Unreadable store — fall through to env, as every other secret-store caller in this codebase does. + } + return process.env[envVarFor(provider)]; +} + +/** 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 - * random value for an absent purpose — for a foreign credential that means silently authenticating - * with a fake. `putSecret`/`getSecret` landed 2026-08-19; `bun run secret:set atelier-openai-key` - * writes it. Falls back to `_API_KEY` in `.env` so an install that predates the secret store - * keeps working unchanged. - * - * Keyed per-adapter (`atelier--key`), matching `envVarFor`'s own per-adapter shape below — - * several providers on one adapter already share one fallback, and this preserves that. + * Only the stored one can be forgotten: a key in `.env` belongs to the install, not to this screen, and + * a button that appeared to delete it and did not would be worse than no button. */ -function bootstrapKeyFor(adapter: string): string | undefined { - try { - 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)]; +export function clearKey(provider: string): void { + putSecret(secretPurposeFor(provider), ''); } -/** `openai` -> `OPENAI_API_KEY`. Per adapter, not per provider: it is one account. */ -export const envVarFor = (adapter: string) => `${adapter.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`; +export type ResolvedProvider = { providerName: string; adapter: ImageAdapter; apiKey: string }; -export type ResolvedProvider = { provider: Provider; adapter: ImageAdapter; apiKey: string }; - -/** Everything needed to run a generation, or a reason it cannot run. */ +/** + * Everything needed to run a generation, or the 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: { providerRef?: string; model?: string; }): Promise { - let provider: Provider | undefined; + const catalogue = await getCatalogue(); - if (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); + let name = options.providerRef; - if (!provider) return { error: `No provider named "${options.providerRef}".`, status: 404 }; - if (!provider.enabled) return { error: `Provider "${provider.name}" is disabled.`, status: 503 }; - } else { - provider = await resolveProviderForModel(options.model); - if (!provider) { - return { error: 'No provider is configured. Add one in Settings → Integrations → Atelier.', status: 503 }; + if (name) { + 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 { + name = catalogue.providers.find((entry) => keyFor(entry.name))?.name; } - const adapter = adapterByName(provider.adapter); - if (!adapter) - return { error: `Provider "${provider.name}" uses unknown adapter "${provider.adapter}".`, status: 400 }; + if (!name) return { error: 'No provider has an API key yet. Add one in Atelier.', status: 503 }; - const apiKey = await keyForProvider(provider); - if (!apiKey) return { error: `Provider "${provider.name}" has no API key.`, status: 503 }; + const adapter = adapterByName(name); + 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. */ -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, - })), - ); + return { providerName: name, adapter, apiKey }; } /** - * 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 — - * which meant adding a model was a code change and a release. It comes from the curated catalogue - * now, refreshed daily, and the adapter keeps the one thing only it knows: whether it can edit. - * - * 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. + * All of them, always — a vendor you have no key for is precisely what you need to see in order to add + * one. Never includes a key, in any form; not masked either, since a mask still leaks length and shape + * and the only questions are whether one is set and where it came from. */ -export const adapterCatalogue = async () => { +export async function providerStatuses() { const catalogue = await getCatalogue(); + const refreshable = refreshableProviders(); - return catalogue.providers.flatMap((provider) => { + return catalogue.providers.map((provider) => { 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 source = keySource(provider.name); - return [ - { - name: provider.name, - label: provider.label, - 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, - 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, - refreshable: refreshableProviders().includes(provider.name), - }, - ]; + return { + name: provider.name, + 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), + model_details: models, + updated_at: catalogue.updatedAt ?? null, + }; }); -}; +} diff --git a/web/Generate/Generate.tsx b/web/Generate/Generate.tsx index 437e861..a4047d3 100644 --- a/web/Generate/Generate.tsx +++ b/web/Generate/Generate.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -11,17 +12,17 @@ import { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerate export const Generate = () => { 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 (
- {/* 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. */} + - {providers.providers.length > 0 && manager.usableProviders.length === 0 && ( -

- No provider is ready. Set an API key from the dropdown above. -

+ {!providers.loading && manager.usableProviders.length === 0 && ( +

No provider has a key yet — add one above.

)}
@@ -36,21 +37,6 @@ export const Generate = () => {
- - - - { - const [dialog, setDialog] = useState(null); +export const ProvidersBar = ({ + manager, + model, + onModelChange, +}: { + manager: ProvidersManager; + model: string; + onModelChange: (model: string) => void; +}) => { + const [selected, setSelected] = useState(''); + const [keyFor, setKeyFor] = useState(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]; + // Default to a provider we can actually use, once the list arrives. + useEffect(() => { + if (selected && manager.byName(selected)) return; + const next = manager.firstUsable() ?? manager.providers[0]; + if (next) setSelected(next.name); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manager.providers]); + + 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(() => { + if (!provider) return; + if (model && models.some((entry) => entry.id === model)) return; + onModelChange(provider.default_model ?? models[0]?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider?.name, provider?.model_details]); return ( -
- - - - +
+
+ - - {manager.providers.length === 0 && ( - No providers configured - )} + +
- {manager.providers.map((provider) => ( - void manager.makeDefault(provider.id)} - className="gap-2" - > - {provider.id === active?.id ? : } - {label(provider)} +
+ - {!provider.configured && ( - - )} + - - - ))} + - - setDialog({ mode: 'add' })} className="gap-2"> - - Add provider - - - + {manager.error && {manager.error}} +
- {manager.error && {manager.error}} - - setDialog(null)} /> + setKeyFor(null)} />
); }; -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. */ +/** "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); - if (days <= 0) return 'refreshed today'; - return `${days}d old`; + return days <= 0 ? 'updated today' : `${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, +const KeyDialog = ({ + provider, manager, onClose, }: { - state: DialogState; + provider: ProviderStatus | null; manager: ProvidersManager; onClose: () => void; }) => { - const [adapter, setAdapter] = useState(''); - const [model, setModel] = useState(''); const [apiKey, setApiKey] = useState(''); - // Seeded when the dialog opens, not on every render: the adapter list arrives asynchronously, and - // resetting on each render would fight the user's own choice as soon as it did. useEffect(() => { - if (state?.mode !== 'add') return; - const first = manager.adapters[0]; - setAdapter(first?.name ?? ''); - setModel(first?.default_model ?? ''); setApiKey(''); - }, [state, manager.adapters]); + }, [provider]); - useEffect(() => { - if (state?.mode === 'key') setApiKey(''); - }, [state]); + const fromEnvironment = provider?.key_source === 'environment'; - const submit = async () => { - if (!state) return; - const ok = - state.mode === 'add' - ? await manager.add(adapter, model, apiKey) - : apiKey.trim() - ? await manager.setKey(state.provider.id, apiKey) - : false; - if (ok) onClose(); + const save = async () => { + if (!provider || !apiKey.trim()) return; + if (await manager.setKey(provider.name, apiKey)) onClose(); }; return ( - !next && onClose()}> + !next && onClose()}> - {state?.mode === 'key' ? 'Set API key' : 'Add a provider'} + {provider?.label} API key - {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.'} + {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.'} + {/* 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. */}
{ ev.preventDefault(); - void submit(); + void save(); }} > - {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. - */} - + setApiKey(ev.target.value)} + /> - - - - - )} - - - - - -
diff --git a/web/Generate/useGenerateManager.ts b/web/Generate/useGenerateManager.ts index 3980694..f68f8c9 100644 --- a/web/Generate/useGenerateManager.ts +++ b/web/Generate/useGenerateManager.ts @@ -8,15 +8,16 @@ export const QUALITIES = ['auto', 'low', 'medium', 'high'] as const; export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const; /** - * The form. Providers are passed IN rather than fetched here — `useProviders` owns them, because the - * dropdown above this form and the model list inside it have to be the same list. They were two - * fetches, and adding a provider left the form showing the old models until the page was reloaded. + * The form. + * + * The MODEL is owned above, by the provider/model pair at the top of the panel — it is a choice about + * what to generate with, made in the same place the provider is chosen, and duplicating it here gave + * two answers to one question. Everything else on the form is this hook's. */ -export const useGenerateManager = (providers: ProviderStatus[]) => { +export const useGenerateManager = (providers: ProviderStatus[], model: string) => { const client = useAtelierClient(); const [, bumpRefresh] = useAtelierRefresh(); - const [model, setModel] = useState(''); const [prompt, setPrompt] = useState(''); const [n, setN] = useState(1); const [size, setSize] = useState('auto'); @@ -27,22 +28,9 @@ export const useGenerateManager = (providers: ProviderStatus[]) => { const [running, setRunning] = useState(false); const [error, setError] = useState(null); - // Follow the provider list: pick the default's model, and only overwrite a choice the user made if - // the provider behind it has gone away. - useEffect(() => { - const usable = providers.filter((row) => row.configured && row.enabled); - if (usable.length === 0) return; - if (model && usable.some((row) => row.model === model)) return; - setModel((usable.find((row) => row.is_default) ?? usable[0]!).model); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [providers]); - - // Derived in the hook, not in the component. - const usableProviders = providers.filter((provider) => provider.configured && provider.enabled); - // One model per configured provider, deduped: two providers on the same model is legitimate (two - // keys, two accounts) and the request picks between them by model alone. - const models = [...new Set(usableProviders.map((provider) => provider.model))]; - const ready = Boolean(prompt.trim()) && Boolean(model) && !running; + // A provider we hold a key for and have code for. Both are required and neither implies the other. + const usableProviders = providers.filter((provider) => provider.configured && provider.usable); + const ready = Boolean(prompt.trim()) && Boolean(model) && usableProviders.length > 0 && !running; async function generate() { if (!ready) return; @@ -82,9 +70,6 @@ export const useGenerateManager = (providers: ProviderStatus[]) => { return { providers, usableProviders, - models, - model, - setModel, prompt, setPrompt, n, diff --git a/web/Generate/useProviders.ts b/web/Generate/useProviders.ts index fdf06d1..cf3c01e 100644 --- a/web/Generate/useProviders.ts +++ b/web/Generate/useProviders.ts @@ -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([]); - const [adapters, setAdapters] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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), }; }; diff --git a/web/lib/api.ts b/web/lib/api.ts index 03b8270..678e02d 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -7,19 +7,27 @@ import { getHeaders, useClient } from 'hooks/useClient'; 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 = { - id: string; name: string; - adapter: string; - model: string; - enabled: boolean; - is_default: boolean; + label: string; configured: boolean; - key_source: 'database' | 'environment' | null; + key_source: 'store' | 'environment' | null; env_var: string; - created_at: string; - updated_at: string; + /** False when the catalogue lists a vendor this build has no adapter for. */ + 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. */ @@ -36,26 +44,6 @@ export type CatalogueModel = { 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. */ export type GenerationRow = { id: string; @@ -128,7 +116,13 @@ export function useAtelierClient() { ...client, 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. @@ -138,7 +132,7 @@ export function useAtelierClient() { */ refreshCatalogue: (provider?: string) => 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)}` : ''}`, {}, )