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.
This commit is contained in:
2026-08-20 22:21:15 +01:00
parent 0de38f0b13
commit 3c59bf4d0c
8 changed files with 143 additions and 61 deletions
+24 -7
View File
@@ -3,7 +3,7 @@ import { createRouter } from '../create-router';
import { encrypt } from '../crypto'; import { encrypt } from '../crypto';
import { adapterByName } from '../providers'; import { adapterByName } from '../providers';
import { adapterCatalogue, providerStatuses } from '../providers/credentials'; import { adapterCatalogue, providerStatuses } from '../providers/credentials';
import { refreshCatalogue } from '../catalogue/refresh'; import { refreshCatalogue, refreshableProviders } from '../catalogue/refresh';
import { openaiError, UUID_PATTERN } from '../errors'; import { openaiError, UUID_PATTERN } from '../errors';
export const providersRouter = createRouter(); export const providersRouter = createRouter();
@@ -24,15 +24,32 @@ providersRouter.get('/', async (ctx) => ctx.json({ data: await providerStatuses(
providersRouter.get('/adapters', async (ctx) => ctx.json({ data: await adapterCatalogue() })); providersRouter.get('/adapters', async (ctx) => ctx.json({ data: await adapterCatalogue() }));
/** /**
* Refresh the catalogue now, rather than waiting for the daily pass. * Refresh one provider's models and prices, now.
* *
* Here rather than on a timer alone because the first thing anyone does after adding a key is expect * The only trigger there is — no schedule. `?provider=openai` refreshes that one; omitted, it does
* the model list to reflect it — the daily refresh has no key to work with until one exists, so the * every provider a refresher exists for. A vendor we cannot refresh is a 400 rather than a silent
* pass that matters most is the one immediately after. * success, since "nothing happened" and "nothing could happen" look identical from a button.
*
* Answers with the models it ended up with, so the caller can render the result without a second
* round trip to the catalogue it just changed.
*/ */
providersRouter.post('/catalogue/refresh', async (ctx) => { providersRouter.post('/catalogue/refresh', async (ctx) => {
const catalogue = await refreshCatalogue(); const name = ctx.req.query('provider') || undefined;
return ctx.json({ data: { updated_at: catalogue.updatedAt, providers: catalogue.providers.length } });
try {
const catalogue = await refreshCatalogue(name);
const provider = name ? catalogue.providers.find((entry) => entry.name === name) : undefined;
return ctx.json({
data: {
updated_at: catalogue.updatedAt,
refreshable: refreshableProviders(),
models: (provider?.models ?? catalogue.providers.flatMap((entry) => entry.models)).length,
},
});
} catch (err) {
return ctx.json(openaiError(err instanceof Error ? err.message : String(err), 'invalid_request_error', 'provider'), 400);
}
}); });
providersRouter.post('/', async (ctx) => { providersRouter.post('/', async (ctx) => {
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './types'; export * from './types';
export { SHIPPED_CATALOGUE } from './shipped'; export { SHIPPED_CATALOGUE } from './shipped';
export { getCatalogue, mergeCatalogue, invalidateCatalogue, CATALOGUE_PATH } from './store'; export { getCatalogue, mergeCatalogue, invalidateCatalogue, CATALOGUE_PATH } from './store';
export { refreshCatalogue, startCatalogueRefresh } from './refresh'; export { refreshCatalogue, refreshableProviders } from './refresh';
+30 -43
View File
@@ -1,46 +1,59 @@
import { projectEnv } from '../env';
import { refreshOpenAI } from './openai-refresh'; import { refreshOpenAI } from './openai-refresh';
import { SHIPPED_CATALOGUE } from './shipped'; import { SHIPPED_CATALOGUE } from './shipped';
import { catalogueAge, getCatalogue, mergeCatalogue, writeCatalogue } from './store'; import { getCatalogue, mergeCatalogue, writeCatalogue } from './store';
import type { Catalogue } from './types'; import type { Catalogue } from './types';
/** /**
* Keeping the catalogue current — the mechanism, with OpenAI as its first and only implementation. * Bringing the catalogue up to date — on demand, never on a timer.
* *
* A provider is refreshed by one function that returns what it could establish; this file decides * There was a daily schedule here and it is gone. A refresh only matters at one moment — you are
* when they run, merges the results, and writes the file. Adding a second provider is a second * choosing a provider and want to know its models are current — and at that moment a button answers
* function and a line in `REFRESHERS` — the same restraint hono.ts keeps on its mounts. * the question while a nightly job can only have already answered it, or not, invisibly. A timer also
* buys a standing obligation: something that hits a vendor from every install forever, whether anyone
* is looking at Atelier or not.
*
* One refresher per provider, each returning what it could establish. Adding a second provider is a
* second function and a line in `REFRESHERS` — the same restraint hono.ts keeps on its mounts.
*/ */
/** Once a day. Model lists and prices move on the order of weeks; this is already generous. */ const REFRESHERS: Record<string, () => Promise<{ catalogue: Catalogue | null; listedIds: string[] | null }>> = {
const INTERVAL_MS = Number(projectEnv('ATELIER_CATALOGUE_INTERVAL_MS') ?? 24 * 60 * 60 * 1000); openai: refreshOpenAI,
};
const REFRESHERS = [refreshOpenAI]; export const refreshableProviders = () => Object.keys(REFRESHERS);
/** /**
* Run every refresher and write the result. * Refresh one provider, or every provider we have a refresher for.
* *
* Retiring happens HERE rather than in a refresher, because it is the one judgement that needs the * Retiring happens HERE rather than in a refresher, because it is the one judgement that needs the
* whole picture: a model we ship and the vendor no longer lists is deprecated, but only when the * whole picture: a model we ship and the vendor no longer lists is deprecated, but only when the
* listing actually succeeded. "The endpoint returned three models" and "the request failed after * listing actually succeeded. "The endpoint returned three models" and "the request failed after
* three" are indistinguishable inside the refresher and completely different here. * three" are indistinguishable inside the refresher and completely different here.
*
* The merge starts from the catalogue as it stands, not from the shipped file, so refreshing OpenAI
* cannot discard what a previous refresh established about anything else.
*/ */
export async function refreshCatalogue(): Promise<Catalogue> { export async function refreshCatalogue(providerName?: string): Promise<Catalogue> {
const results = await Promise.all(REFRESHERS.map((run) => run())); const names = providerName ? [providerName] : Object.keys(REFRESHERS);
const unknown = names.filter((name) => !REFRESHERS[name]);
if (unknown.length > 0) throw new Error(`No refresher for provider "${unknown.join(', ')}".`);
let merged = SHIPPED_CATALOGUE; let merged = mergeCatalogue(SHIPPED_CATALOGUE, await getCatalogue());
for (const { catalogue, listedIds } of results) {
for (const name of names) {
const { catalogue, listedIds } = await REFRESHERS[name]!();
if (!catalogue) continue; if (!catalogue) continue;
merged = mergeCatalogue(merged, catalogue); merged = mergeCatalogue(merged, catalogue);
if (listedIds) { if (listedIds) {
const listed = new Set(listedIds); const listed = new Set(listedIds);
for (const provider of merged.providers) { for (const provider of merged.providers) {
if (!catalogue.providers.some((entry) => entry.name === provider.name)) continue; if (provider.name !== name) continue;
for (const model of provider.models) { for (const model of provider.models) {
// Deprecated, not deleted: the gallery labels historical rows from this list, and 88 images // Deprecated, not deleted: the gallery labels historical rows from this list, and 88 images
// name the model that made them. // name the model that made them.
if (!listed.has(model.id)) model.status = 'deprecated'; model.status = listed.has(model.id) ? 'current' : 'deprecated';
} }
} }
} }
@@ -50,34 +63,8 @@ export async function refreshCatalogue(): Promise<Catalogue> {
await writeCatalogue(written); await writeCatalogue(written);
const count = written.providers.reduce((sum, provider) => sum + provider.models.length, 0); const count = written.providers.reduce((sum, provider) => sum + provider.models.length, 0);
console.log(`[atelier:catalogue] refreshed — ${written.providers.length} provider(s), ${count} model(s)`); console.log(`[atelier:catalogue] refreshed ${names.join(', ')} — ${count} model(s) across ${written.providers.length} provider(s)`);
return written; return written;
} }
/**
* Start the schedule.
*
* Refreshes on boot only when the file is genuinely stale, so a sidecar that restarts twenty times in
* an afternoon — which this one has — does not hit OpenAI twenty times. `unref` so a pending timer
* never holds the process open at shutdown.
*/
export async function startCatalogueRefresh(): Promise<void> {
const globalForCatalogue = globalThis as unknown as { __atelierCatalogueStarted?: boolean };
if (globalForCatalogue.__atelierCatalogueStarted) return;
globalForCatalogue.__atelierCatalogueStarted = true;
const age = await catalogueAge();
if (age === null || age > INTERVAL_MS) {
// Not awaited: a slow or unreachable vendor must not delay the sidecar's own startup, and every
// failure path here already degrades to the shipped catalogue.
void refreshCatalogue().catch((err: unknown) => console.error('[atelier:catalogue] refresh failed:', err));
} else {
console.log(`[atelier:catalogue] ${Math.round(age / 3_600_000)}h old — next refresh on schedule`);
}
setInterval(() => {
void refreshCatalogue().catch((err: unknown) => console.error('[atelier:catalogue] refresh failed:', err));
}, INTERVAL_MS).unref();
}
export { getCatalogue }; export { getCatalogue };
-4
View File
@@ -4,7 +4,6 @@ import { API_URL } from '@@/officer-url.mjs';
import { buildHonoApp } from './hono'; import { buildHonoApp } from './hono';
import { waitForDatabase } from './db'; import { waitForDatabase } from './db';
import { startQueue } from './generations/queue'; import { startQueue } from './generations/queue';
import { startCatalogueRefresh } from './catalogue/refresh';
// The officer-atelier sidecar. Owns the whole image-generation contract: providers, the generation // The officer-atelier sidecar. Owns the whole image-generation contract: providers, the generation
// queue, and the `atelier` Postgres database (kept separate from officer_db — see ../PLUGIN.md). // queue, and the `atelier` Postgres database (kept separate from officer_db — see ../PLUGIN.md).
@@ -81,9 +80,6 @@ void (async () => {
if (await waitForDatabase()) await startQueue(); if (await waitForDatabase()) await startQueue();
else console.error('[atelier:queue] not started: no database'); else console.error('[atelier:queue] not started: no database');
// After the database, because a refresh reads any connection's key before falling back to the
// environment — and before it, there is nothing to read.
await startCatalogueRefresh();
})(); })();
function shutdown(signal: string) { function shutdown(signal: string) {
+3
View File
@@ -4,6 +4,7 @@ import type { Provider } from '../db';
import { decrypt } from '../crypto'; import { decrypt } from '../crypto';
import { ADAPTERS, adapterByName } from './index'; import { ADAPTERS, adapterByName } from './index';
import { getCatalogue } from '../catalogue/store'; import { getCatalogue } from '../catalogue/store';
import { refreshableProviders } from '../catalogue/refresh';
import type { ImageAdapter } from './types'; import type { ImageAdapter } from './types';
/** /**
@@ -146,7 +147,9 @@ export const adapterCatalogue = async () => {
default_model: provider.defaultModel ?? adapter.defaultModel, default_model: provider.defaultModel ?? adapter.defaultModel,
env_var: provider.envVar || envVarFor(provider.name), env_var: provider.envVar || envVarFor(provider.name),
supports_edit: typeof adapter.edit === 'function', 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, updated_at: catalogue.updatedAt ?? null,
refreshable: refreshableProviders().includes(provider.name),
}, },
]; ];
}); });
+44 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Check, KeyRound, Plus, Trash2 } from 'lucide-react'; import { Check, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -114,6 +114,14 @@ export const ProvidersBar = ({ manager }: { manager: ProvidersManager }) => {
const label = (provider: ProviderStatus) => `${provider.adapter} · ${provider.model}`; const label = (provider: ProviderStatus) => `${provider.adapter} · ${provider.model}`;
/** "never refreshed" is a fact worth showing: it means these models are exactly what the plugin shipped. */
const freshness = (updatedAt?: string | null): string => {
if (!updatedAt) return 'as shipped';
const days = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 86_400_000);
if (days <= 0) return 'refreshed today';
return `${days}d old`;
};
type DialogState = null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus }; type DialogState = null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus };
/** Add a provider, or set the key on one that has none. Same dialog, since the key field is shared. */ /** Add a provider, or set the key on one that has none. Same dialog, since the key field is shared. */
@@ -176,8 +184,14 @@ const ProviderDialog = ({
> >
{state?.mode === 'add' && ( {state?.mode === 'add' && (
<> <>
{/*
The provider list is the CURATED FILE — the providers Officer decided are worth
offering — and the models under it are that file's current answer for the one chosen.
Neither is read from the vendor here: the file is the offer, and refreshing it is a
deliberate act with its own button rather than something that happens on a timer.
*/}
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-xs text-muted-foreground">Adapter</span> <span className="text-xs text-muted-foreground">Provider</span>
<Select <Select
value={adapter} value={adapter}
onValueChange={(value) => { onValueChange={(value) => {
@@ -199,20 +213,44 @@ const ProviderDialog = ({
</Label> </Label>
<Label className="grid gap-2"> <Label className="grid gap-2">
<span className="text-xs text-muted-foreground">Model</span> <span className="flex items-center gap-2 text-xs text-muted-foreground">
Model
<span className="ml-auto flex items-center gap-1">
<span className="text-muted-foreground/60">{freshness(manager.adapterFor(adapter)?.updated_at)}</span>
{/* This provider's models, from the vendor, now. Slow — it is a live fetch. */}
<button
type="button"
title={`Refresh ${manager.adapterFor(adapter)?.label ?? 'this provider'}'s models and prices`}
disabled={manager.refreshing || !manager.adapterFor(adapter)?.refreshable}
onClick={() => void manager.refreshCatalogue(adapter)}
className="rounded p-1 hover:bg-accent disabled:opacity-40"
>
<RefreshCw className={manager.refreshing ? 'size-3 animate-spin' : 'size-3'} />
</button>
</span>
</span>
<Select value={model} onValueChange={setModel}> <Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Choose" /> <SelectValue placeholder="Choose" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{manager.modelsFor(adapter).map((entry) => ( {(manager.adapterFor(adapter)?.model_details ?? []).map((entry) => (
<SelectItem key={entry} value={entry}> <SelectItem key={entry.id} value={entry.id}>
{entry} {entry.label ?? entry.id}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</Label> </Label>
<button
type="button"
disabled={manager.refreshing}
onClick={() => void manager.refreshCatalogue()}
className="justify-self-start text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{manager.refreshing ? 'Refreshing…' : 'Refresh all providers'}
</button>
</> </>
)} )}
+25
View File
@@ -17,6 +17,7 @@ export const useProviders = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
@@ -65,11 +66,35 @@ export const useProviders = () => {
return client.updateProvider(created.id, { apiKey: apiKey.trim() }); return client.updateProvider(created.id, { apiKey: apiKey.trim() });
}); });
/**
* Re-read the catalogue from the vendor, then re-read our copy of it.
*
* Separate busy flag from `busy`: this one is a live fetch against OpenAI and takes seconds, where
* everything else here is a local database write. Sharing one flag would grey out the whole dialog
* for the length of a network round trip to a third party.
*/
const refreshCatalogue = async (provider?: string) => {
setRefreshing(true);
try {
await client.refreshCatalogue(provider);
setAdapters(await client.listAdapters());
setError(null);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setRefreshing(false);
}
};
return { return {
providers, providers,
adapters, adapters,
loading, loading,
busy, busy,
refreshing,
refreshCatalogue,
error, error,
refresh, refresh,
add, add,
+16
View File
@@ -52,6 +52,8 @@ export type Adapter = {
env_var: string; env_var: string;
supports_edit: boolean; supports_edit: boolean;
updated_at: string | null; updated_at: string | null;
/** Whether the sidecar has a refresher for this provider — i.e. whether the refresh button does anything. */
refreshable: boolean;
}; };
/** Snake case, matching the server — one spelling per field across every endpoint. */ /** Snake case, matching the server — one spelling per field across every endpoint. */
@@ -128,6 +130,20 @@ export function useAtelierClient() {
listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').then((r) => r.data), listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').then((r) => r.data),
listAdapters: () => client.get<{ data: Adapter[] }>('providers/adapters').then((r) => r.data), listAdapters: () => client.get<{ data: Adapter[] }>('providers/adapters').then((r) => r.data),
/**
* Re-read one provider's models and prices from the vendor, or every provider when omitted.
*
* The only thing that updates the catalogue — there is no schedule. It runs a live fetch, so it is
* as slow as the vendor is and the caller should show that it is working.
*/
refreshCatalogue: (provider?: string) =>
client
.post<{ data: { updated_at: string | null; models: number } }>(
`providers/catalogue/refresh${provider ? `?provider=${encodeURIComponent(provider)}` : ''}`,
{},
)
.then((r) => r.data),
listGenerations: (limit = 60) => client.get<{ data: GenerationRow[] }>(`images?limit=${limit}`).then((r) => r.data), listGenerations: (limit = 60) => client.get<{ data: GenerationRow[] }>(`images?limit=${limit}`).then((r) => r.data),
startGeneration: (body: Record<string, unknown>) => client.post<{ id: string }>('images/generations', body), startGeneration: (body: Record<string, unknown>) => client.post<{ id: string }>('images/generations', body),