// SPDX-License-Identifier: GPL-3.0-or-later /** * Merging a live price feed onto a curated catalog. * * Why this exists: Pi's OpenRouter catalog is stale on price but rich on * everything else. It carries metadata the public `/models` endpoint simply * does not expose — `compat.cacheControlFormat`, per-model `thinkingLevelMap`, * hand-checked `maxTokens` — for every entry, and it is where the user's own * `models.json` definitions live. The live feed carries correct prices and * dozens of models Pi has never heard of. * Which catalog plays the `base` role is decided in sync.ts: Pi's live composed * list when it could be captured, the shipped snapshot only as a last resort. * * Replacing one catalog with the other therefore trades one class of wrongness * for another. Merging keeps both: prices come from the feed, everything else * stays curated, and unseen models are appended from the feed. */ import type { LiveModel, ModelCost } from "./types.ts"; /** Cost rates are compared with this tolerance to absorb float noise. */ const EPSILON = 1e-9; /** * Prices are the only field taken from the live feed. Everything else on the * curated entry wins, because the feed cannot express it. */ function withLiveCost(base: LiveModel, cost: ModelCost): LiveModel { return { ...base, cost }; } export interface MergeStats { /** Curated models whose price the live feed corrected. */ repriced: number; /** Models present in the live feed but not in the curated catalog. */ added: number; /** Curated models the live feed did not mention; kept as-is. */ keptStale: number; total: number; } export interface MergeResult { models: LiveModel[]; stats: MergeStats; } function costsDiffer(a: ModelCost, b: ModelCost): boolean { if ( Math.abs(a.input - b.input) > EPSILON || Math.abs(a.output - b.output) > EPSILON || Math.abs(a.cacheRead - b.cacheRead) > EPSILON || Math.abs(a.cacheWrite - b.cacheWrite) > EPSILON ) { return true; } return (a.tiers?.length ?? 0) !== (b.tiers?.length ?? 0); } /** * Merge `live` prices onto `base`. * * Curated models missing from the live feed are kept rather than dropped: a * model can vanish from the feed while a user still has it selected, and * silently deleting it would break their session for no benefit. Their price * stays whatever Pi shipped, which is the best available answer. */ export function mergeCatalog( base: readonly LiveModel[], live: readonly LiveModel[], ): MergeResult { const liveById = new Map(live.map((model) => [model.id, model])); const models: LiveModel[] = []; let repriced = 0; let keptStale = 0; for (const curated of base) { const fresh = liveById.get(curated.id); if (!fresh) { models.push(curated); keptStale++; continue; } if (costsDiffer(curated.cost, fresh.cost)) repriced++; models.push(withLiveCost(curated, fresh.cost)); liveById.delete(curated.id); } // Whatever is left in the feed is new to Pi; take it verbatim. const added = liveById.size; for (const fresh of liveById.values()) models.push(fresh); return { models, stats: { repriced, added, keptStale, total: models.length } }; }