diff --git a/changelog.d/fixes/12215-free-tier-overlay-freshness.md b/changelog.d/fixes/12215-free-tier-overlay-freshness.md new file mode 100644 index 0000000000..7c9a416097 --- /dev/null +++ b/changelog.d/fixes/12215-free-tier-overlay-freshness.md @@ -0,0 +1 @@ +- **fix(free-tier):** `/api/free-tier/summary` no longer computes its totals from a Radar feed built before the catalog the running release ships. When the cached feed is older — or carries no build date at all — the route answers from the shipped catalog, resolved through the operator's local model state so disabled and tombstoned models stay out of the numbers ([#12215](https://github.com/diegosouzapw/OmniRoute/pull/12215)). diff --git a/src/app/api/free-tier/summary/route.ts b/src/app/api/free-tier/summary/route.ts index 054c092551..52c38f8673 100644 --- a/src/app/api/free-tier/summary/route.ts +++ b/src/app/api/free-tier/summary/route.ts @@ -7,7 +7,7 @@ import { FREE_MODEL_BUDGETS, } from "@omniroute/open-sse/config/freeModelCatalog.data.ts"; import type { MergedEntry } from "@/lib/radar/applyFeed"; -import { getRadarCatalog } from "@/lib/radar"; +import { getCatalogWithoutOverlay, getRadarCatalog } from "@/lib/radar"; import { sumUsageTokensThisMonth } from "@/lib/db/usageSummary"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { listNoCredentialProviders } from "@/shared/utils/providerCredentialRequirement"; @@ -69,11 +69,30 @@ export async function GET(req: Request): Promise { // live feed is supporter-key content, so it only reaches callers this // instance has authenticated — never anonymous visitors, or an exposed // instance would re-publish the paid feed for free. - const serveOverlay = meta !== null && (meta.tier !== "live" || (await isAuthenticated(req))); + // A feed built before the catalog this release ships is not an overlay, it is a + // regression: the totals would be recomputed from data older than the baseline + // the operator installed. An unknown build date — a cache row written before the + // column existed — counts as older: unknown never outranks known. + const overlayIsFresh = + meta !== null && + meta.generatedAt !== null && + meta.generatedAt.slice(0, 10) >= FREE_CATALOG_CURATED_AT; + + const serveOverlay = overlayIsFresh && (meta.tier !== "live" || (await isAuthenticated(req))); + + // Withheld only because it is stale: drop the feed, keep the operator's own + // local state. Falling back to the raw baseline here would resurrect models the + // operator disabled or tombstoned. + const overlayWithheldAsStale = meta !== null && !overlayIsFresh; const totals = serveOverlay ? computeFreeModelTotals({ excludeTosAvoid, entries: entries.map(toBudgetEntry) }) - : computeFreeModelTotals({ excludeTosAvoid }); + : overlayWithheldAsStale + ? computeFreeModelTotals({ + excludeTosAvoid, + entries: getCatalogWithoutOverlay().map(toBudgetEntry), + }) + : computeFreeModelTotals({ excludeTosAvoid }); const usedThisMonth = sumUsageTokensThisMonth(); const body = { diff --git a/src/lib/radar/applyFeed.ts b/src/lib/radar/applyFeed.ts index 816d16f774..6bfcf3e846 100644 --- a/src/lib/radar/applyFeed.ts +++ b/src/lib/radar/applyFeed.ts @@ -210,8 +210,7 @@ export function applyFeed(input: ApplyFeedInput): MergedEntry[] { const overrides = localOverrides.get(key); if (!feedEntry) { - // No feed entry: baseline passes through (rule 3: user-added survives) - resultMap.set(key, { ...baseEntry }); + resultMap.set(key, applyLocalOverrideToBaseEntry(baseEntry, overrides)); continue; } @@ -238,6 +237,24 @@ export function applyFeed(input: ApplyFeedInput): MergedEntry[] { // Internal merge helpers // --------------------------------------------------------------------------- +/** + * Baseline entry with NO feed counterpart: still honour a local override + * (e.g. enabled:false) and mark the origin, so `computeFreeModelTotals` sees + * the operator's state even when the feed does not mention this baseline + * entry. Without an override, the baseline passes through untouched + * (rule 3: user-added survives). + */ +function applyLocalOverrideToBaseEntry( + baseEntry: MergedEntry, + overrides: Partial | undefined +): MergedEntry { + if (!overrides) return { ...baseEntry }; + const localEntry: MergedEntry = { ...baseEntry, origin: "local" as const }; + if (overrides.displayName !== undefined) localEntry.displayName = overrides.displayName; + if (overrides.enabled !== undefined) localEntry.enabled = overrides.enabled; + return localEntry; +} + /** * Merge a single baseline entry with a feed entry and optional local overrides. * Rule 1: local overrides take precedence over feed values. diff --git a/src/lib/radar/index.ts b/src/lib/radar/index.ts index 34f1425bc3..af0c80d3c9 100644 --- a/src/lib/radar/index.ts +++ b/src/lib/radar/index.ts @@ -161,6 +161,30 @@ export function getRadarCatalog(deps: GetRadarCatalogDeps = {}): RadarCatalogRes }; } +/** Injectable deps for `getCatalogWithoutOverlay`, same shape as the catalog resolver's. */ +export interface GetCatalogWithoutOverlayDeps { + baseline?: MergedEntry[]; + getLocalState?: () => RadarLocalMergeState; +} + +/** + * The catalog with no feed applied: the shipped baseline seen through the + * operator's own local Radar state (renames, disabled models, tombstones). + * + * This is the correct fallback when a cached feed exists but is too old to + * serve. Recomputing from the raw baseline instead would drop that state, and + * the totals honour it — `computeFreeModelTotals` treats `enabled: false` as + * absent, and `applyFeed` skips tombstoned entries — so models the operator + * removed would silently reappear in the published numbers. + */ +export function getCatalogWithoutOverlay(deps: GetCatalogWithoutOverlayDeps = {}): MergedEntry[] { + const { baseline: baselineInput, getLocalState: getLocalStateFn = getRadarLocalMergeState } = + deps; + const baseline = baselineInput ?? baselineToMergedEntries(FREE_MODEL_BUDGETS); + const { localOverrides, tombstones } = getLocalStateFn(); + return applyFeed({ baseline, feed: [], localOverrides, tombstones }); +} + // --------------------------------------------------------------------------- // getRadarReferrals / getDefaultReferralFor // --------------------------------------------------------------------------- diff --git a/tests/unit/free-tier-summary-radar-overlay.test.ts b/tests/unit/free-tier-summary-radar-overlay.test.ts index d97ab156ff..59090b1c3c 100644 --- a/tests/unit/free-tier-summary-radar-overlay.test.ts +++ b/tests/unit/free-tier-summary-radar-overlay.test.ts @@ -44,9 +44,14 @@ const radarDb = await import("../../src/lib/db/radar.ts"); const { GET } = await import("../../src/app/api/free-tier/summary/route.ts"); const { computeFreeModelTotals } = await import("../../open-sse/config/freeModelCatalog.ts"); const { FREE_CATALOG_CURATED_AT } = await import("../../open-sse/config/freeModelCatalog.data.ts"); +const { FREE_MODEL_BUDGETS } = await import("../../open-sse/config/freeModelCatalog.data.ts"); -const GEN_AT = "2026-08-20T12:00:00.000Z"; -const FETCHED_AT = "2026-08-25T06:00:00.000Z"; +// Anchored on the shipped catalog's curation date so the suite cannot rot the +// next time the catalog is curated: a fixed literal would silently become older +// than the baseline and change what the route is expected to serve. +const GEN_AT = `${FREE_CATALOG_CURATED_AT}T12:00:00.000Z`; +const STALE_GEN_AT = "2026-01-02T12:00:00.000Z"; +const FETCHED_AT = `${FREE_CATALOG_CURATED_AT}T18:00:00.000Z`; const OVERLAY_TOKENS = 1_234_567; const DISABLED_TOKENS = 9_999_999; @@ -223,12 +228,10 @@ test("a pre-migration cache row serves the overlay with an UNKNOWN date, not the const body = await getBody(false); - assert.equal(body.catalogSource, "radar-overlay"); - assert.equal( - body.catalogUpdatedAt, - null, - "unknown must stay unknown — fetchedAt would re-create the confusion generated_at exists to end" - ); + // Freshness guard: unknown build date is stale, so the baseline answers. + // Unknown must still not be papered over by fetchedAt. + assert.equal(body.catalogSource, "baseline"); + assert.equal(body.catalogUpdatedAt, FREE_CATALOG_CURATED_AT); }); // --- live tier: paid content stays behind this instance's sessions ---------- @@ -280,3 +283,84 @@ test("corrupt cache => baseline fallback with the baseline contract", async () = assert.equal(body.catalogUpdatedAt, FREE_CATALOG_CURATED_AT); assert.equal(body.steadyRecurringTokens, computeFreeModelTotals().steadyRecurringTokens); }); + +// --- freshness guard: never answer from a feed built before the shipped catalog --- + +test("overlay older than the shipped catalog => the baseline answers", async () => { + resetState(); + setFeatureFlagOverride("RADAR_ENABLED", "true"); + seedCache("community", { generatedAt: STALE_GEN_AT }); + + const body = await getBody(); + + assert.equal(body.catalogSource, "baseline"); + assert.equal(body.catalogUpdatedAt, FREE_CATALOG_CURATED_AT); +}); + +test("overlay with an unknown build date => the baseline answers", async () => { + resetState(); + setFeatureFlagOverride("RADAR_ENABLED", "true"); + seedCache("community", { generatedAt: null }); + + const body = await getBody(); + + assert.equal(body.catalogSource, "baseline"); + assert.equal(body.catalogUpdatedAt, FREE_CATALOG_CURATED_AT); +}); + +test("overlay built on the curation day itself => the overlay answers", async () => { + resetState(); + setFeatureFlagOverride("RADAR_ENABLED", "true"); + seedCache("community", { generatedAt: `${FREE_CATALOG_CURATED_AT}T00:30:00.000Z` }); + + const body = await getBody(); + + assert.equal(body.catalogSource, "radar-overlay"); +}); + +test("overlay newer than the shipped catalog still answers", async () => { + resetState(); + setFeatureFlagOverride("RADAR_ENABLED", "true"); + seedCache("community"); + + const body = await getBody(); + + assert.equal(body.catalogSource, "radar-overlay"); + assert.equal(body.catalogUpdatedAt, GEN_AT); +}); + +// --- the stale fallback keeps the operator's own decisions --- + +test("stale overlay => a tombstoned model stays out of the totals", async () => { + resetState(); + setFeatureFlagOverride("RADAR_ENABLED", "true"); + seedCache("community", { generatedAt: STALE_GEN_AT }); + + const target = FREE_MODEL_BUDGETS[0]; + const withoutTombstone = await getBody(); + radarDb.setRadarModelTombstone(target.provider, target.modelId, true); + const withTombstone = await getBody(); + + assert.equal(withTombstone.catalogSource, "baseline"); + assert.ok( + (withTombstone.modelCount as number) < (withoutTombstone.modelCount as number), + "a tombstoned model must never come back into the totals when the feed is withheld" + ); +}); + +test("stale overlay => a locally disabled model stays out of the totals", async () => { + resetState(); + setFeatureFlagOverride("RADAR_ENABLED", "true"); + seedCache("community", { generatedAt: STALE_GEN_AT }); + + const baselineBody = await getBody(); + const target = FREE_MODEL_BUDGETS[0]; + radarDb.setRadarLocalModelOverride(target.provider, target.modelId, { enabled: false }); + const disabledBody = await getBody(); + + assert.equal(disabledBody.catalogSource, "baseline"); + assert.ok( + (disabledBody.modelCount as number) < (baselineBody.modelCount as number), + "a locally disabled model must not be counted when the feed is withheld" + ); +});