authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 02:46:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 02:46:51-07:00
log2b745efe57cddc1753d3007d081c64bd20e3ab79
tree1b4b06117c57fef9ec67359fc60db2a383b26e44
parent2aa907b1969237efdaced29612fb46ba51be7041
signature Signed by PGP key B5690EEEBB952194

fix(usage): price new models without waiting a day for the rate table (#9202)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

11 files changed, 166 insertions(+), 32 deletions(-)

apps/mobile/src/state/usage.ts+13-3
......@@ -16,6 +16,7 @@ import {
1616 type UsageSummary,
1717 type UsageSummaryInput,
1818} from "@t3tools/contracts";
19import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
1920import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
2021import * as Option from "effect/Option";
2122import { AsyncResult, Atom } from "effect/unstable/reactivity";
......@@ -100,12 +101,21 @@ export function useUsage(input: UsageSummaryInput): UsageView {
100101 // Refreshing only the derived atom would re-read the per-environment SWR
101102 // queries within their stale window and change nothing. Refresh each
102103 // environment's query so pull-to-refresh always rescans.
104 //
105 // Each environment refetches model pricing first, so a model released since
106 // its last daily fetch gets priced by the rescan. The rescan runs whether or
107 // not the refetch succeeds: an offline environment still recounts tokens.
103108 const refresh = useCallback(() => {
104109 const input = JSON.parse(windowKey) as UsageSummaryInput;
105110 for (const environment of environments) {
106 appAtomRegistry.refresh(
107 serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }),
108 );
111 const { environmentId } = environment;
112 const query = serverEnvironment.usageSummary({ environmentId, input });
113 void runAtomCommand(
114 appAtomRegistry,
115 serverEnvironment.refreshUsageRates,
116 { environmentId, input: {} },
117 { reportFailure: false },
118 ).finally(() => appAtomRegistry.refresh(query));
109119 }
110120 }, [environments, windowKey]);
111121
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -56,6 +56,7 @@ export const RPC_REQUIRED_SCOPES = {
5656 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
5757 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
5858 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
59 [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,
5960 [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,
6061 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
6162 [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,
apps/server/src/usage/UsageService.test.ts+47-1
......@@ -8,11 +8,13 @@ import { assert, describe, it } from "@effect/vitest";
88import * as NodeServices from "@effect/platform-node/NodeServices";
99import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1010import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
11import * as Duration from "effect/Duration";
1112import * as Effect from "effect/Effect";
1213import * as Exit from "effect/Exit";
1314import * as Fiber from "effect/Fiber";
1415import * as Layer from "effect/Layer";
1516import * as Scheduler from "effect/Scheduler";
17import * as TestClock from "effect/testing/TestClock";
1618import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1719
1820import * as ServerConfig from "../config.ts";
......@@ -65,6 +67,8 @@ const serviceLayers = (input: {
6567 readonly home: string;
6668 readonly settings: Parameters<typeof ServerSettings.layerTest>[0];
6769 readonly onRatesFetch?: () => void;
70 /** Defaults to an unparsable document so every scan retries the fetch. */
71 readonly ratesDocument?: unknown;
6872}) =>
6973 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
7074 Layer.provideMerge(NodeServices.layer),
......@@ -77,7 +81,7 @@ const serviceLayers = (input: {
7781 input.onRatesFetch?.();
7882 // Unparsable rates: every scan retries the fetch, which makes the
7983 // fetch count a boundary-level observation of how many scans ran.
80 return HttpClientResponse.fromWeb(request, Response.json({}));
84 return HttpClientResponse.fromWeb(request, Response.json(input.ratesDocument ?? {}));
8185 }),
8286 ),
8387 ),
......@@ -142,6 +146,48 @@ describe("UsageService", () => {
142146 }).pipe(Effect.scoped),
143147 );
144148
149 it.live("refetches a rate table inside its TTL only when the client asks", () =>
150 Effect.gen(function* () {
151 const { transcript, settings, home } = yield* setup;
152 yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5)));
153
154 let ratesFetches = 0;
155 const service = yield* UsageService.make.pipe(
156 Effect.provide(
157 serviceLayers({
158 prefix: "usage-service-rates-refresh-test",
159 home,
160 settings,
161 ratesDocument: {
162 "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 },
163 },
164 onRatesFetch: () => {
165 ratesFetches += 1;
166 },
167 }),
168 ),
169 );
170
171 const first = yield* service.readSummary(WINDOW);
172 assert.strictEqual(ratesFetches, 1);
173 assert.strictEqual(first.pricing.status, "fresh");
174
175 // Inside the daily TTL a plain rescan keeps the cached table.
176 yield* TestClock.adjust(Duration.minutes(2));
177 yield* service.readSummary(WINDOW);
178 assert.strictEqual(ratesFetches, 1);
179
180 // An explicit refresh fetches again so a newly listed model gets priced.
181 // A burst of refreshes shares that one fetch.
182 const [refreshed] = yield* Effect.all([service.refreshRates, service.refreshRates], {
183 concurrency: 2,
184 });
185 assert.strictEqual(ratesFetches, 2);
186 assert.strictEqual(refreshed.status, "fresh");
187 assert.strictEqual(refreshed.knownModels, 1);
188 }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
189 );
190
145191 it.live("does not orphan an in-flight scan when its first caller is interrupted", () =>
146192 Effect.gen(function* () {
147193 const { settings, home } = yield* setup;
apps/server/src/usage/UsageService.ts+44-22
......@@ -18,6 +18,7 @@ import {
1818 USAGE_CONTRACT_VERSION,
1919 type UsageProviderKind,
2020 type UsageSource,
21 type UsagePricing,
2122 type UsageSummary,
2223 type UsageSummaryInput,
2324 UsageReadError,
......@@ -34,6 +35,7 @@ import * as Layer from "effect/Layer";
3435import * as Option from "effect/Option";
3536import * as Path from "effect/Path";
3637import * as Schema from "effect/Schema";
38import * as Semaphore from "effect/Semaphore";
3739import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3840
3941import { ServerConfig } from "../config.ts";
......@@ -63,6 +65,9 @@ const LITELLM_RATES_URL =
6365/** Rates move rarely; a day-old table keeps the page working offline. */
6466const RATES_TTL_MS = 24 * 60 * 60 * 1000;
6567
68/** An explicit refresh ignores the TTL, but not a table fetched this recently. */
69const RATES_REFRESH_FLOOR_MS = 60 * 1000;
70
6671/**
6772 * Files are filtered by mtime before opening. The slack covers a session whose
6873 * last write lands just before local midnight on the window's first day.
......@@ -94,9 +99,18 @@ export class UsageService extends Context.Service<
9499 UsageService,
95100 {
96101 readonly readSummary: (input: UsageSummaryInput) => Effect.Effect<UsageSummary, UsageReadError>;
102 /** Refetches the rate table ahead of its TTL. See `ensureRates`. */
103 readonly refreshRates: Effect.Effect<UsagePricing>;
97104 }
98105>()("t3/usage/UsageService") {}
99106
107const EMPTY_PRICING: UsagePricing = {
108 status: "unavailable",
109 source: LITELLM_RATES_URL,
110 fetchedAt: null,
111 knownModels: 0,
112};
113
100114/** Empty summary, for suites that only need the RPC surface to resolve. */
101115export const layerTest = Layer.succeed(
102116 UsageService,
......@@ -110,14 +124,10 @@ export const layerTest = Layer.succeed(
110124 untilDay: input.untilDay,
111125 buckets: [],
112126 sources: [],
113 pricing: {
114 status: "unavailable",
115 source: LITELLM_RATES_URL,
116 fetchedAt: null,
117 knownModels: 0,
118 },
127 pricing: EMPTY_PRICING,
119128 scanDurationMs: 0,
120129 }),
130 refreshRates: Effect.succeed(EMPTY_PRICING),
121131 }),
122132);
123133
......@@ -136,16 +146,29 @@ export const make = Effect.gen(function* () {
136146 const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json");
137147 let rates: RateTable = new Map();
138148 let ratesFetchedAtMs: number | null = null;
139 let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable";
149 let ratesStatus: UsagePricing["status"] = "unavailable";
150 // One fetch at a time. A burst of refreshes from several clients waits on
151 // the first fetch and then sees a table young enough to skip its own.
152 const ratesLock = yield* Semaphore.make(1);
153
154 const pricing = (): UsagePricing => ({
155 status: ratesStatus,
156 source: LITELLM_RATES_URL,
157 fetchedAt:
158 ratesFetchedAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)),
159 knownModels: rates.size,
160 });
140161
141162 /**
142163 * Loads the LiteLLM rate table, preferring a fresh copy and falling back to
143164 * the on-disk snapshot. With neither, every model reports as unpriced rather
144 * than the page failing.
165 * than the page failing. `force` refetches inside the TTL so a model that
166 * LiteLLM added since the last fetch gets priced now.
145167 */
146 const ensureRates = Effect.fn("UsageService.ensureRates")(function* () {
168 const loadRates = Effect.fn("UsageService.loadRates")(function* (force: boolean) {
147169 const now = yield* Clock.currentTimeMillis;
148 if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return;
170 const maxAgeMs = force ? RATES_REFRESH_FLOOR_MS : RATES_TTL_MS;
171 if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < maxAgeMs) return;
149172
150173 if (ratesFetchedAtMs === null) {
151174 const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe(
......@@ -158,7 +181,7 @@ export const make = Effect.gen(function* () {
158181 rates = parsed;
159182 ratesFetchedAtMs = fromDisk.fetchedAtMs;
160183 ratesStatus = "cached";
161 if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return;
184 if (now - fromDisk.fetchedAtMs < maxAgeMs) return;
162185 }
163186 }
164187 }
......@@ -189,6 +212,13 @@ export const make = Effect.gen(function* () {
189212 );
190213 });
191214
215 const ensureRates = (force: boolean) => ratesLock.withPermit(loadRates(force));
216
217 const refreshRates = ensureRates(true).pipe(
218 Effect.map(pricing),
219 Effect.withSpan("UsageService.refreshRates"),
220 );
221
192222 /**
193223 * Claude's config dir is the home itself when overridden, but a default
194224 * install nests transcripts under `~/.claude/projects`. Probe both.
......@@ -425,7 +455,7 @@ export const make = Effect.gen(function* () {
425455 // Pricing only matters once records are aggregated, so the rate table
426456 // loads while transcripts stream instead of gating them: a cold rates
427457 // fetch on a slow network no longer delays the scan by its own timeout.
428 const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], {
458 const [, scannedDirs] = yield* Effect.all([ensureRates(false), collectDirs(windowStartMs)], {
429459 concurrency: 2,
430460 });
431461
......@@ -511,15 +541,7 @@ export const make = Effect.gen(function* () {
511541 untilDay: input.untilDay,
512542 buckets: aggregated.buckets,
513543 sources,
514 pricing: {
515 status: ratesStatus,
516 source: LITELLM_RATES_URL,
517 fetchedAt:
518 ratesFetchedAtMs === null
519 ? null
520 : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)),
521 knownModels: rates.size,
522 },
544 pricing: pricing(),
523545 scanDurationMs: Math.max(0, finishedAtMs - startedAtMs),
524546 } satisfies UsageSummary;
525547 });
......@@ -570,7 +592,7 @@ export const make = Effect.gen(function* () {
570592 return yield* Deferred.await(deferred);
571593 });
572594
573 return { readSummary } as const;
595 return { readSummary, refreshRates } as const;
574596});
575597
576598export const layer = Layer.effect(UsageService, make);
apps/server/src/usage/usagePricing.test.ts+9
......@@ -31,6 +31,15 @@ describe("usage pricing", () => {
3131 }
3232 });
3333
34 it("prices a bracketed context-tier variant at the base model's rate", () => {
35 const table = parseRateTable({ "claude-fable-5-1": rate(1e-5, 2.5e-7) });
36
37 expect(lookupRate(table, "claude-fable-5-1[1m]")).toEqual(
38 lookupRate(table, "claude-fable-5-1"),
39 );
40 expect(lookupRate(table, "anthropic/Claude-Fable-5-1[1m]")).toBeNull();
41 });
42
3443 it("adds a bare alias when every qualified entry has the same rate", () => {
3544 const table = parseRateTable({
3645 "provider-a/example-model": rate(1),
apps/server/src/usage/usagePricing.ts+11-1
......@@ -119,6 +119,16 @@ function bareModelName(key: string): string {
119119 return slash === -1 ? key : key.slice(slash + 1);
120120}
121121
122/**
123 * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which
124 * Claude Code writes for the 1M context tier. The rate table only knows the
125 * base name, and we price at the base tier anyway.
126 */
127function stripVariantSuffix(key: string): string {
128 const bracket = key.indexOf("[");
129 return bracket === -1 ? key : key.slice(0, bracket);
130}
131
122132/**
123133 * Models we never price, regardless of the table.
124134 *
......@@ -136,7 +146,7 @@ const UNPRICEABLE_MODELS = new Set([
136146]);
137147
138148export function lookupRate(table: RateTable, model: string): ModelRate | null {
139 const key = normalizeRateKey(model);
149 const key = stripVariantSuffix(normalizeRateKey(model));
140150 const bareName = bareModelName(key);
141151 if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null;
142152 return table.get(key) ?? null;
apps/server/src/ws.ts+4
......@@ -1899,6 +1899,10 @@ const makeWsRpcLayer = (
18991899 observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), {
19001900 "rpc.aggregate": "server",
19011901 }),
1902 [WS_METHODS.serverRefreshUsageRates]: (_input) =>
1903 observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, {
1904 "rpc.aggregate": "server",
1905 }),
19021906 [WS_METHODS.serverRetryResourceTelemetry]: (_input) =>
19031907 observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, {
19041908 "rpc.aggregate": "server",
apps/web/src/state/usage.ts+13-3
......@@ -13,6 +13,7 @@ import {
1313 type UsageSummary,
1414 type UsageSummaryInput,
1515} from "@t3tools/contracts";
16import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
1617import * as Option from "effect/Option";
1718import { AsyncResult, Atom } from "effect/unstable/reactivity";
1819import { useCallback, useMemo } from "react";
......@@ -97,12 +98,21 @@ export function useUsage(input: UsageSummaryInput): UsageView {
9798 // Refreshing only the derived atom would re-read the per-environment SWR
9899 // queries within their stale window and change nothing. Refresh each
99100 // environment's query so the button always rescans.
101 //
102 // Each environment refetches model pricing first, so a model released since
103 // its last daily fetch gets priced by the rescan. The rescan runs whether or
104 // not the refetch succeeds: an offline environment still recounts tokens.
100105 const refresh = useCallback(() => {
101106 const input = JSON.parse(windowKey) as UsageSummaryInput;
102107 for (const environment of environments) {
103 appAtomRegistry.refresh(
104 serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }),
105 );
108 const { environmentId } = environment;
109 const query = serverEnvironment.usageSummary({ environmentId, input });
110 void runAtomCommand(
111 appAtomRegistry,
112 serverEnvironment.refreshUsageRates,
113 { environmentId, input: {} },
114 { reportFailure: false },
115 ).finally(() => appAtomRegistry.refresh(query));
106116 }
107117 }, [environments, windowKey]);
108118
docs/user/usage.md+3-1
......@@ -10,4 +10,6 @@ completed-turn record will not appear.
1010
1111Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
1212**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the
13headline and chart, and refreshing rescans every connected environment.
13headline and chart. Refreshing rescans every connected environment and refetches model pricing on
14each of them, so a newly released model that showed $0.00 gets a price without waiting for the daily
15pricing update.
packages/client-runtime/src/state/server.ts+8
......@@ -933,6 +933,14 @@ export function createServerEnvironmentAtoms<R, E>(
933933 label: "environment-data:server:signal-process",
934934 tag: WS_METHODS.serverSignalProcess,
935935 }),
936 refreshUsageRates: createEnvironmentRpcCommand(runtime, {
937 label: "environment-data:server:refresh-usage-rates",
938 tag: WS_METHODS.serverRefreshUsageRates,
939 concurrency: {
940 mode: "singleFlight",
941 key: ({ environmentId }) => environmentId,
942 },
943 }),
936944 retryResourceTelemetry: createEnvironmentRpcCommand(runtime, {
937945 label: "environment-data:server:retry-resource-telemetry",
938946 tag: WS_METHODS.serverRetryResourceTelemetry,
packages/contracts/src/rpc.ts+13-1
......@@ -206,7 +206,7 @@ import {
206206 ResourceTelemetryRetryResult,
207207 ResourceTelemetrySnapshot,
208208} from "./resourceTelemetry.ts";
209import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
209import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
210210import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
211211import {
212212 SourceControlCloneRepositoryInput,
......@@ -315,6 +315,7 @@ export const WS_METHODS = {
315315 serverReportHostPowerState: "server.reportHostPowerState",
316316 serverGetBackgroundPolicy: "server.getBackgroundPolicy",
317317 serverGetUsageSummary: "server.getUsageSummary",
318 serverRefreshUsageRates: "server.refreshUsageRates",
318319
319320 // Cloud environment methods
320321 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
......@@ -547,6 +548,16 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm
547548 error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]),
548549});
549550
551/**
552 * Refetches the model rate table ahead of its daily TTL, so a model released
553 * since the last fetch gets priced. The next usage summary uses the new table.
554 */
555export const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, {
556 payload: Schema.Struct({}),
557 success: UsagePricing,
558 error: EnvironmentAuthorizationError,
559});
560
550561export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
551562 payload: ServerSignalProcessInput,
552563 success: ServerSignalProcessResult,
......@@ -1167,6 +1178,7 @@ export const WsRpcGroup = RpcGroup.make(
11671178 WsServerGetResourceTelemetryHistoryRpc,
11681179 WsServerRetryResourceTelemetryRpc,
11691180 WsServerGetUsageSummaryRpc,
1181 WsServerRefreshUsageRatesRpc,
11701182 WsServerSignalProcessRpc,
11711183 WsServerReportClientActivityRpc,
11721184 WsServerReportHostPowerStateRpc,