authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-09-26 20:58:29+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 14:58:29-05:00
log3dae78f33b1e3c7534feb51ca41a407ab2536b83
tree4d51d0bcdd439d373190a3f2fb870070a4af52da
parentd6802b4acfbb6bfb669e0fa225539920622f7670
signature Signed by PGP key B5690EEEBB952194

fix(usage): identify client version mismatches (#8208)


5 files changed, 92 insertions(+), 25 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+7-1
......@@ -15,6 +15,7 @@ import {
1515 formatHourShort,
1616 formatPercent,
1717 formatTokens,
18 formatUsageContractMismatch,
1819 formatUsd,
1920 makeWindow,
2021} from "@t3tools/shared/usageFormat";
......@@ -753,7 +754,12 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
753754 environment.summary &&
754755 !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)
755756 ) {
756 return "Older server · excluded from usage totals";
757 return formatUsageContractMismatch(environment.label, {
758 direction:
759 environment.summary.contractVersion < USAGE_CONTRACT_VERSION
760 ? "serverBehind"
761 : "clientBehind",
762 });
757763 }
758764 if (!environment.isConnected)
759765 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
apps/web/src/components/usage/UsagePage.tsx+19-13
......@@ -20,6 +20,7 @@ import {
2020 isModelCostUnknown,
2121 type DailyTotals,
2222 type HourlyTotals,
23 type MergedUsage,
2324} from "@t3tools/shared/usageMerge";
2425
2526import { isElectron } from "../../env";
......@@ -40,6 +41,7 @@ import {
4041 formatHourShort,
4142 formatPercent,
4243 formatTokens,
44 formatUsageContractMismatch,
4345 formatUsd,
4446 makeWindow,
4547} from "@t3tools/shared/usageFormat";
......@@ -314,7 +316,7 @@ export function UsagePage() {
314316 showUsageStatus={!showingLimits}
315317 isPartial={isPartial}
316318 duplicateSources={merged.duplicateSources}
317 staleEnvironments={merged.staleEnvironments}
319 contractMismatches={merged.contractMismatches}
318320 />
319321 </WorkspaceBreadcrumbItem>
320322 </WorkspaceBreadcrumb>
......@@ -899,17 +901,21 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri
899901function UsageCoverageNotice({
900902 environments,
901903 duplicateSources,
902 staleEnvironments,
904 contractMismatches,
903905}: {
904906 readonly environments: readonly EnvironmentUsageStatus[];
905907 readonly duplicateSources: readonly string[];
906 readonly staleEnvironments: readonly string[];
908 readonly contractMismatches: MergedUsage["contractMismatches"];
907909}) {
908910 const failed = environments.filter((environment) => environment.error !== null);
909 const stale = environments.filter((environment) =>
910 staleEnvironments.includes(environment.environmentId),
911 const mismatchByEnvironment = new Map(
912 contractMismatches.map((mismatch) => [mismatch.environmentId, mismatch]),
911913 );
912 if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) {
914 const incompatible = environments.flatMap((environment) => {
915 const mismatch = mismatchByEnvironment.get(environment.environmentId);
916 return mismatch === undefined ? [] : [{ environment, mismatch }];
917 });
918 if (failed.length === 0 && incompatible.length === 0 && duplicateSources.length === 0) {
913919 return null;
914920 }
915921
......@@ -918,9 +924,9 @@ function UsageCoverageNotice({
918924 {failed.map((environment) => (
919925 <span key={environment.label}>{environment.label} could not report usage.</span>
920926 ))}
921 {stale.map((environment) => (
922 <span key={environment.label}>
923 {environment.label} runs an older server version and is excluded from totals.
927 {incompatible.map(({ environment, mismatch }) => (
928 <span key={environment.environmentId}>
929 {formatUsageContractMismatch(environment.label, mismatch)}
924930 </span>
925931 ))}
926932 {duplicateSources.length > 0 ? (
......@@ -942,7 +948,7 @@ function UsageEnvironmentFilter({
942948 showUsageStatus,
943949 isPartial,
944950 duplicateSources,
945 staleEnvironments,
951 contractMismatches,
946952}: {
947953 readonly environments: readonly EnvironmentUsageStatus[];
948954 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
......@@ -951,7 +957,7 @@ function UsageEnvironmentFilter({
951957 readonly showUsageStatus: boolean;
952958 readonly isPartial: boolean;
953959 readonly duplicateSources: readonly string[];
954 readonly staleEnvironments: readonly string[];
960 readonly contractMismatches: MergedUsage["contractMismatches"];
955961}) {
956962 const [modelPricesOpen, setModelPricesOpen] = useState(false);
957963 const allSelected = selectedEnvironmentIds === null;
......@@ -966,7 +972,7 @@ function UsageEnvironmentFilter({
966972 ).length;
967973 const hasIssue =
968974 selectedEnvironments.some((environment) => environment.error !== null) ||
969 staleEnvironments.length > 0;
975 contractMismatches.length > 0;
970976
971977 return (
972978 <>
......@@ -1066,7 +1072,7 @@ function UsageEnvironmentFilter({
10661072 <UsageCoverageNotice
10671073 environments={selectedEnvironments}
10681074 duplicateSources={duplicateSources}
1069 staleEnvironments={staleEnvironments}
1075 contractMismatches={contractMismatches}
10701076 />
10711077 ) : null}
10721078 <MenuSeparator />
packages/shared/src/usageFormat.ts+11
......@@ -6,6 +6,8 @@
66 */
77import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts";
88
9import type { UsageContractMismatch } from "./usageMerge.ts";
10
911const CURRENCY = new Intl.NumberFormat("en-US", {
1012 style: "currency",
1113 currency: "USD",
......@@ -49,6 +51,15 @@ export function formatPercent(share: number, digits = 1): string {
4951 return `${percent.toFixed(digits)}%`;
5052}
5153
54export function formatUsageContractMismatch(
55 environmentLabel: string,
56 mismatch: Pick<UsageContractMismatch, "direction">,
57): string {
58 return mismatch.direction === "serverBehind"
59 ? `${environmentLabel} runs an older server version and is excluded from totals.`
60 : `This client is older than the server on ${environmentLabel}; its usage is excluded from totals.`;
61}
62
5263/** `2026-08-07` to `Aug 7`. */
5364export function formatDayShort(day: string): string {
5465 const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
packages/shared/src/usageMerge.test.ts+34-3
......@@ -329,7 +329,7 @@ describe("mergeUsage", () => {
329329 expect(merged.contributingEnvironments).toEqual(["old"]);
330330 });
331331
332 it("excludes an environment reporting an older contract version", () => {
332 it("identifies an environment reporting an older contract version", () => {
333333 const merged = mergeUsage(
334334 [
335335 environment(
......@@ -349,7 +349,38 @@ describe("mergeUsage", () => {
349349 );
350350
351351 expect(merged.costUsd).toBe(10);
352 expect(merged.staleEnvironments).toEqual(["env-b"]);
352 expect(merged.contractMismatches).toEqual([
353 {
354 environmentId: "env-b",
355 direction: "serverBehind",
356 contractVersion: USAGE_MERGE_COMPATIBLE_SINCE - 1,
357 },
358 ]);
359 });
360
361 it("identifies an environment reporting a newer contract version", () => {
362 const merged = mergeUsage(
363 [
364 environment(
365 "env-a",
366 summary(
367 [bucket()],
368 [{ provider: "claude", hostId: "mac", homePath: "/a" }],
369 USAGE_CONTRACT_VERSION + 1,
370 ),
371 ),
372 ],
373 USAGE_CONTRACT_VERSION,
374 );
375
376 expect(merged.costUsd).toBe(0);
377 expect(merged.contractMismatches).toEqual([
378 {
379 environmentId: "env-a",
380 direction: "clientBehind",
381 contractVersion: USAGE_CONTRACT_VERSION + 1,
382 },
383 ]);
353384 });
354385
355386 it("keeps the previous compatible contract version so additive provider expansions still merge", () => {
......@@ -375,7 +406,7 @@ describe("mergeUsage", () => {
375406 );
376407
377408 expect(merged.costUsd).toBe(14);
378 expect(merged.staleEnvironments).toEqual([]);
409 expect(merged.contractMismatches).toEqual([]);
379410 });
380411
381412 it("keeps known usage when newer providers and bucket variants cannot be decoded", () => {
packages/shared/src/usageMerge.ts+21-8
......@@ -76,6 +76,12 @@ export interface CostQuality {
7676 readonly cacheSavingsUsd: number;
7777}
7878
79export interface UsageContractMismatch {
80 readonly environmentId: EnvironmentId;
81 readonly direction: "serverBehind" | "clientBehind";
82 readonly contractVersion: number;
83}
84
7985export interface MergedUsage {
8086 readonly costUsd: number;
8187 readonly uncachedInputTokens: number;
......@@ -94,7 +100,7 @@ export interface MergedUsage {
94100 /** Environments whose data was dropped as a duplicate of another's. */
95101 readonly duplicateSources: readonly string[];
96102 readonly contributingEnvironments: readonly EnvironmentId[];
97 readonly staleEnvironments: readonly EnvironmentId[];
103 readonly contractMismatches: readonly UsageContractMismatch[];
98104}
99105
100106/**
......@@ -303,15 +309,15 @@ const EMPTY_MERGED: MergedUsage = {
303309 },
304310 duplicateSources: [],
305311 contributingEnvironments: [],
306 staleEnvironments: [],
312 contractMismatches: [],
307313};
308314
309315/**
310316 * Merges every connected environment's summary.
311317 *
312 * `expectedContractVersion` guards against an environment running older server
313 * code: rather than blocking the page, incompatible data is excluded and its
314 * id is reported so the UI can say coverage is partial. Versions in
318 * `expectedContractVersion` guards against incompatible server code: rather
319 * than blocking the page, its data is excluded and the mismatch direction is
320 * reported so the UI can identify which side needs updating. Versions in
315321 * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive
316322 * provider expansion does not drop Claude/Codex totals from older servers.
317323 */
......@@ -322,14 +328,21 @@ export function mergeUsage(
322328 if (environments.length === 0) return EMPTY_MERGED;
323329
324330 const current: EnvironmentUsage[] = [];
325 const staleEnvironments: EnvironmentId[] = [];
331 const contractMismatches: UsageContractMismatch[] = [];
326332 for (const environment of environments) {
327333 if (
328334 isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion)
329335 ) {
330336 current.push(environment);
331337 } else {
332 staleEnvironments.push(environment.environmentId);
338 contractMismatches.push({
339 environmentId: environment.environmentId,
340 direction:
341 environment.summary.contractVersion < expectedContractVersion
342 ? "serverBehind"
343 : "clientBehind",
344 contractVersion: environment.summary.contractVersion,
345 });
333346 }
334347 }
335348
......@@ -544,6 +557,6 @@ export function mergeUsage(
544557 },
545558 duplicateSources: duplicates,
546559 contributingEnvironments,
547 staleEnvironments,
560 contractMismatches,
548561 };
549562}