diff --git a/sidecar/api/providers.ts b/sidecar/api/providers.ts index 070d0d9..e2bbff2 100644 --- a/sidecar/api/providers.ts +++ b/sidecar/api/providers.ts @@ -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) => { diff --git a/sidecar/catalogue/index.ts b/sidecar/catalogue/index.ts index c636810..121b91b 100644 --- a/sidecar/catalogue/index.ts +++ b/sidecar/catalogue/index.ts @@ -1,4 +1,4 @@ export * from './types'; export { SHIPPED_CATALOGUE } from './shipped'; export { getCatalogue, mergeCatalogue, invalidateCatalogue, CATALOGUE_PATH } from './store'; -export { refreshCatalogue, startCatalogueRefresh } from './refresh'; +export { refreshCatalogue, refreshableProviders } from './refresh'; diff --git a/sidecar/catalogue/refresh.ts b/sidecar/catalogue/refresh.ts index a62c49b..7f8bf02 100644 --- a/sidecar/catalogue/refresh.ts +++ b/sidecar/catalogue/refresh.ts @@ -1,46 +1,59 @@ -import { projectEnv } from '../env'; import { refreshOpenAI } from './openai-refresh'; import { SHIPPED_CATALOGUE } from './shipped'; -import { catalogueAge, getCatalogue, mergeCatalogue, writeCatalogue } from './store'; +import { getCatalogue, mergeCatalogue, writeCatalogue } from './store'; 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 - * when they run, merges the results, and writes the file. Adding a second provider is a second - * function and a line in `REFRESHERS` — the same restraint hono.ts keeps on its mounts. + * There was a daily schedule here and it is gone. A refresh only matters at one moment — you are + * choosing a provider and want to know its models are current — and at that moment a button answers + * 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 INTERVAL_MS = Number(projectEnv('ATELIER_CATALOGUE_INTERVAL_MS') ?? 24 * 60 * 60 * 1000); +const REFRESHERS: Record Promise<{ catalogue: Catalogue | null; listedIds: string[] | null }>> = { + 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 * 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 * 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 { - const results = await Promise.all(REFRESHERS.map((run) => run())); +export async function refreshCatalogue(providerName?: string): Promise { + 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; - for (const { catalogue, listedIds } of results) { + let merged = mergeCatalogue(SHIPPED_CATALOGUE, await getCatalogue()); + + for (const name of names) { + const { catalogue, listedIds } = await REFRESHERS[name]!(); if (!catalogue) continue; + merged = mergeCatalogue(merged, catalogue); if (listedIds) { const listed = new Set(listedIds); 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) { // Deprecated, not deleted: the gallery labels historical rows from this list, and 88 images // 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 { await writeCatalogue(written); 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; } -/** - * 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 { - 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 }; diff --git a/sidecar/index.ts b/sidecar/index.ts index 922538d..2e5ffc2 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -4,7 +4,6 @@ import { API_URL } from '@@/officer-url.mjs'; import { buildHonoApp } from './hono'; import { waitForDatabase } from './db'; import { startQueue } from './generations/queue'; -import { startCatalogueRefresh } from './catalogue/refresh'; // 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). @@ -81,9 +80,6 @@ void (async () => { if (await waitForDatabase()) await startQueue(); 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) { diff --git a/sidecar/providers/credentials.ts b/sidecar/providers/credentials.ts index b333eb2..f3aa9af 100644 --- a/sidecar/providers/credentials.ts +++ b/sidecar/providers/credentials.ts @@ -4,6 +4,7 @@ import type { Provider } from '../db'; import { decrypt } from '../crypto'; import { ADAPTERS, adapterByName } from './index'; import { getCatalogue } from '../catalogue/store'; +import { refreshableProviders } from '../catalogue/refresh'; import type { ImageAdapter } from './types'; /** @@ -146,7 +147,9 @@ export const adapterCatalogue = async () => { default_model: provider.defaultModel ?? adapter.defaultModel, env_var: provider.envVar || envVarFor(provider.name), 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, + refreshable: refreshableProviders().includes(provider.name), }, ]; }); diff --git a/web/Generate/ProvidersBar.tsx b/web/Generate/ProvidersBar.tsx index d1cd7b1..9efaa26 100644 --- a/web/Generate/ProvidersBar.tsx +++ b/web/Generate/ProvidersBar.tsx @@ -1,5 +1,5 @@ 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 { DropdownMenu, @@ -114,6 +114,14 @@ export const ProvidersBar = ({ manager }: { manager: ProvidersManager }) => { 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 }; /** 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' && ( <> + {/* + 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. + */} + + )} diff --git a/web/Generate/useProviders.ts b/web/Generate/useProviders.ts index fb190a1..fdf06d1 100644 --- a/web/Generate/useProviders.ts +++ b/web/Generate/useProviders.ts @@ -17,6 +17,7 @@ export const useProviders = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [refreshing, setRefreshing] = useState(false); const refresh = useCallback(async () => { try { @@ -65,11 +66,35 @@ export const useProviders = () => { 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 { providers, adapters, loading, busy, + refreshing, + refreshCatalogue, error, refresh, add, diff --git a/web/lib/api.ts b/web/lib/api.ts index 2889f72..03b8270 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -52,6 +52,8 @@ export type Adapter = { env_var: string; supports_edit: boolean; 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. */ @@ -128,6 +130,20 @@ export function useAtelierClient() { listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').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), startGeneration: (body: Record) => client.post<{ id: string }>('images/generations', body),