The five connections in the database were never configured by anyone — a migration minted one per model from the adapter's own list, so the table held five decisions nobody made. What that list actually is, is a CATALOGUE: what you can choose from, which is not the same kind of thing as what you chose. So it is a file. `sidecar/catalogue/shipped.ts` is the curated baseline, in git, reviewed; `$OFFICER_ROOT/data/atelier/catalogue.json` is what a refresh writes, and it wins where it has something to say. Data dir, not the plugin dir — a plugin directory is a clone an update replaces, which is the rule env.ts already states for images. Refresh runs in the sidecar, daily, and only on boot when the file is actually stale — this process has restarted twenty times in an afternoon and should not hit OpenAI twenty times for it. Two sources, unequal, and the code says so. `GET /v1/models` is authoritative and thin: ids and nothing else, so a model matching /^(gpt-image|dall-e)/ appears the day it ships without a release. Prices come off an HTML page because there is no pricing endpoint for any plan, and that parser is written to give up rather than guess — a guess gets written to disk and then believed. When it gives up it says so in the log; the durable replacement is handing the page to a model for structured output, which Officer can already do locally. Nothing a refresh cannot establish overwrites a curated value: fields merge one by one, not objects wholesale, so an id-only listing cannot wipe the sizes and prices that were written by hand. Retiring is decided by the merge, which can tell a complete listing from a request that failed halfway — and it marks `deprecated` rather than deleting, because the gallery labels 88 historical rows from this list. Per-model sizes, qualities and transparency now travel with each entry, which is what will let the form stop offering dall-e-2 a transparent background it cannot do. The catalogue is advisory throughout: validate.ts still refuses to enforce per-model rules, exactly as before.
154 lines
6.4 KiB
TypeScript
154 lines
6.4 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 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',
|
|
updated_at: catalogue.updatedAt ?? null,
|
|
},
|
|
];
|
|
});
|
|
};
|