diff --git a/docs/user/usage.md b/docs/user/usage.md index dc55aba41023c8988fd91ddb30ae20684fa4ce5b..64f392d6e941da317d75882e5fb19bc3a1d99775 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -31,6 +31,9 @@ variable. Use absolute paths or `~/` paths in the account's environment settings environment paths depend on each project's working directory and cannot be reliably discovered by Usage. Accounts sharing a history directory count once. +When your app and server support different providers, usage totals may cover only the providers +your app understands. Update the app to include newly supported providers. + On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; results appear as each one responds. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index a6431330558a040530b1632c91f08e06c099f4a0..97db8594379a7b3b8402dc1c7c9c6e3b14c21e95 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -11,12 +11,14 @@ */ import * as Schema from "effect/Schema"; -import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. The * client renders partial coverage when an environment reports an older version * rather than failing the whole page. + * Adding providers or other array-element variants is additive: unknown + * entries are skipped on decode and do not require a version bump. */ export const USAGE_CONTRACT_VERSION = 6 as const; @@ -201,8 +203,8 @@ export const UsageSummary = Schema.Struct({ timeZone: TrimmedNonEmptyString, sinceDay: UsageDay, untilDay: UsageDay, - buckets: Schema.Array(UsageBucket), - sources: Schema.Array(UsageSource), + buckets: ForwardCompatibleArray(UsageBucket), + sources: ForwardCompatibleArray(UsageSource), pricing: UsagePricing, /** Wall-clock cost of the scan, surfaced in diagnostics. */ scanDurationMs: NonNegativeInt, diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 178282238482c6929e80db333d4700091b78a719..d7fc15144b7ce3c4924eed079c09b95e2ffd91da 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -5,12 +5,16 @@ import { type UsageBucket, type UsageDay, type UsageProviderKind, - type UsageSummary, + UsageSummary, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +const decodeSummary = Schema.decodeUnknownSync(UsageSummary); +const encodeSummary = Schema.encodeSync(UsageSummary); + function bucket(overrides: Partial = {}): UsageBucket { return { day: "2026-08-07" as UsageDay, @@ -374,6 +378,79 @@ describe("mergeUsage", () => { expect(merged.staleEnvironments).toEqual([]); }); + it("keeps known usage when newer providers and bucket variants cannot be decoded", () => { + const known = summary([bucket()], [{ provider: "claude", hostId: "mac", homePath: "/a" }]); + const decoded = decodeSummary({ + ...known, + buckets: [ + ...known.buckets, + { ...bucket(), provider: "future-provider", costUsd: 100 }, + { ...bucket(), costSource: "future-pricing", costUsd: 200 }, + ], + sources: [ + ...known.sources, + { + ...known.sources[0], + fingerprint: { + ...known.sources[0]?.fingerprint, + provider: "future-provider", + }, + }, + ], + }); + expect(decoded).toEqual(known); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.totalTokens).toBe(1160); + expect(merged.staleEnvironments).toEqual([]); + }); + + it("normalizes model names during decoding before grouping usage", () => { + const decoded = decodeSummary( + summary( + [ + bucket({ provider: "codex", model: " gpt-5 " }), + bucket({ provider: "codex", model: "gpt-5" }), + ], + [{ provider: "codex", hostId: "mac", homePath: "/a" }], + ), + ); + + expect(decoded.buckets.map((entry) => entry.model)).toEqual(["gpt-5", "gpt-5"]); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.models).toHaveLength(1); + expect(merged.models[0]).toMatchObject({ model: "gpt-5", costUsd: 20, totalTokens: 2320 }); + }); + + it("keeps all supported providers when encoding a response", () => { + const current = summary( + [bucket(), bucket({ provider: "grok" })], + [ + { provider: "claude", hostId: "mac", homePath: "/a" }, + { provider: "grok", hostId: "mac", homePath: "/b" }, + ], + ); + expect(encodeSummary(current)).toEqual(current); + expect(decodeSummary(encodeSummary(current))).toEqual(current); + }); + + it("still rejects a malformed summary envelope", () => { + expect(() => decodeSummary({ ...summary([], []), buckets: null })).toThrow(); + }); + + it("excludes a future incompatible contract even when its buckets still decode", () => { + const decoded = decodeSummary( + summary( + [bucket()], + [{ provider: "claude", hostId: "mac", homePath: "/a" }], + USAGE_CONTRACT_VERSION + 1, + ), + ); + const merged = mergeUsage([environment("env-a", decoded)], USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(0); + expect(merged.staleEnvironments).toEqual(["env-a"]); + }); + it("derives provider shares and cost quality", () => { const merged = mergeUsage( [