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'; /** * Which vendors Atelier can generate with, and whether we hold a key for each. * * ── 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. */ /** * Does this provider authenticate with anything at all? * * Asked of the ADAPTER, because it is a fact about the code rather than about configuration: a local * service of ours takes no key and never will, so there is nothing a user could set or forget. Every * "is it configured?" test below has to consult this FIRST — otherwise a keyless provider reads as * unconfigured forever and can never be selected. */ export const isKeyless = (provider: string): boolean => adapterByName(provider)?.keyless === true; /** Usable means: no key needed, or a key we hold. The single test the resolver and the UI both want. */ export const isUsable = (provider: string): boolean => isKeyless(provider) || Boolean(keyFor(provider)); /** `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' | 'keyless' | null { if (isKeyless(provider)) return 'keyless'; 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()); } /** * Forget a key. * * 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. */ export function clearKey(provider: string): void { putSecret(secretPurposeFor(provider), ''); } export type ResolvedProvider = { providerName: string; adapter: ImageAdapter; apiKey: string }; /** * 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 { const catalogue = await getCatalogue(); let name = options.providerRef; 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) => isUsable(entry.name))?.name; } else { name = catalogue.providers.find((entry) => isUsable(entry.name))?.name; } if (!name) return { error: 'No provider is usable yet. Add an API key in Atelier.', status: 503 }; const adapter = adapterByName(name); if (!adapter) return { error: `Provider "${name}" has no adapter in this build.`, status: 400 }; // A keyless adapter must not reach `keyFor` at all. The comment on it is the reason: it uses // `getSecret` rather than `getKey` precisely so a missing FOREIGN credential is never minted, and a // provider that has no credential by design has no business in that code path. if (adapter.keyless) return { providerName: name, adapter, apiKey: '' }; const apiKey = keyFor(name); if (!apiKey) return { error: `Provider "${name}" has no API key.`, status: 503 }; return { providerName: name, adapter, apiKey }; } /** * What the UI lists: every provider in the catalogue, one per vendor, whether or not it is set up. * * 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 async function providerStatuses() { const catalogue = await getCatalogue(); const refreshable = refreshableProviders(); return catalogue.providers.map((provider) => { const adapter = adapterByName(provider.name); const models = provider.models.filter((model) => model.status !== 'deprecated'); const source = keySource(provider.name); const keyless = isKeyless(provider.name); return { name: provider.name, label: provider.label, /** Keyless is configured by existing. There is nothing to add, so nothing can be missing. */ configured: keyless || Boolean(keyFor(provider.name)), keyless, key_source: source, /** Absent for a keyless provider: naming a variable nobody can usefully set would be a wrong hint. */ env_var: keyless ? null : 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, }; }); }