// SPDX-License-Identifier: GPL-3.0-or-later /** * Catalog lifecycle: what we contribute to pi, when, and — just as important — * when we say nothing at all. * * Designed for zero steady-state cost: * - No background threads, no polling. The network is touched only when Pi * calls `refreshModels` (session start, opening /model), never per turn. * - Within the TTL the cached list is returned from a Map lookup. * * ## The three rules * * Everything below follows from how Pi's provider composer treats our return * value, which is blunter than the published type suggests: * * getModels() = applyExtension(applyModelsJson(base, models.json), us) * * `applyExtension` does not merge. Whatever array we return *becomes* the * provider's catalog, shadowing Pi's own remote catalog and every model the * user declared in `models.json`. That gives us three hard rules. * * **1. Returning an array is a claim to own the whole catalog. Only make it * when we can back it up.** `[]` does not mean "no change" — it means "this * provider has no models", and Pi will wipe the list. A *stale* array is no * better: it silently deletes any model the user added or Pi's catalog server * shipped since our snapshot was taken. The only truthful answer when we have * no live prices is `undefined`, which the composer skips. Every failure path * below therefore degrades to `undefined`, never to a frozen catalog. * * **2. Merge onto what Pi actually composed, not onto the shipped snapshot.** * Because our array replaces everything, it has to already contain the remote * catalog and the user's `models.json` models. `getBuiltinModels()` knows about * neither — it is the frozen copy that shipped with the installed pi release. * So the real base is captured from the live registry once per process * (`captureCuratedBase`), before we have ever contributed. Rule 1 is what makes * that capture trustworthy: with no contribution on the cold paths, the first * list we observe is guaranteed to be Pi's own. * * **3. A provider's storage slot belongs to whoever owns the provider.** * Standalone providers are ours, so `ctx.publish({ persist })` is correct for * them. Merge-mode providers are not: that slot is where the built-in * `withRemoteCatalog` keeps its downloaded catalog, and it restores from it on * every start. Writing our raw feed there erased it (our entries carry no * `lastModified`, which is exactly the field its restore gates on), while its * writes made our own restore bail — two components destroying each other's * cache through one key. Merge mode now keeps its cache in memory only and * leaves the slot alone; the cost is one background catalog fetch per process, * off the startup path. */ import type { RefreshModelsContext } from "@earendil-works/pi-ai"; import { mergeCatalog, type MergeStats } from "./merge.ts"; import type { CatalogAdapter, LiveModel } from "./types.ts"; /** Prices move on the order of days, so six hours is frequent enough. */ export const DEFAULT_TTL_MS = 6 * 3600 * 1000; const TTL_ENV_VAR = "PI_LIVE_PRICING_TTL_MS"; /** Kept for compatibility with the original single-provider release. */ const LEGACY_TTL_ENV_VAR = "OPENROUTER_PRICING_TTL_MS"; interface CacheEntry { at: number; models: LiveModel[]; etag?: string; } const cache = new Map(); /** Last merge outcome per provider, surfaced by the status command. */ const lastStats = new Map(); /** Curated bases captured from the live registry. See `captureCuratedBase`. */ const curatedBases = new Map(); /** Providers we have already handed a catalog to in this process. */ const contributed = new Set(); /** * Providers whose next sync must bypass the TTL, independently of `ctx.force`. * * `/pricing-refresh` asks the registry for a forced refresh, but pi's extension * facade only started forwarding refresh options in 0.84.0; on older builds it * calls `runtime.refresh()` with no arguments, so `force` never reaches us and * the command would quietly serve the cache it was supposed to bypass. The flag * is set locally by the command and consumed by the next sync, which makes the * bypass work the same way on every pi release. */ const forcedSyncs = new Set(); /** Marks the next sync of `providerId` as forced. Consumed once. */ export function forceNextSync(providerId: string): void { forcedSyncs.add(providerId); } export function readTtlMs(env: Record = process.env): number { const raw = env[TTL_ENV_VAR] ?? env[LEGACY_TTL_ENV_VAR]; if (!raw) return DEFAULT_TTL_MS; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_MS; } /** Test seam: drops all cached catalogs and captured bases. */ export function resetCache(): void { cache.clear(); lastStats.clear(); curatedBases.clear(); contributed.clear(); forcedSyncs.clear(); } /** True in merge mode: the adapter overrides a provider Pi already ships. */ function isMergeMode(provider: CatalogAdapter): boolean { return provider.baseModels !== undefined; } /** * Records the catalog Pi composed for a provider, to be used as the merge base. * * This is the fix for the class of bug where a user's `models.json` model, or a * model Pi's catalog server shipped after the installed release was cut, * flickered in and out of the picker depending on whether our fetch had landed * yet. Both live in Pi's composed list and in neither of ours. * * Rejected once we have contributed to that provider, because from that moment * the registry is echoing our own catalog back at us: merging onto it would * make the merge self-referential, freezing prices and cementing whatever the * feed happened to be missing that day. * * @returns whether the capture was accepted. */ export function captureCuratedBase( providerId: string, models: readonly LiveModel[], ): boolean { if (contributed.has(providerId) || curatedBases.has(providerId)) return false; if (!models.length) return false; curatedBases.set(providerId, [...models]); return true; } /** * The catalog live prices are merged onto: Pi's composed list when we managed * to capture it, the shipped catalog otherwise, and nothing in standalone mode. */ export function curatedBase(provider: CatalogAdapter): readonly LiveModel[] | undefined { return curatedBases.get(provider.providerId) ?? provider.baseModels?.(); } export interface ProviderStatus { providerId: string; providerName: string; models: number; /** Milliseconds since the last successful check, or undefined if never. */ ageMs?: number; stats?: MergeStats; /** Merge mode only: whether the base came from Pi's live composed catalog. */ baseCaptured?: boolean; } export function getStatus(provider: CatalogAdapter): ProviderStatus { const hit = cache.get(provider.providerId); return { providerId: provider.providerId, providerName: provider.providerName, models: hit?.models.length ?? 0, ...(hit ? { ageMs: Date.now() - hit.at } : {}), ...(lastStats.has(provider.providerId) ? { stats: lastStats.get(provider.providerId) } : {}), ...(isMergeMode(provider) ? { baseCaptured: curatedBases.has(provider.providerId) } : {}), }; } /** * Marker stamped onto snapshots this extension writes. * * Standalone providers own their storage slot outright, but a slot can still * hold something we did not write — a leftover from an older release, or from * whatever owned the provider id before us. Adopting a foreign payload as if it * were our raw feed would corrupt the merge, so we only restore what we * recognise. An unmarked entry costs one extra background fetch. */ const SNAPSHOT_MARKER = "pi-live-pricing/1"; interface StoredSnapshot { models?: LiveModel[]; checkedAt?: number; etag?: string; source?: string; } /** * Restores a persisted snapshot into memory on first use and returns its ETag * so the next fetch can be conditional. Foreign snapshots are ignored. * * Standalone mode only — see rule 3. */ export function restoreSnapshot( providerId: string, stored: RefreshModelsContext["stored"], ): string | undefined { const hit = cache.get(providerId); if (hit) return hit.etag; if (!stored) return undefined; const snapshot = stored as unknown as StoredSnapshot; if (snapshot.source !== SNAPSHOT_MARKER) return undefined; if (!snapshot.models?.length) return snapshot.etag; cache.set(providerId, { at: snapshot.checkedAt ?? Date.now(), models: snapshot.models, etag: snapshot.etag, }); return snapshot.etag; } /** * Applies the live feed to the provider's catalog. In merge mode prices land on * the curated entries; in standalone mode the feed is the catalog. */ export function resolveCatalog( provider: CatalogAdapter, live: LiveModel[], ): { models: LiveModel[]; stats?: MergeStats } { const base = curatedBase(provider); if (!base) return { models: live }; const { models, stats } = mergeCatalog(base, live); lastStats.set(provider.providerId, stats); return { models, stats }; } /** * The one place an array leaves this module. Merging and marking the provider * as contributed happen together so that a capture can never be accepted after * we have already spoken (see `captureCuratedBase`). */ function contribute(provider: CatalogAdapter, live: LiveModel[]): LiveModel[] { const { models } = resolveCatalog(provider, live); contributed.add(provider.providerId); return models; } /** * Pi's `ModelsStoreEntry` holds full `Model` objects, which carry the provider * identity that `LiveModel` omits. Stamp it on before persisting so the stored * catalog is valid for anything else that reads it. */ function toStoredModels(provider: CatalogAdapter, models: LiveModel[]) { return models.map((model) => ({ ...model, api: model.api ?? provider.api, provider: provider.providerId, baseUrl: model.baseUrl ?? provider.baseUrl, })); } async function persist( provider: CatalogAdapter, ctx: RefreshModelsContext, models: LiveModel[], checkedAt: number, etag?: string, ): Promise { await ctx.publish({ persist: { models: toStoredModels(provider, models), checkedAt, source: SNAPSHOT_MARKER, ...(etag ? { etag } : {}), } as never, }); } /** * Entry point wired into Pi's `refreshModels`. * * Never throws, and never returns an array it cannot stand behind. `undefined` * means "nothing to contribute, keep your catalog" — see rule 1. */ export async function syncLiveModels( provider: CatalogAdapter, ctx: RefreshModelsContext, ): Promise { const merge = isMergeMode(provider); // Rule 2: in merge mode, say nothing until we know what we are merging onto. // Returning the shipped catalog here is what used to delete models. if (merge && !curatedBases.has(provider.providerId)) return undefined; // Rule 3: only a standalone provider may read its own storage slot. const priorEtag = merge ? cache.get(provider.providerId)?.etag : restoreSnapshot(provider.providerId, ctx.stored); const hit = cache.get(provider.providerId); const ttlMs = readTtlMs(); // Consumed unconditionally: `||` would short-circuit past the delete whenever // pi did forward `force`, leaving the flag armed for an unrelated later sync. const flagged = forcedSyncs.delete(provider.providerId); const force = ctx.force || flagged; // Fresh enough: serve from memory without touching the network. if (!force && hit && Date.now() - hit.at < ttlMs) { return contribute(provider, hit.models); } // Offline/cache-only initialization: whatever we already have, or nothing. if (!ctx.allowNetwork) { return hit ? contribute(provider, hit.models) : undefined; } try { const fetched = await provider.fetch(ctx.signal, priorEtag); if (fetched === null) { // 304 Not Modified: keep the catalog, bump freshness, persist the bump. if (!hit) return undefined; const checkedAt = Date.now(); cache.set(provider.providerId, { at: checkedAt, models: hit.models, etag: hit.etag }); if (!merge) await persist(provider, ctx, hit.models, checkedAt, hit.etag); return contribute(provider, hit.models); } cache.set(provider.providerId, { at: fetched.fetchedAt, models: fetched.models, etag: fetched.etag, }); if (!merge) await persist(provider, ctx, fetched.models, fetched.fetchedAt, fetched.etag); return contribute(provider, fetched.models); } catch { // Offline, rate-limited, malformed payload: degrade, never break Pi. return hit ? contribute(provider, hit.models) : undefined; } }