From 324c8fdd4bafc5ff3319cc31a622b1e87eda74c1 Mon Sep 17 00:00:00 2001 From: DrSeedon Date: Tue, 10 Nov 2026 07:06:22 +0100 Subject: [PATCH] fix(usage): refetch cached rates early for a model they do not list Rates are cached for 24 hours, so a model LiteLLM adds after the cache was fetched (Claude Fable 5.2 on 2026-11-09) stays unpriced until the cache expires. When a scan prices a model the table does not list, refetch the table first instead of waiting for the TTL. Each model triggers this once per fresh table, and loadRates still keeps RATES_REFRESH_FLOOR_MS between attempts, so a model LiteLLM never lists does not refetch on every scan. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 50 ++++++++++++++++ apps/server/src/usage/UsageService.ts | 66 +++++++++++++++++----- apps/server/src/usage/usagePricing.ts | 8 +++ 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index a3775c6aebe17..80f095ca0beb7 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -3178,6 +3178,56 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("refetches cached rates early for a model they do not list", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-08-02T00:00:00Z")); + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.writeFile( + transcript, + claudeLine(1, 5, "claude-new-model") + claudeLine(2, 5, "claude-unknown-model"), + ), + ); + const requests: string[] = []; + let ratesDocument: Record = { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }; + const serviceLayer = layerService({ + prefix: "usage-service-unlisted-model-test", + home, + settings, + onRatesFetch: (request) => requests.push(request.url), + ratesResponse: () => Effect.succeed(Response.json(ratesDocument)), + }); + const newModelCost = (summary: UsageSummary) => + summary.buckets.find((bucket) => bucket.model === "claude-new-model")?.costUsd; + + // Rates fetched for this scan are fresh: no second request for the models they lack. + const first = yield* (yield* UsageService.make.pipe( + Effect.provide(serviceLayer), + )).readSummary(WINDOW); + assert.strictEqual(newModelCost(first), 0); + assert.strictEqual(requests.length, 1); + + // An hour later LiteLLM lists the new model; a restarted server starts from the cached table. + ratesDocument = { + ...ratesDocument, + "claude-new-model": { input_cost_per_token: 2e-5, output_cost_per_token: 1e-4 }, + }; + yield* TestClock.adjust(Duration.hours(1)); + const service = yield* UsageService.make.pipe(Effect.provide(serviceLayer)); + const refreshed = yield* service.readSummary(WINDOW); + assert.strictEqual(refreshed.pricing.status, "fresh"); + assert.closeTo(newModelCost(refreshed) ?? -1, 10 * 2e-5 + 5 * 1e-4, 1e-12); + assert.strictEqual(requests.length, 2); + + // A model the fresh table still lacks does not refetch again before the TTL. + yield* TestClock.adjust(Duration.hours(1)); + yield* service.readSummary(WINDOW); + assert.strictEqual(requests.length, 2); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 6b6762c089ba6..25486a1b214a5 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -40,7 +40,12 @@ import type { UsageRecord, } from "@t3tools/provider-core/server/usage"; import { canonicalModelFilter, resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; -import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; +import { + createOverrideRateTable, + isUnlistedModel, + parseRateTable, + type RateTable, +} from "./usagePricing.ts"; import { listTranscriptFiles, readDirectoryVolumeId, @@ -280,6 +285,24 @@ export const make = Effect.gen(function* () { const ensureRates = (force: boolean) => ratesLock.withPermit(loadRates(force)); + // Rates are kept for RATES_TTL_MS, so a model released after the table was fetched would stay unpriced + // that long. A scan that meets a model the table does not list refetches early: once per model against + // a fresh table, with loadRates keeping RATES_REFRESH_FLOOR_MS between attempts. + const checkedUnlistedModels = new Set(); + const refreshForUnlistedModels = Effect.fn("UsageService.refreshForUnlistedModels")(function* ( + models: ReadonlySet, + ) { + const unlisted = [...models].filter( + (model) => !checkedUnlistedModels.has(model) && isUnlistedModel(rates, model), + ); + if (unlisted.length === 0) return; + yield* ensureRates(true); + const now = yield* Clock.currentTimeMillis; + // A failed fetch leaves the models unchecked, so a later scan tries again. + if (ratesFetchedAtMs === null || now - ratesFetchedAtMs >= RATES_REFRESH_FLOOR_MS) return; + for (const model of unlisted) checkedUnlistedModels.add(model); + }); + const refreshRates = ensureRates(true).pipe( Effect.map(pricing), Effect.withSpan("UsageService.refreshRates"), @@ -719,18 +742,8 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); - const aggregator = new UsageAggregator({ - timeZone: input.timeZone, - sinceDay: input.sinceDay, - untilDay: input.untilDay, - resolution: input.resolution ?? "day", - ...hourlyWindow, - rates, - priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), - modelAliases: resolveModelAliases(settings.usageModelAliases), - ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), - }); - + const priceOverrides = createOverrideRateTable(settings.usagePriceOverrides); + const modelAliases = resolveModelAliases(settings.usageModelAliases); const sources: UsageSource[] = []; // Deleted transcripts' saved usage still aggregates and deduplicates normally. @@ -754,6 +767,33 @@ export const make = Effect.gen(function* () { } return retainedFiles; }); + + // The models priced below, as UsageAggregator resolves them: an alias replaces rateModel. + const rateModels = new Set(); + for (const files of filesByDir) { + for (const file of files) { + for (const record of file.records) { + const alias = modelAliases.get(record.model); + const model = alias ?? record.model; + if (priceOverrides.has(model.trim())) continue; + rateModels.add(alias === undefined ? (record.rateModel ?? model) : model); + } + } + } + yield* refreshForUnlistedModels(rateModels); + + const aggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + resolution: input.resolution ?? "day", + ...hourlyWindow, + rates, + priceOverrides, + modelAliases, + ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), + }); + const windowEndMs = hourlyWindow?.untilTimeMs ?? Date.parse(`${input.untilDay}T00:00:00Z`) + 86400000 + MTIME_SLACK_MS; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 1f515abb736c8..5a961d13826a8 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -213,6 +213,14 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { return rate; } +/** A priceable model the table has no rate for; a newer table may list it. */ +export function isUnlistedModel(table: RateTable, model: string): boolean { + const bareName = bareModelName(stripVariantSuffix(normalizeRateKey(model))); + return ( + bareName.length > 0 && !UNPRICEABLE_MODELS.has(bareName) && lookupRate(table, model) === null + ); +} + function resolveRate(table: RateTable, model: string): ModelRate | null { const key = stripVariantSuffix(normalizeRateKey(model)); const bareName = bareModelName(key);