authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-09-26 20:55:18+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 14:55:18-05:00
log10bb59bf061252f5edadf96bc8139911b3fed7fd
tree01c3332a0f9cd5069904fee213a8a98d5d585289
parent393d595984f66df08504e1659997e12ad551bce9
signature Signed by PGP key B5690EEEBB952194

fix(usage): tolerate newer provider variants (#10076)


3 files changed, 86 insertions(+), 4 deletions(-)

docs/user/usage.md+3
...@@ -31,6 +31,9 @@ variable. Use absolute paths or `~/` paths in the account's environment settings...@@ -31,6 +31,9 @@ variable. Use absolute paths or `~/` paths in the account's environment settings
31environment paths depend on each project's working directory and cannot be reliably discovered31environment paths depend on each project's working directory and cannot be reliably discovered
32by Usage. Accounts sharing a history directory count once.32by Usage. Accounts sharing a history directory count once.
3333
34When your app and server support different providers, usage totals may cover only the providers
35your app understands. Update the app to include newly supported providers.
36
34On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All37On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
35environments are selected by default. The dropdown shows which environments are still scanning;38environments are selected by default. The dropdown shows which environments are still scanning;
36results appear as each one responds.39results appear as each one responds.
packages/contracts/src/usage.ts+5-3
...@@ -11,12 +11,14 @@...@@ -11,12 +11,14 @@
11 */11 */
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
1313
14import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";14import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
1515
16/**16/**
17 * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The17 * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The
18 * client renders partial coverage when an environment reports an older version18 * client renders partial coverage when an environment reports an older version
19 * rather than failing the whole page.19 * rather than failing the whole page.
20 * Adding providers or other array-element variants is additive: unknown
21 * entries are skipped on decode and do not require a version bump.
20 */22 */
21export const USAGE_CONTRACT_VERSION = 6 as const;23export const USAGE_CONTRACT_VERSION = 6 as const;
2224
...@@ -201,8 +203,8 @@ export const UsageSummary = Schema.Struct({...@@ -201,8 +203,8 @@ export const UsageSummary = Schema.Struct({
201 timeZone: TrimmedNonEmptyString,203 timeZone: TrimmedNonEmptyString,
202 sinceDay: UsageDay,204 sinceDay: UsageDay,
203 untilDay: UsageDay,205 untilDay: UsageDay,
204 buckets: Schema.Array(UsageBucket),206 buckets: ForwardCompatibleArray(UsageBucket),
205 sources: Schema.Array(UsageSource),207 sources: ForwardCompatibleArray(UsageSource),
206 pricing: UsagePricing,208 pricing: UsagePricing,
207 /** Wall-clock cost of the scan, surfaced in diagnostics. */209 /** Wall-clock cost of the scan, surfaced in diagnostics. */
208 scanDurationMs: NonNegativeInt,210 scanDurationMs: NonNegativeInt,
packages/shared/src/usageMerge.test.ts+78-1
...@@ -5,12 +5,16 @@ import {...@@ -5,12 +5,16 @@ import {
5 type UsageBucket,5 type UsageBucket,
6 type UsageDay,6 type UsageDay,
7 type UsageProviderKind,7 type UsageProviderKind,
8 type UsageSummary,8 UsageSummary,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { describe, expect, it } from "vite-plus/test";10import { describe, expect, it } from "vite-plus/test";
11import * as Schema from "effect/Schema";
1112
12import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";13import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
1314
15const decodeSummary = Schema.decodeUnknownSync(UsageSummary);
16const encodeSummary = Schema.encodeSync(UsageSummary);
17
14function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {18function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
15 return {19 return {
16 day: "2026-08-07" as UsageDay,20 day: "2026-08-07" as UsageDay,
...@@ -374,6 +378,79 @@ describe("mergeUsage", () => {...@@ -374,6 +378,79 @@ describe("mergeUsage", () => {
374 expect(merged.staleEnvironments).toEqual([]);378 expect(merged.staleEnvironments).toEqual([]);
375 });379 });
376380
381 it("keeps known usage when newer providers and bucket variants cannot be decoded", () => {
382 const known = summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]);
383 const decoded = decodeSummary({
384 ...known,
385 buckets: [
386 ...known.buckets,
387 { ...bucket(), provider: "future-provider", costUsd: 100 },
388 { ...bucket(), costSource: "future-pricing", costUsd: 200 },
389 ],
390 sources: [
391 ...known.sources,
392 {
393 ...known.sources[0],
394 fingerprint: {
395 ...known.sources[0]?.fingerprint,
396 provider: "future-provider",
397 },
398 },
399 ],
400 });
401 expect(decoded).toEqual(known);
402 const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION);
403 expect(merged.costUsd).toBe(10);
404 expect(merged.totalTokens).toBe(1160);
405 expect(merged.staleEnvironments).toEqual([]);
406 });
407
408 it("normalizes model names during decoding before grouping usage", () => {
409 const decoded = decodeSummary(
410 summary(
411 [
412 bucket({ provider: "codex", model: " gpt-5 " }),
413 bucket({ provider: "codex", model: "gpt-5" }),
414 ],
415 [{ provider: "codex", hostId: "mac", homePath: "/a" }],
416 ),
417 );
418
419 expect(decoded.buckets.map((entry) => entry.model)).toEqual(["gpt-5", "gpt-5"]);
420 const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION);
421 expect(merged.models).toHaveLength(1);
422 expect(merged.models[0]).toMatchObject({ model: "gpt-5", costUsd: 20, totalTokens: 2320 });
423 });
424
425 it("keeps all supported providers when encoding a response", () => {
426 const current = summary(
427 [bucket(), bucket({ provider: "grok" })],
428 [
429 { provider: "claude", hostId: "mac", homePath: "/a" },
430 { provider: "grok", hostId: "mac", homePath: "/b" },
431 ],
432 );
433 expect(encodeSummary(current)).toEqual(current);
434 expect(decodeSummary(encodeSummary(current))).toEqual(current);
435 });
436
437 it("still rejects a malformed summary envelope", () => {
438 expect(() => decodeSummary({ ...summary([], []), buckets: null })).toThrow();
439 });
440
441 it("excludes a future incompatible contract even when its buckets still decode", () => {
442 const decoded = decodeSummary(
443 summary(
444 [bucket()],
445 [{ provider: "claude", hostId: "mac", homePath: "/a" }],
446 USAGE_CONTRACT_VERSION + 1,
447 ),
448 );
449 const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION);
450 expect(merged.costUsd).toBe(0);
451 expect(merged.staleEnvironments).toEqual(["env-a"]);
452 });
453
377 it("derives provider shares and cost quality", () => {454 it("derives provider shares and cost quality", () => {
378 const merged = mergeUsage(455 const merged = mergeUsage(
379 [456 [