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 { adapterByName } from '../providers';
import { adapterCatalogue, providerStatuses } from '../providers/credentials';
import { refreshCatalogue } from '../catalogue/refresh';
import { refreshCatalogue, refreshableProviders } from '../catalogue/refresh';
import { openaiError, UUID_PATTERN } from '../errors';
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() }));
/**
* 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 model list to reflect it — the daily refresh has no key to work with until one exists, so the
* pass that matters most is the one immediately after.
* The only trigger there is — no schedule. `?provider=openai` refreshes that one; omitted, it does
* every provider a refresher exists for. A vendor we cannot refresh is a 400 rather than a silent
* 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) => {
const catalogue = await refreshCatalogue();
return ctx.json({ data: { updated_at: catalogue.updatedAt, providers: catalogue.providers.length } });
const name = ctx.req.query('provider') || undefined;
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) => {