| author | |
| committer | |
| log | 2b745efe57cddc1753d3007d081c64bd20e3ab79 |
| tree | 1b4b06117c57fef9ec67359fc60db2a383b26e44 |
| parent | 2aa907b1969237efdaced29612fb46ba51be7041 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 19 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 19 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; | 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; |
| 20 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 21 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 22 | import { 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 SWR | 101 | // Refreshing only the derived atom would re-read the per-environment SWR |
| 101 | // queries within their stale window and change nothing. Refresh each | 102 | // 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]); |
| 111 | 121 |
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"; |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 10 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; | 10 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; |
| 11 | import * as Duration from "effect/Duration"; | ||
| 11 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 12 | import * as Exit from "effect/Exit"; | 13 | import * as Exit from "effect/Exit"; |
| 13 | import * as Fiber from "effect/Fiber"; | 14 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 15 | import * as Scheduler from "effect/Scheduler"; | 16 | import * as Scheduler from "effect/Scheduler"; |
| 17 | import * as TestClock from "effect/testing/TestClock"; | ||
| 16 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 18 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 17 | 19 | ||
| 18 | import * as ServerConfig from "../config.ts"; | 20 | import * 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 the | 82 | // 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 | ); |
| 144 | 148 | ||
| 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"; |
| 34 | import * as Option from "effect/Option"; | 35 | import * as Option from "effect/Option"; |
| 35 | import * as Path from "effect/Path"; | 36 | import * as Path from "effect/Path"; |
| 36 | import * as Schema from "effect/Schema"; | 37 | import * as Schema from "effect/Schema"; |
| 38 | import * as Semaphore from "effect/Semaphore"; | ||
| 37 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 39 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 38 | 40 | ||
| 39 | import { ServerConfig } from "../config.ts"; | 41 | import { 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. */ |
| 64 | const RATES_TTL_MS = 24 * 60 * 60 * 1000; | 66 | const RATES_TTL_MS = 24 * 60 * 60 * 1000; |
| 65 | 67 | ||
| 68 | /** An explicit refresh ignores the TTL, but not a table fetched this recently. */ | ||
| 69 | const RATES_REFRESH_FLOOR_MS = 60 * 1000; | ||
| 70 | |||
| 66 | /** | 71 | /** |
| 67 | * Files are filtered by mtime before opening. The slack covers a session whose | 72 | * 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") {} |
| 99 | 106 | ||
| 107 | const 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. */ |
| 101 | export const layerTest = Layer.succeed( | 115 | export 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 | ); |
| 123 | 133 | ||
| ... | @@ -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 | }); | ||
| 140 | 161 | ||
| 141 | /** | 162 | /** |
| 142 | * Loads the LiteLLM rate table, preferring a fresh copy and falling back to | 163 | * 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 rather | 164 | * 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; | ||
| 149 | 172 | ||
| 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 | }); |
| 191 | 214 | ||
| 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 default | 223 | * 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 table | 455 | // Pricing only matters once records are aggregated, so the rate table |
| 426 | // loads while transcripts stream instead of gating them: a cold rates | 456 | // 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 | }); |
| 431 | 461 | ||
| ... | @@ -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 | }); |
| 572 | 594 | ||
| 573 | return { readSummary } as const; | 595 | return { readSummary, refreshRates } as const; |
| 574 | }); | 596 | }); |
| 575 | 597 | ||
| 576 | export const layer = Layer.effect(UsageService, make); | 598 | export 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 | }); |
| 33 | 33 | ||
| 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 | } |
| 121 | 121 | ||
| 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 | */ | ||
| 127 | function 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 | ]); |
| 137 | 147 | ||
| 138 | export function lookupRate(table: RateTable, model: string): ModelRate | null { | 148 | export 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"; |
| 16 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | ||
| 16 | import * as Option from "effect/Option"; | 17 | import * as Option from "effect/Option"; |
| 17 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 18 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 18 | import { useCallback, useMemo } from "react"; | 19 | import { 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 SWR | 98 | // Refreshing only the derived atom would re-read the per-environment SWR |
| 98 | // queries within their stale window and change nothing. Refresh each | 99 | // 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]); |
| 108 | 118 |
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. |
| 10 | 10 | ||
| 11 | Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, | 11 | Use **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 the | 12 | **30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the |
| 13 | headline and chart, and refreshing rescans every connected environment. | 13 | headline and chart. Refreshing rescans every connected environment and refetches model pricing on |
| 14 | each of them, so a newly released model that showed $0.00 gets a price without waiting for the daily | ||
| 15 | pricing 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"; |
| 209 | import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; | 209 | import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; |
| 210 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; | 210 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 211 | import { | 211 | import { |
| 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", | ||
| 318 | 319 | ||
| 319 | // Cloud environment methods | 320 | // 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 | }); |
| 549 | 550 | ||
| 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 | */ | ||
| 555 | export const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, { | ||
| 556 | payload: Schema.Struct({}), | ||
| 557 | success: UsagePricing, | ||
| 558 | error: EnvironmentAuthorizationError, | ||
| 559 | }); | ||
| 560 | |||
| 550 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { | 561 | export 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, |