sd-server (stable-diffusion.cpp, Vulkan) serving Qwen-Image 2.1 on this machine. It already speaks OpenAI's image API, which is the vocabulary `ImageRequest` is written in, so there is no translation layer — only the parts that are true of a local GPU and not of a hosted one. ── keyless is a category, not an exception ── `credentials.ts` decided a provider was usable by asking whether a key existed, so a service that takes none read as unconfigured forever. The fix is an adapter declaring `keyless`, not a dummy secret: a dummy would record a credential that does not exist and make the secret store lie about what is configured, and that store is the one place meant to be the truth about them. A keyless adapter never reaches `keyFor` at all. That function's own comment says why it uses `getSecret` rather than `getKey` — so a missing FOREIGN credential is never minted — and a provider with no credential by design has no business in that path. whisper, OCR and TTS already run on this machine, so this will not be the last. ── measured, not assumed ── The integration doc said "tens of seconds, not two". The server's own log says 512x512 completes in ~185s and 1024x1024 samples at ~41.4s/step over 20 steps, about 14 minutes. And it serialises strictly: a second caller waits for the first to finish entirely, so a queued 512 can wait 17 minutes before a byte comes back. So: 512 is the default and the substitute for `auto`, 1024 is offered with the cost in its label, and `timeout: false` is a requirement rather than caution — every default anyone would reach for is far under 17 minutes. The caller's `signal` still aborts, because a user cancelling is not a client giving up. ── the model id moved, because only it could ── `GET /v1/models` returns `sd-cpp-local`, hardcoded in sd-server with no override. The catalogue now agrees with the server and carries "Qwen-Image 2.1 (local)" in the label, so requests name a model the server knows while the UI still says something meaningful. ── not shipped: edit() ── Same GPU, same queue. It doubles the ways generation can be blocked while the generate path is unproven in anger, and `ImageAdapter` makes absence the way to say so — the route answers 400 instead of the queue finding out late. Verified: /v1/models answers, provider statuses report qwen-local as configured/keyless/usable, and the shipped entry survives the merge with the stored catalogue.json. NOT verified end to end — a generation is occupying the GPU and I left it alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
177 lines
8.2 KiB
TypeScript
177 lines
8.2 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.
|
|
*/
|
|
|
|
/**
|
|
* 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<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) => 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,
|
|
};
|
|
});
|
|
}
|