| author | |
| committer | |
| log | 3dae78f33b1e3c7534feb51ca41a407ab2536b83 |
| tree | 4d51d0bcdd439d373190a3f2fb870070a4af52da |
| parent | d6802b4acfbb6bfb669e0fa225539920622f7670 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 24 | 25 | ||
| 25 | import { isElectron } from "../../env"; | 26 | import { 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 |
| 899 | function UsageCoverageNotice({ | 901 | function 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 | } |
| 915 | 921 | ||
| ... | @@ -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; |
| 970 | 976 | ||
| 971 | return ( | 977 | return ( |
| 972 | <> | 978 | <> |
| ... | @@ -1066,7 +1072,7 @@ function UsageEnvironmentFilter({ | ... | @@ -1066,7 +1072,7 @@ function UsageEnvironmentFilter({ |
| 1066 | <UsageCoverageNotice | 1072 | <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 | */ |
| 7 | import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; | 7 | import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; |
| 8 | 8 | ||
| 9 | import type { UsageContractMismatch } from "./usageMerge.ts"; | ||
| 10 | |||
| 9 | const CURRENCY = new Intl.NumberFormat("en-US", { | 11 | const 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 | } |
| 51 | 53 | ||
| 54 | export 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`. */ |
| 53 | export function formatDayShort(day: string): string { | 64 | export 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 | }); |
| 331 | 331 | ||
| 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 | ); |
| 350 | 350 | ||
| 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 | }); |
| 354 | 385 | ||
| 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 | ); |
| 376 | 407 | ||
| 377 | expect(merged.costUsd).toBe(14); | 408 | expect(merged.costUsd).toBe(14); |
| 378 | expect(merged.staleEnvironments).toEqual([]); | 409 | expect(merged.contractMismatches).toEqual([]); |
| 379 | }); | 410 | }); |
| 380 | 411 | ||
| 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 | } |
| 78 | 78 | ||
| 79 | export interface UsageContractMismatch { | ||
| 80 | readonly environmentId: EnvironmentId; | ||
| 81 | readonly direction: "serverBehind" | "clientBehind"; | ||
| 82 | readonly contractVersion: number; | ||
| 83 | } | ||
| 84 | |||
| 79 | export interface MergedUsage { | 85 | export 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 | } |
| 99 | 105 | ||
| 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 | }; |
| 308 | 314 | ||
| 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 server | 318 | * `expectedContractVersion` guards against incompatible server code: rather |
| 313 | * code: rather than blocking the page, incompatible data is excluded and its | 319 | * 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 in | 320 | * reported so the UI can identify which side needs updating. Versions in |
| 315 | * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive | 321 | * [{@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; |
| 323 | 329 | ||
| 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 | } |
| 335 | 348 | ||
| ... | @@ -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 | } |