Files
atelier/sidecar/providers/credentials.ts
T
pastilhas 3c59bf4d0c feat(catalogue): refresh is a button, not a schedule
The daily job is gone. A refresh only matters at one moment — you are picking a
provider and want to know its models are current — and at that moment a button
answers the question, where a nightly pass can only have already answered it,
invisibly, or not. It also bought a standing obligation: something hitting a
vendor from every install forever whether anyone opens Atelier or not.

So the add dialog is now the curated file made visible. Provider comes from the
catalogue, models come from that provider's entry in it, and two buttons re-read
it from the vendor: one for the provider in front of you, one for all of them.
`POST /providers/catalogue/refresh?provider=` is the only trigger there is.

The dialog also says how old the answer is — "as shipped" until a refresh has
ever run, which is worth showing, because it means the models listed are exactly
what the plugin was released with.

A provider with no refresher answers 400 rather than a silent success: from a
button, "nothing happened" and "nothing could happen" look identical.
2026-08-20 22:21:15 +01:00

157 lines
6.6 KiB
TypeScript

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<string | undefined> {
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 `<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 {
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<ResolvedProvider | { error: string; status: 400 | 404 | 503 }> {
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),
},
];
});
};