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 {...@@ -16,6 +16,7 @@ import {
16 type UsageSummary,16 type UsageSummary,
17 type UsageSummaryInput,17 type UsageSummaryInput,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
19import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";20import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
20import * as Option from "effect/Option";21import * as Option from "effect/Option";
21import { AsyncResult, Atom } from "effect/unstable/reactivity";22import { AsyncResult, Atom } from "effect/unstable/reactivity";
...@@ -100,12 +101,21 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -100,12 +101,21 @@ export function useUsage(input: UsageSummaryInput): UsageView {
100 // Refreshing only the derived atom would re-read the per-environment SWR101 // Refreshing only the derived atom would re-read the per-environment SWR
101 // queries within their stale window and change nothing. Refresh each102 // queries within their stale window and change nothing. Refresh each
102 // environment's query so pull-to-refresh always rescans.103 // 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.
103 const refresh = useCallback(() => {108 const refresh = useCallback(() => {
104 const input = JSON.parse(windowKey) as UsageSummaryInput;109 const input = JSON.parse(windowKey) as UsageSummaryInput;
105 for (const environment of environments) {110 for (const environment of environments) {
106 appAtomRegistry.refresh(111 const { environmentId } = environment;
107 serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }),112 const query = serverEnvironment.usageSummary({ environmentId, input });
108 );113 void runAtomCommand(
114 appAtomRegistry,
115 serverEnvironment.refreshUsageRates,
116 { environmentId, input: {} },
117 { reportFailure: false },
118 ).finally(() => appAtomRegistry.refresh(query));
109 }119 }
110 }, [environments, windowKey]);120 }, [environments, windowKey]);
111121
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -56,6 +56,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -56,6 +56,7 @@ export const RPC_REQUIRED_SCOPES = {
56 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,56 [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
57 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,57 [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
58 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,58 [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
59 [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,
59 [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,60 [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,
60 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,61 [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
61 [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,62 [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope,
apps/server/src/usage/UsageService.test.ts+47-1
...@@ -8,11 +8,13 @@ import { assert, describe, it } from "@effect/vitest";...@@ -8,11 +8,13 @@ import { assert, describe, it } from "@effect/vitest";
8import * as NodeServices from "@effect/platform-node/NodeServices";8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
10import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";10import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";
11import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
13import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
14import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
15import * as Scheduler from "effect/Scheduler";16import * as Scheduler from "effect/Scheduler";
17import * as TestClock from "effect/testing/TestClock";
16import { HttpClient, HttpClientResponse } from "effect/unstable/http";18import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1719
18import * as ServerConfig from "../config.ts";20import * as ServerConfig from "../config.ts";
...@@ -65,6 +67,8 @@ const serviceLayers = (input: {...@@ -65,6 +67,8 @@ const serviceLayers = (input: {
65 readonly home: string;67 readonly home: string;
66 readonly settings: Parameters<typeof ServerSettings.layerTest>[0];68 readonly settings: Parameters<typeof ServerSettings.layerTest>[0];
67 readonly onRatesFetch?: () => void;69 readonly onRatesFetch?: () => void;
70 /** Defaults to an unparsable document so every scan retries the fetch. */
71 readonly ratesDocument?: unknown;
68}) =>72}) =>
69 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(73 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
70 Layer.provideMerge(NodeServices.layer),74 Layer.provideMerge(NodeServices.layer),
...@@ -77,7 +81,7 @@ const serviceLayers = (input: {...@@ -77,7 +81,7 @@ const serviceLayers = (input: {
77 input.onRatesFetch?.();81 input.onRatesFetch?.();
78 // Unparsable rates: every scan retries the fetch, which makes the82 // Unparsable rates: every scan retries the fetch, which makes the
79 // fetch count a boundary-level observation of how many scans ran.83 // 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 ?? {}));
81 }),85 }),
82 ),86 ),
83 ),87 ),
...@@ -142,6 +146,48 @@ describe("UsageService", () => {...@@ -142,6 +146,48 @@ describe("UsageService", () => {
142 }).pipe(Effect.scoped),146 }).pipe(Effect.scoped),
143 );147 );
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
145 it.live("does not orphan an in-flight scan when its first caller is interrupted", () =>191 it.live("does not orphan an in-flight scan when its first caller is interrupted", () =>
146 Effect.gen(function* () {192 Effect.gen(function* () {
147 const { settings, home } = yield* setup;193 const { settings, home } = yield* setup;
apps/server/src/usage/UsageService.ts+44-22
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 USAGE_CONTRACT_VERSION,18 USAGE_CONTRACT_VERSION,
19 type UsageProviderKind,19 type UsageProviderKind,
20 type UsageSource,20 type UsageSource,
21 type UsagePricing,
21 type UsageSummary,22 type UsageSummary,
22 type UsageSummaryInput,23 type UsageSummaryInput,
23 UsageReadError,24 UsageReadError,
...@@ -34,6 +35,7 @@ import * as Layer from "effect/Layer";...@@ -34,6 +35,7 @@ import * as Layer from "effect/Layer";
34import * as Option from "effect/Option";35import * as Option from "effect/Option";
35import * as Path from "effect/Path";36import * as Path from "effect/Path";
36import * as Schema from "effect/Schema";37import * as Schema from "effect/Schema";
38import * as Semaphore from "effect/Semaphore";
37import { HttpClient, HttpClientResponse } from "effect/unstable/http";39import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3840
39import { ServerConfig } from "../config.ts";41import { ServerConfig } from "../config.ts";
...@@ -63,6 +65,9 @@ const LITELLM_RATES_URL =...@@ -63,6 +65,9 @@ const LITELLM_RATES_URL =
63/** Rates move rarely; a day-old table keeps the page working offline. */65/** Rates move rarely; a day-old table keeps the page working offline. */
64const RATES_TTL_MS = 24 * 60 * 60 * 1000;66const 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
66/**71/**
67 * Files are filtered by mtime before opening. The slack covers a session whose72 * Files are filtered by mtime before opening. The slack covers a session whose
68 * last write lands just before local midnight on the window's first day.73 * last write lands just before local midnight on the window's first day.
...@@ -94,9 +99,18 @@ export class UsageService extends Context.Service<...@@ -94,9 +99,18 @@ export class UsageService extends Context.Service<
94 UsageService,99 UsageService,
95 {100 {
96 readonly readSummary: (input: UsageSummaryInput) => Effect.Effect<UsageSummary, UsageReadError>;101 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>;
97 }104 }
98>()("t3/usage/UsageService") {}105>()("t3/usage/UsageService") {}
99106
107const EMPTY_PRICING: UsagePricing = {
108 status: "unavailable",
109 source: LITELLM_RATES_URL,
110 fetchedAt: null,
111 knownModels: 0,
112};
113
100/** Empty summary, for suites that only need the RPC surface to resolve. */114/** Empty summary, for suites that only need the RPC surface to resolve. */
101export const layerTest = Layer.succeed(115export const layerTest = Layer.succeed(
102 UsageService,116 UsageService,
...@@ -110,14 +124,10 @@ export const layerTest = Layer.succeed(...@@ -110,14 +124,10 @@ export const layerTest = Layer.succeed(
110 untilDay: input.untilDay,124 untilDay: input.untilDay,
111 buckets: [],125 buckets: [],
112 sources: [],126 sources: [],
113 pricing: {127 pricing: EMPTY_PRICING,
114 status: "unavailable",
115 source: LITELLM_RATES_URL,
116 fetchedAt: null,
117 knownModels: 0,
118 },
119 scanDurationMs: 0,128 scanDurationMs: 0,
120 }),129 }),
130 refreshRates: Effect.succeed(EMPTY_PRICING),
121 }),131 }),
122);132);
123133
...@@ -136,16 +146,29 @@ export const make = Effect.gen(function* () {...@@ -136,16 +146,29 @@ export const make = Effect.gen(function* () {
136 const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json");146 const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json");
137 let rates: RateTable = new Map();147 let rates: RateTable = new Map();
138 let ratesFetchedAtMs: number | null = null;148 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
141 /**162 /**
142 * Loads the LiteLLM rate table, preferring a fresh copy and falling back to163 * Loads the LiteLLM rate table, preferring a fresh copy and falling back to
143 * the on-disk snapshot. With neither, every model reports as unpriced rather164 * 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.
145 */167 */
146 const ensureRates = Effect.fn("UsageService.ensureRates")(function* () {168 const loadRates = Effect.fn("UsageService.loadRates")(function* (force: boolean) {
147 const now = yield* Clock.currentTimeMillis;169 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
150 if (ratesFetchedAtMs === null) {173 if (ratesFetchedAtMs === null) {
151 const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe(174 const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe(
...@@ -158,7 +181,7 @@ export const make = Effect.gen(function* () {...@@ -158,7 +181,7 @@ export const make = Effect.gen(function* () {
158 rates = parsed;181 rates = parsed;
159 ratesFetchedAtMs = fromDisk.fetchedAtMs;182 ratesFetchedAtMs = fromDisk.fetchedAtMs;
160 ratesStatus = "cached";183 ratesStatus = "cached";
161 if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return;184 if (now - fromDisk.fetchedAtMs < maxAgeMs) return;
162 }185 }
163 }186 }
164 }187 }
...@@ -189,6 +212,13 @@ export const make = Effect.gen(function* () {...@@ -189,6 +212,13 @@ export const make = Effect.gen(function* () {
189 );212 );
190 });213 });
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
192 /**222 /**
193 * Claude's config dir is the home itself when overridden, but a default223 * Claude's config dir is the home itself when overridden, but a default
194 * install nests transcripts under `~/.claude/projects`. Probe both.224 * install nests transcripts under `~/.claude/projects`. Probe both.
...@@ -425,7 +455,7 @@ export const make = Effect.gen(function* () {...@@ -425,7 +455,7 @@ export const make = Effect.gen(function* () {
425 // Pricing only matters once records are aggregated, so the rate table455 // Pricing only matters once records are aggregated, so the rate table
426 // loads while transcripts stream instead of gating them: a cold rates456 // loads while transcripts stream instead of gating them: a cold rates
427 // fetch on a slow network no longer delays the scan by its own timeout.457 // 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)], {
429 concurrency: 2,459 concurrency: 2,
430 });460 });
431461
...@@ -511,15 +541,7 @@ export const make = Effect.gen(function* () {...@@ -511,15 +541,7 @@ export const make = Effect.gen(function* () {
511 untilDay: input.untilDay,541 untilDay: input.untilDay,
512 buckets: aggregated.buckets,542 buckets: aggregated.buckets,
513 sources,543 sources,
514 pricing: {544 pricing: 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 },
523 scanDurationMs: Math.max(0, finishedAtMs - startedAtMs),545 scanDurationMs: Math.max(0, finishedAtMs - startedAtMs),
524 } satisfies UsageSummary;546 } satisfies UsageSummary;
525 });547 });
...@@ -570,7 +592,7 @@ export const make = Effect.gen(function* () {...@@ -570,7 +592,7 @@ export const make = Effect.gen(function* () {
570 return yield* Deferred.await(deferred);592 return yield* Deferred.await(deferred);
571 });593 });
572594
573 return { readSummary } as const;595 return { readSummary, refreshRates } as const;
574});596});
575597
576export const layer = Layer.effect(UsageService, make);598export const layer = Layer.effect(UsageService, make);
apps/server/src/usage/usagePricing.test.ts+9
...@@ -31,6 +31,15 @@ describe("usage pricing", () => {...@@ -31,6 +31,15 @@ describe("usage pricing", () => {
31 }31 }
32 });32 });
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
34 it("adds a bare alias when every qualified entry has the same rate", () => {43 it("adds a bare alias when every qualified entry has the same rate", () => {
35 const table = parseRateTable({44 const table = parseRateTable({
36 "provider-a/example-model": rate(1),45 "provider-a/example-model": rate(1),
apps/server/src/usage/usagePricing.ts+11-1
...@@ -119,6 +119,16 @@ function bareModelName(key: string): string {...@@ -119,6 +119,16 @@ function bareModelName(key: string): string {
119 return slash === -1 ? key : key.slice(slash + 1);119 return slash === -1 ? key : key.slice(slash + 1);
120}120}
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
122/**132/**
123 * Models we never price, regardless of the table.133 * Models we never price, regardless of the table.
124 *134 *
...@@ -136,7 +146,7 @@ const UNPRICEABLE_MODELS = new Set([...@@ -136,7 +146,7 @@ const UNPRICEABLE_MODELS = new Set([
136]);146]);
137147
138export function lookupRate(table: RateTable, model: string): ModelRate | null {148export function lookupRate(table: RateTable, model: string): ModelRate | null {
139 const key = normalizeRateKey(model);149 const key = stripVariantSuffix(normalizeRateKey(model));
140 const bareName = bareModelName(key);150 const bareName = bareModelName(key);
141 if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null;151 if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null;
142 return table.get(key) ?? null;152 return table.get(key) ?? null;
apps/server/src/ws.ts+4
...@@ -1899,6 +1899,10 @@ const makeWsRpcLayer = (...@@ -1899,6 +1899,10 @@ const makeWsRpcLayer = (
1899 observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), {1899 observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), {
1900 "rpc.aggregate": "server",1900 "rpc.aggregate": "server",
1901 }),1901 }),
1902 [WS_METHODS.serverRefreshUsageRates]: (_input) =>
1903 observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, {
1904 "rpc.aggregate": "server",
1905 }),
1902 [WS_METHODS.serverRetryResourceTelemetry]: (_input) =>1906 [WS_METHODS.serverRetryResourceTelemetry]: (_input) =>
1903 observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, {1907 observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, {
1904 "rpc.aggregate": "server",1908 "rpc.aggregate": "server",
apps/web/src/state/usage.ts+13-3
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 type UsageSummary,13 type UsageSummary,
14 type UsageSummaryInput,14 type UsageSummaryInput,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
16import * as Option from "effect/Option";17import * as Option from "effect/Option";
17import { AsyncResult, Atom } from "effect/unstable/reactivity";18import { AsyncResult, Atom } from "effect/unstable/reactivity";
18import { useCallback, useMemo } from "react";19import { useCallback, useMemo } from "react";
...@@ -97,12 +98,21 @@ export function useUsage(input: UsageSummaryInput): UsageView {...@@ -97,12 +98,21 @@ export function useUsage(input: UsageSummaryInput): UsageView {
97 // Refreshing only the derived atom would re-read the per-environment SWR98 // Refreshing only the derived atom would re-read the per-environment SWR
98 // queries within their stale window and change nothing. Refresh each99 // queries within their stale window and change nothing. Refresh each
99 // environment's query so the button always rescans.100 // 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.
100 const refresh = useCallback(() => {105 const refresh = useCallback(() => {
101 const input = JSON.parse(windowKey) as UsageSummaryInput;106 const input = JSON.parse(windowKey) as UsageSummaryInput;
102 for (const environment of environments) {107 for (const environment of environments) {
103 appAtomRegistry.refresh(108 const { environmentId } = environment;
104 serverEnvironment.usageSummary({ environmentId: environment.environmentId, input }),109 const query = serverEnvironment.usageSummary({ environmentId, input });
105 );110 void runAtomCommand(
111 appAtomRegistry,
112 serverEnvironment.refreshUsageRates,
113 { environmentId, input: {} },
114 { reportFailure: false },
115 ).finally(() => appAtomRegistry.refresh(query));
106 }116 }
107 }, [environments, windowKey]);117 }, [environments, windowKey]);
108118
docs/user/usage.md+3-1
...@@ -10,4 +10,6 @@ completed-turn record will not appear....@@ -10,4 +10,6 @@ completed-turn record will not appear.
1010
11Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,11Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
12**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the12**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>(...@@ -933,6 +933,14 @@ export function createServerEnvironmentAtoms<R, E>(
933 label: "environment-data:server:signal-process",933 label: "environment-data:server:signal-process",
934 tag: WS_METHODS.serverSignalProcess,934 tag: WS_METHODS.serverSignalProcess,
935 }),935 }),
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 }),
936 retryResourceTelemetry: createEnvironmentRpcCommand(runtime, {944 retryResourceTelemetry: createEnvironmentRpcCommand(runtime, {
937 label: "environment-data:server:retry-resource-telemetry",945 label: "environment-data:server:retry-resource-telemetry",
938 tag: WS_METHODS.serverRetryResourceTelemetry,946 tag: WS_METHODS.serverRetryResourceTelemetry,
packages/contracts/src/rpc.ts+13-1
...@@ -206,7 +206,7 @@ import {...@@ -206,7 +206,7 @@ import {
206 ResourceTelemetryRetryResult,206 ResourceTelemetryRetryResult,
207 ResourceTelemetrySnapshot,207 ResourceTelemetrySnapshot,
208} from "./resourceTelemetry.ts";208} from "./resourceTelemetry.ts";
209import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";209import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
210import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";210import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
211import {211import {
212 SourceControlCloneRepositoryInput,212 SourceControlCloneRepositoryInput,
...@@ -315,6 +315,7 @@ export const WS_METHODS = {...@@ -315,6 +315,7 @@ export const WS_METHODS = {
315 serverReportHostPowerState: "server.reportHostPowerState",315 serverReportHostPowerState: "server.reportHostPowerState",
316 serverGetBackgroundPolicy: "server.getBackgroundPolicy",316 serverGetBackgroundPolicy: "server.getBackgroundPolicy",
317 serverGetUsageSummary: "server.getUsageSummary",317 serverGetUsageSummary: "server.getUsageSummary",
318 serverRefreshUsageRates: "server.refreshUsageRates",
318319
319 // Cloud environment methods320 // Cloud environment methods
320 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",321 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
...@@ -547,6 +548,16 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm...@@ -547,6 +548,16 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm
547 error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]),548 error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]),
548});549});
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
550export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {561export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, {
551 payload: ServerSignalProcessInput,562 payload: ServerSignalProcessInput,
552 success: ServerSignalProcessResult,563 success: ServerSignalProcessResult,
...@@ -1167,6 +1178,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1167,6 +1178,7 @@ export const WsRpcGroup = RpcGroup.make(
1167 WsServerGetResourceTelemetryHistoryRpc,1178 WsServerGetResourceTelemetryHistoryRpc,
1168 WsServerRetryResourceTelemetryRpc,1179 WsServerRetryResourceTelemetryRpc,
1169 WsServerGetUsageSummaryRpc,1180 WsServerGetUsageSummaryRpc,
1181 WsServerRefreshUsageRatesRpc,
1170 WsServerSignalProcessRpc,1182 WsServerSignalProcessRpc,
1171 WsServerReportClientActivityRpc,1183 WsServerReportClientActivityRpc,
1172 WsServerReportHostPowerStateRpc,1184 WsServerReportHostPowerStateRpc,