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 { 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. * * 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. */ 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); } /** * The adapter's bootstrap credential — the secret store first, `.env` second. * * `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. */ 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)]; } /** `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 = { provider: Provider; adapter: ImageAdapter; apiKey: string }; /** Everything needed to run a generation, or a reason it cannot run. */ export async function resolveForRequest(options: { providerRef?: string; model?: string; }): Promise { let provider: Provider | undefined; 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); 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 }; } } const adapter = adapterByName(provider.adapter); if (!adapter) return { error: `Provider "${provider.name}" uses unknown adapter "${provider.adapter}".`, status: 400 }; const apiKey = await keyForProvider(provider); if (!apiKey) return { error: `Provider "${provider.name}" has no API key.`, status: 503 }; return { provider, adapter, apiKey }; } /** 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, })), ); } /** * What a new connection can be built on: the CATALOGUE, filtered to adapters that exist as code. * * 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. */ export const adapterCatalogue = async () => { const catalogue = await getCatalogue(); return catalogue.providers.flatMap((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'); 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), }, ]; }); };