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
3131environment paths depend on each project's working directory and cannot be reliably discovered
3232by 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
3437On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
3538environments are selected by default. The dropdown shows which environments are still scanning;
3639results appear as each one responds.
packages/contracts/src/usage.ts+5-3
......@@ -11,12 +11,14 @@
1111 */
1212import * as Schema from "effect/Schema";
1313
14import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
14import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
1515
1616/**
1717 * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The
1818 * client renders partial coverage when an environment reports an older version
1919 * 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.
2022 */
2123export const USAGE_CONTRACT_VERSION = 6 as const;
2224
......@@ -201,8 +203,8 @@ export const UsageSummary = Schema.Struct({
201203 timeZone: TrimmedNonEmptyString,
202204 sinceDay: UsageDay,
203205 untilDay: UsageDay,
204 buckets: Schema.Array(UsageBucket),
205 sources: Schema.Array(UsageSource),
206 buckets: ForwardCompatibleArray(UsageBucket),
207 sources: ForwardCompatibleArray(UsageSource),
206208 pricing: UsagePricing,
207209 /** Wall-clock cost of the scan, surfaced in diagnostics. */
208210 scanDurationMs: NonNegativeInt,
packages/shared/src/usageMerge.test.ts+78-1
......@@ -5,12 +5,16 @@ import {
55 type UsageBucket,
66 type UsageDay,
77 type UsageProviderKind,
8 type UsageSummary,
8 UsageSummary,
99} from "@t3tools/contracts";
1010import { describe, expect, it } from "vite-plus/test";
11import * as Schema from "effect/Schema";
1112
1213import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts";
1314
15const decodeSummary = Schema.decodeUnknownSync(UsageSummary);
16const encodeSummary = Schema.encodeSync(UsageSummary);
17
1418function bucket(overrides: Partial<UsageBucket> = {}): UsageBucket {
1519 return {
1620 day: "2026-08-07" as UsageDay,
......@@ -374,6 +378,79 @@ describe("mergeUsage", () => {
374378 expect(merged.staleEnvironments).toEqual([]);
375379 });
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
377454 it("derives provider shares and cost quality", () => {
378455 const merged = mergeUsage(
379456 [