| 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 | 16 | type UsageSummary, |
| 17 | 17 | type UsageSummaryInput, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 19 | 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; |
| 20 | 21 | import * as Option from "effect/Option"; |
| 21 | 22 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -100,12 +101,21 @@ export function useUsage(input: UsageSummaryInput): UsageView { |
| 100 | 101 | // Refreshing only the derived atom would re-read the per-environment SWR |
| 101 | 102 | // queries within their stale window and change nothing. Refresh each |
| 102 | 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 | 108 | const refresh = useCallback(() => { |
| 104 | 109 | const input = JSON.parse(windowKey) as UsageSummaryInput; |
| 105 | 110 | 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)); | |
| 109 | 119 | } |
| 110 | 120 | }, [environments, windowKey]); |
| 111 | 121 |
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -56,6 +56,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 56 | 56 | [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, |
| 57 | 57 | [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, |
| 58 | 58 | [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, |
| 59 | [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, | |
| 59 | 60 | [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, |
| 60 | 61 | [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, |
| 61 | 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 | 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 10 | 10 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; |
| 11 | import * as Duration from "effect/Duration"; | |
| 11 | 12 | import * as Effect from "effect/Effect"; |
| 12 | 13 | import * as Exit from "effect/Exit"; |
| 13 | 14 | import * as Fiber from "effect/Fiber"; |
| 14 | 15 | import * as Layer from "effect/Layer"; |
| 15 | 16 | import * as Scheduler from "effect/Scheduler"; |
| 17 | import * as TestClock from "effect/testing/TestClock"; | |
| 16 | 18 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 17 | 19 | |
| 18 | 20 | import * as ServerConfig from "../config.ts"; |
| ... | ... | @@ -65,6 +67,8 @@ const serviceLayers = (input: { |
| 65 | 67 | readonly home: string; |
| 66 | 68 | readonly settings: Parameters<typeof ServerSettings.layerTest>[0]; |
| 67 | 69 | readonly onRatesFetch?: () => void; |
| 70 | /** Defaults to an unparsable document so every scan retries the fetch. */ | |
| 71 | readonly ratesDocument?: unknown; | |
| 68 | 72 | }) => |
| 69 | 73 | ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( |
| 70 | 74 | Layer.provideMerge(NodeServices.layer), |
| ... | ... | @@ -77,7 +81,7 @@ const serviceLayers = (input: { |
| 77 | 81 | input.onRatesFetch?.(); |
| 78 | 82 | // Unparsable rates: every scan retries the fetch, which makes the |
| 79 | 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 | 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 | 191 | it.live("does not orphan an in-flight scan when its first caller is interrupted", () => |
| 146 | 192 | Effect.gen(function* () { |
| 147 | 193 | const { settings, home } = yield* setup; |
apps/server/src/usage/UsageService.ts+44-22| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | USAGE_CONTRACT_VERSION, |
| 19 | 19 | type UsageProviderKind, |
| 20 | 20 | type UsageSource, |
| 21 | type UsagePricing, | |
| 21 | 22 | type UsageSummary, |
| 22 | 23 | type UsageSummaryInput, |
| 23 | 24 | UsageReadError, |
| ... | ... | @@ -34,6 +35,7 @@ import * as Layer from "effect/Layer"; |
| 34 | 35 | import * as Option from "effect/Option"; |
| 35 | 36 | import * as Path from "effect/Path"; |
| 36 | 37 | import * as Schema from "effect/Schema"; |
| 38 | import * as Semaphore from "effect/Semaphore"; | |
| 37 | 39 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 38 | 40 | |
| 39 | 41 | import { ServerConfig } from "../config.ts"; |
| ... | ... | @@ -63,6 +65,9 @@ const LITELLM_RATES_URL = |
| 63 | 65 | /** Rates move rarely; a day-old table keeps the page working offline. */ |
| 64 | 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 | 72 | * Files are filtered by mtime before opening. The slack covers a session whose |
| 68 | 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 | 99 | UsageService, |
| 95 | 100 | { |
| 96 | 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 | 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 | 114 | /** Empty summary, for suites that only need the RPC surface to resolve. */ |
| 101 | 115 | export const layerTest = Layer.succeed( |
| 102 | 116 | UsageService, |
| ... | ... | @@ -110,14 +124,10 @@ export const layerTest = Layer.succeed( |
| 110 | 124 | untilDay: input.untilDay, |
| 111 | 125 | buckets: [], |
| 112 | 126 | sources: [], |
| 113 | pricing: { | |
| 114 | status: "unavailable", | |
| 115 | source: LITELLM_RATES_URL, | |
| 116 | fetchedAt: null, | |
| 117 | knownModels: 0, | |
| 118 | }, | |
| 127 | pricing: EMPTY_PRICING, | |
| 119 | 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 | 146 | const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); |
| 137 | 147 | let rates: RateTable = new Map(); |
| 138 | 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 | 163 | * Loads the LiteLLM rate table, preferring a fresh copy and falling back to |
| 143 | 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 | 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 | 173 | if (ratesFetchedAtMs === null) { |
| 151 | 174 | const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( |
| ... | ... | @@ -158,7 +181,7 @@ export const make = Effect.gen(function* () { |
| 158 | 181 | rates = parsed; |
| 159 | 182 | ratesFetchedAtMs = fromDisk.fetchedAtMs; |
| 160 | 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 | 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 | 223 | * Claude's config dir is the home itself when overridden, but a default |
| 194 | 224 | * install nests transcripts under `~/.claude/projects`. Probe both. |
| ... | ... | @@ -425,7 +455,7 @@ export const make = Effect.gen(function* () { |
| 425 | 455 | // Pricing only matters once records are aggregated, so the rate table |
| 426 | 456 | // loads while transcripts stream instead of gating them: a cold rates |
| 427 | 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 | 459 | concurrency: 2, |
| 430 | 460 | }); |
| 431 | 461 | |
| ... | ... | @@ -511,15 +541,7 @@ export const make = Effect.gen(function* () { |
| 511 | 541 | untilDay: input.untilDay, |
| 512 | 542 | buckets: aggregated.buckets, |
| 513 | 543 | 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(), | |
| 523 | 545 | scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), |
| 524 | 546 | } satisfies UsageSummary; |
| 525 | 547 | }); |
| ... | ... | @@ -570,7 +592,7 @@ export const make = Effect.gen(function* () { |
| 570 | 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 | 598 | export const layer = Layer.effect(UsageService, make); |
apps/server/src/usage/usagePricing.test.ts+9| ... | ... | @@ -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 | 43 | it("adds a bare alias when every qualified entry has the same rate", () => { |
| 35 | 44 | const table = parseRateTable({ |
| 36 | 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 | 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 | 133 | * Models we never price, regardless of the table. |
| 124 | 134 | * |
| ... | ... | @@ -136,7 +146,7 @@ const UNPRICEABLE_MODELS = new Set([ |
| 136 | 146 | ]); |
| 137 | 147 | |
| 138 | 148 | export function lookupRate(table: RateTable, model: string): ModelRate | null { |
| 139 | const key = normalizeRateKey(model); | |
| 149 | const key = stripVariantSuffix(normalizeRateKey(model)); | |
| 140 | 150 | const bareName = bareModelName(key); |
| 141 | 151 | if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; |
| 142 | 152 | return table.get(key) ?? null; |
apps/server/src/ws.ts+4| ... | ... | @@ -1899,6 +1899,10 @@ const makeWsRpcLayer = ( |
| 1899 | 1899 | observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { |
| 1900 | 1900 | "rpc.aggregate": "server", |
| 1901 | 1901 | }), |
| 1902 | [WS_METHODS.serverRefreshUsageRates]: (_input) => | |
| 1903 | observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, { | |
| 1904 | "rpc.aggregate": "server", | |
| 1905 | }), | |
| 1902 | 1906 | [WS_METHODS.serverRetryResourceTelemetry]: (_input) => |
| 1903 | 1907 | observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { |
| 1904 | 1908 | "rpc.aggregate": "server", |
apps/web/src/state/usage.ts+13-3| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | type UsageSummary, |
| 14 | 14 | type UsageSummaryInput, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; | |
| 16 | 17 | import * as Option from "effect/Option"; |
| 17 | 18 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 18 | 19 | import { useCallback, useMemo } from "react"; |
| ... | ... | @@ -97,12 +98,21 @@ export function useUsage(input: UsageSummaryInput): UsageView { |
| 97 | 98 | // Refreshing only the derived atom would re-read the per-environment SWR |
| 98 | 99 | // queries within their stale window and change nothing. Refresh each |
| 99 | 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 | 105 | const refresh = useCallback(() => { |
| 101 | 106 | const input = JSON.parse(windowKey) as UsageSummaryInput; |
| 102 | 107 | 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)); | |
| 106 | 116 | } |
| 107 | 117 | }, [environments, windowKey]); |
| 108 | 118 |
docs/user/usage.md+3-1| ... | ... | @@ -10,4 +10,6 @@ completed-turn record will not appear. |
| 10 | 10 | |
| 11 | 11 | Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, |
| 12 | 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 | 933 | label: "environment-data:server:signal-process", |
| 934 | 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 | 944 | retryResourceTelemetry: createEnvironmentRpcCommand(runtime, { |
| 937 | 945 | label: "environment-data:server:retry-resource-telemetry", |
| 938 | 946 | tag: WS_METHODS.serverRetryResourceTelemetry, |
packages/contracts/src/rpc.ts+13-1| ... | ... | @@ -206,7 +206,7 @@ import { |
| 206 | 206 | ResourceTelemetryRetryResult, |
| 207 | 207 | ResourceTelemetrySnapshot, |
| 208 | 208 | } from "./resourceTelemetry.ts"; |
| 209 | import { UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; | |
| 209 | import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; | |
| 210 | 210 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 211 | 211 | import { |
| 212 | 212 | SourceControlCloneRepositoryInput, |
| ... | ... | @@ -315,6 +315,7 @@ export const WS_METHODS = { |
| 315 | 315 | serverReportHostPowerState: "server.reportHostPowerState", |
| 316 | 316 | serverGetBackgroundPolicy: "server.getBackgroundPolicy", |
| 317 | 317 | serverGetUsageSummary: "server.getUsageSummary", |
| 318 | serverRefreshUsageRates: "server.refreshUsageRates", | |
| 318 | 319 | |
| 319 | 320 | // Cloud environment methods |
| 320 | 321 | cloudGetRelayClientStatus: "cloud.getRelayClientStatus", |
| ... | ... | @@ -547,6 +548,16 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm |
| 547 | 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 | 561 | export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { |
| 551 | 562 | payload: ServerSignalProcessInput, |
| 552 | 563 | success: ServerSignalProcessResult, |
| ... | ... | @@ -1167,6 +1178,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1167 | 1178 | WsServerGetResourceTelemetryHistoryRpc, |
| 1168 | 1179 | WsServerRetryResourceTelemetryRpc, |
| 1169 | 1180 | WsServerGetUsageSummaryRpc, |
| 1181 | WsServerRefreshUsageRatesRpc, | |
| 1170 | 1182 | WsServerSignalProcessRpc, |
| 1171 | 1183 | WsServerReportClientActivityRpc, |
| 1172 | 1184 | WsServerReportHostPowerStateRpc, |