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 {...@@ -15,6 +15,7 @@ import {
15 formatHourShort,15 formatHourShort,
16 formatPercent,16 formatPercent,
17 formatTokens,17 formatTokens,
18 formatUsageContractMismatch,
18 formatUsd,19 formatUsd,
19 makeWindow,20 makeWindow,
20} from "@t3tools/shared/usageFormat";21} from "@t3tools/shared/usageFormat";
...@@ -753,7 +754,12 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {...@@ -753,7 +754,12 @@ function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string {
753 environment.summary &&754 environment.summary &&
754 !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)755 !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION)
755 ) {756 ) {
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 });
757 }763 }
758 if (!environment.isConnected)764 if (!environment.isConnected)
759 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";765 return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…";
apps/web/src/components/usage/UsagePage.tsx+19-13
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 isModelCostUnknown,20 isModelCostUnknown,
21 type DailyTotals,21 type DailyTotals,
22 type HourlyTotals,22 type HourlyTotals,
23 type MergedUsage,
23} from "@t3tools/shared/usageMerge";24} from "@t3tools/shared/usageMerge";
2425
25import { isElectron } from "../../env";26import { isElectron } from "../../env";
...@@ -40,6 +41,7 @@ import {...@@ -40,6 +41,7 @@ import {
40 formatHourShort,41 formatHourShort,
41 formatPercent,42 formatPercent,
42 formatTokens,43 formatTokens,
44 formatUsageContractMismatch,
43 formatUsd,45 formatUsd,
44 makeWindow,46 makeWindow,
45} from "@t3tools/shared/usageFormat";47} from "@t3tools/shared/usageFormat";
...@@ -314,7 +316,7 @@ export function UsagePage() {...@@ -314,7 +316,7 @@ export function UsagePage() {
314 showUsageStatus={!showingLimits}316 showUsageStatus={!showingLimits}
315 isPartial={isPartial}317 isPartial={isPartial}
316 duplicateSources={merged.duplicateSources}318 duplicateSources={merged.duplicateSources}
317 staleEnvironments={merged.staleEnvironments}319 contractMismatches={merged.contractMismatches}
318 />320 />
319 </WorkspaceBreadcrumbItem>321 </WorkspaceBreadcrumbItem>
320 </WorkspaceBreadcrumb>322 </WorkspaceBreadcrumb>
...@@ -899,17 +901,21 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri...@@ -899,17 +901,21 @@ function Metric({ label, value }: { readonly label: string; readonly value: stri
899function UsageCoverageNotice({901function UsageCoverageNotice({
900 environments,902 environments,
901 duplicateSources,903 duplicateSources,
902 staleEnvironments,904 contractMismatches,
903}: {905}: {
904 readonly environments: readonly EnvironmentUsageStatus[];906 readonly environments: readonly EnvironmentUsageStatus[];
905 readonly duplicateSources: readonly string[];907 readonly duplicateSources: readonly string[];
906 readonly staleEnvironments: readonly string[];908 readonly contractMismatches: MergedUsage["contractMismatches"];
907}) {909}) {
908 const failed = environments.filter((environment) => environment.error !== null);910 const failed = environments.filter((environment) => environment.error !== null);
909 const stale = environments.filter((environment) =>911 const mismatchByEnvironment = new Map(
910 staleEnvironments.includes(environment.environmentId),912 contractMismatches.map((mismatch) => [mismatch.environmentId, mismatch]),
911 );913 );
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) {
913 return null;919 return null;
914 }920 }
915921
...@@ -918,9 +924,9 @@ function UsageCoverageNotice({...@@ -918,9 +924,9 @@ function UsageCoverageNotice({
918 {failed.map((environment) => (924 {failed.map((environment) => (
919 <span key={environment.label}>{environment.label} could not report usage.</span>925 <span key={environment.label}>{environment.label} could not report usage.</span>
920 ))}926 ))}
921 {stale.map((environment) => (927 {incompatible.map(({ environment, mismatch }) => (
922 <span key={environment.label}>928 <span key={environment.environmentId}>
923 {environment.label} runs an older server version and is excluded from totals.929 {formatUsageContractMismatch(environment.label, mismatch)}
924 </span>930 </span>
925 ))}931 ))}
926 {duplicateSources.length > 0 ? (932 {duplicateSources.length > 0 ? (
...@@ -942,7 +948,7 @@ function UsageEnvironmentFilter({...@@ -942,7 +948,7 @@ function UsageEnvironmentFilter({
942 showUsageStatus,948 showUsageStatus,
943 isPartial,949 isPartial,
944 duplicateSources,950 duplicateSources,
945 staleEnvironments,951 contractMismatches,
946}: {952}: {
947 readonly environments: readonly EnvironmentUsageStatus[];953 readonly environments: readonly EnvironmentUsageStatus[];
948 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];954 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
...@@ -951,7 +957,7 @@ function UsageEnvironmentFilter({...@@ -951,7 +957,7 @@ function UsageEnvironmentFilter({
951 readonly showUsageStatus: boolean;957 readonly showUsageStatus: boolean;
952 readonly isPartial: boolean;958 readonly isPartial: boolean;
953 readonly duplicateSources: readonly string[];959 readonly duplicateSources: readonly string[];
954 readonly staleEnvironments: readonly string[];960 readonly contractMismatches: MergedUsage["contractMismatches"];
955}) {961}) {
956 const [modelPricesOpen, setModelPricesOpen] = useState(false);962 const [modelPricesOpen, setModelPricesOpen] = useState(false);
957 const allSelected = selectedEnvironmentIds === null;963 const allSelected = selectedEnvironmentIds === null;
...@@ -966,7 +972,7 @@ function UsageEnvironmentFilter({...@@ -966,7 +972,7 @@ function UsageEnvironmentFilter({
966 ).length;972 ).length;
967 const hasIssue =973 const hasIssue =
968 selectedEnvironments.some((environment) => environment.error !== null) ||974 selectedEnvironments.some((environment) => environment.error !== null) ||
969 staleEnvironments.length > 0;975 contractMismatches.length > 0;
970976
971 return (977 return (
972 <>978 <>
...@@ -1066,7 +1072,7 @@ function UsageEnvironmentFilter({...@@ -1066,7 +1072,7 @@ function UsageEnvironmentFilter({
1066 <UsageCoverageNotice1072 <UsageCoverageNotice
1067 environments={selectedEnvironments}1073 environments={selectedEnvironments}
1068 duplicateSources={duplicateSources}1074 duplicateSources={duplicateSources}
1069 staleEnvironments={staleEnvironments}1075 contractMismatches={contractMismatches}
1070 />1076 />
1071 ) : null}1077 ) : null}
1072 <MenuSeparator />1078 <MenuSeparator />
packages/shared/src/usageFormat.ts+11
...@@ -6,6 +6,8 @@...@@ -6,6 +6,8 @@
6 */6 */
7import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts";7import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts";
88
9import type { UsageContractMismatch } from "./usageMerge.ts";
10
9const CURRENCY = new Intl.NumberFormat("en-US", {11const CURRENCY = new Intl.NumberFormat("en-US", {
10 style: "currency",12 style: "currency",
11 currency: "USD",13 currency: "USD",
...@@ -49,6 +51,15 @@ export function formatPercent(share: number, digits = 1): string {...@@ -49,6 +51,15 @@ export function formatPercent(share: number, digits = 1): string {
49 return `${percent.toFixed(digits)}%`;51 return `${percent.toFixed(digits)}%`;
50}52}
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
52/** `2026-08-07` to `Aug 7`. */63/** `2026-08-07` to `Aug 7`. */
53export function formatDayShort(day: string): string {64export function formatDayShort(day: string): string {
54 const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));65 const [year, month, dayOfMonth] = day.split("-").map((part) => Number(part));
packages/shared/src/usageMerge.test.ts+34-3
...@@ -329,7 +329,7 @@ describe("mergeUsage", () => {...@@ -329,7 +329,7 @@ describe("mergeUsage", () => {
329 expect(merged.contributingEnvironments).toEqual(["old"]);329 expect(merged.contributingEnvironments).toEqual(["old"]);
330 });330 });
331331
332 it("excludes an environment reporting an older contract version", () => {332 it("identifies an environment reporting an older contract version", () => {
333 const merged = mergeUsage(333 const merged = mergeUsage(
334 [334 [
335 environment(335 environment(
...@@ -349,7 +349,38 @@ describe("mergeUsage", () => {...@@ -349,7 +349,38 @@ describe("mergeUsage", () => {
349 );349 );
350350
351 expect(merged.costUsd).toBe(10);351 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 ]);
353 });384 });
354385
355 it("keeps the previous compatible contract version so additive provider expansions still merge", () => {386 it("keeps the previous compatible contract version so additive provider expansions still merge", () => {
...@@ -375,7 +406,7 @@ describe("mergeUsage", () => {...@@ -375,7 +406,7 @@ describe("mergeUsage", () => {
375 );406 );
376407
377 expect(merged.costUsd).toBe(14);408 expect(merged.costUsd).toBe(14);
378 expect(merged.staleEnvironments).toEqual([]);409 expect(merged.contractMismatches).toEqual([]);
379 });410 });
380411
381 it("keeps known usage when newer providers and bucket variants cannot be decoded", () => {412 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 {...@@ -76,6 +76,12 @@ export interface CostQuality {
76 readonly cacheSavingsUsd: number;76 readonly cacheSavingsUsd: number;
77}77}
7878
79export interface UsageContractMismatch {
80 readonly environmentId: EnvironmentId;
81 readonly direction: "serverBehind" | "clientBehind";
82 readonly contractVersion: number;
83}
84
79export interface MergedUsage {85export interface MergedUsage {
80 readonly costUsd: number;86 readonly costUsd: number;
81 readonly uncachedInputTokens: number;87 readonly uncachedInputTokens: number;
...@@ -94,7 +100,7 @@ export interface MergedUsage {...@@ -94,7 +100,7 @@ export interface MergedUsage {
94 /** Environments whose data was dropped as a duplicate of another's. */100 /** Environments whose data was dropped as a duplicate of another's. */
95 readonly duplicateSources: readonly string[];101 readonly duplicateSources: readonly string[];
96 readonly contributingEnvironments: readonly EnvironmentId[];102 readonly contributingEnvironments: readonly EnvironmentId[];
97 readonly staleEnvironments: readonly EnvironmentId[];103 readonly contractMismatches: readonly UsageContractMismatch[];
98}104}
99105
100/**106/**
...@@ -303,15 +309,15 @@ const EMPTY_MERGED: MergedUsage = {...@@ -303,15 +309,15 @@ const EMPTY_MERGED: MergedUsage = {
303 },309 },
304 duplicateSources: [],310 duplicateSources: [],
305 contributingEnvironments: [],311 contributingEnvironments: [],
306 staleEnvironments: [],312 contractMismatches: [],
307};313};
308314
309/**315/**
310 * Merges every connected environment's summary.316 * Merges every connected environment's summary.
311 *317 *
312 * `expectedContractVersion` guards against an environment running older server318 * `expectedContractVersion` guards against incompatible server code: rather
313 * code: rather than blocking the page, incompatible data is excluded and its319 * than blocking the page, its data is excluded and the mismatch direction is
314 * id is reported so the UI can say coverage is partial. Versions in320 * reported so the UI can identify which side needs updating. Versions in
315 * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive321 * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive
316 * provider expansion does not drop Claude/Codex totals from older servers.322 * provider expansion does not drop Claude/Codex totals from older servers.
317 */323 */
...@@ -322,14 +328,21 @@ export function mergeUsage(...@@ -322,14 +328,21 @@ export function mergeUsage(
322 if (environments.length === 0) return EMPTY_MERGED;328 if (environments.length === 0) return EMPTY_MERGED;
323329
324 const current: EnvironmentUsage[] = [];330 const current: EnvironmentUsage[] = [];
325 const staleEnvironments: EnvironmentId[] = [];331 const contractMismatches: UsageContractMismatch[] = [];
326 for (const environment of environments) {332 for (const environment of environments) {
327 if (333 if (
328 isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion)334 isCompatibleUsageContractVersion(environment.summary.contractVersion, expectedContractVersion)
329 ) {335 ) {
330 current.push(environment);336 current.push(environment);
331 } else {337 } 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 });
333 }346 }
334 }347 }
335348
...@@ -544,6 +557,6 @@ export function mergeUsage(...@@ -544,6 +557,6 @@ export function mergeUsage(
544 },557 },
545 duplicateSources: duplicates,558 duplicateSources: duplicates,
546 contributingEnvironments,559 contributingEnvironments,
547 staleEnvironments,560 contractMismatches,
548 };561 };
549}562}