The dropdown was listing "connections" — a row per adapter + model + key — which made choosing a model an act of configuration, put a key icon and a bin inside a menu item, and showed five accounts nobody had created. A provider is now a vendor Officer curates. Every one is listed, set up or not, because a vendor you have no key for is exactly what you need to see in order to add one. The model is chosen per generation, from that provider's entry in the catalogue, beside it. With the model off the row, all that remained of a "provider" was the key for OpenAI — and Officer already stores keys: one active value per purpose, 0600 inside a 0700 directory, outside the backup tarball, created on first use so nothing needs provisioning. So `atelier_providers` is dropped, along with `generations.provider_id` that pointed into it. `provider` and `model` stay as text on each generation, which were always the historical fact rather than a pointer to configuration. `getSecret`, never `getKey`: this is a foreign credential, and getKey mints a random value for a purpose it cannot find — which for someone else's API key means authenticating with something invented. Resolution follows: a request names a model, and the catalogue says whose it is, rather than a database being asked which stored row served it. A model nobody in the catalogue claims is still accepted — the catalogue is advisory, and a model released today should work before we ship a file that mentions it. Two routes replace five: PUT and DELETE on /providers/:name/key. Deleting a key that came from the environment is refused rather than faked — it belongs to the install, not to this screen.
153 lines
6.8 KiB
TypeScript
153 lines
6.8 KiB
TypeScript
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-<provider>-key`, or the
|
|
* environment has `<PROVIDER>_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.
|
|
*/
|
|
|
|
/** `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());
|
|
}
|
|
|
|
/**
|
|
* 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<ResolvedProvider | { error: string; status: 400 | 404 | 503 }> {
|
|
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) => keyFor(entry.name))?.name;
|
|
} else {
|
|
name = catalogue.providers.find((entry) => keyFor(entry.name))?.name;
|
|
}
|
|
|
|
if (!name) return { error: 'No provider has an API key yet. Add one in Atelier.', status: 503 };
|
|
|
|
const adapter = adapterByName(name);
|
|
if (!adapter) return { error: `Provider "${name}" has no adapter in this build.`, status: 400 };
|
|
|
|
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);
|
|
|
|
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,
|
|
};
|
|
});
|
|
}
|