From 8a3e8f3e0149431db45437e2c660031d5e75ea97 Mon Sep 17 00:00:00 2001 From: Tan Vir Date: Wed, 9 Sep 2026 04:37:42 +0600 Subject: [PATCH 1/4] fix(usage): keep same-email Codex subscriptions apart when their plans differ A single ChatGPT email can hold a personal Plus subscription and belong to a Business workspace at once, but the Limits view collapsed both into one account because accountKey() only looked at driver and email. Identity now also compares the plan string each side already carries (native provider.auth.label, hub account.plan) via a shared accountGroupKey() helper, splitting the two subscriptions apart while an unset plan on either side still merges as before, so one subscription reported natively and by a hub, or natively on two environments, keeps deduping the same way it always has. Co-Authored-By: Claude Fable 5.1 --- packages/shared/src/usageLimits.test.ts | 79 ++++++++++++++++++++ packages/shared/src/usageLimits.ts | 98 +++++++++++++++++-------- 2 files changed, 145 insertions(+), 32 deletions(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b814e66da459..c07630371f38 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -238,6 +238,18 @@ describe("collectLimitSources", () => { ).toEqual(accounts); }); + it("does not hide a hub account whose plan differs from the native subscription", () => { + const plusNative = { + ...native, + auth: { ...native.auth, label: "ChatGPT Plus Subscription" }, + }; + const businessAccount = { ...account, plan: "ChatGPT Business Subscription" }; + + expect(collectLimitSources(presentations([plusNative], [businessAccount]))).toMatchObject([ + { accounts: [businessAccount], hiddenAccountCount: 0 }, + ]); + }); + it.each([ { enabled: false }, { installed: false }, @@ -389,6 +401,73 @@ describe("pools", () => { expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); }); + it("still merges same-email accounts when both sides report the same plan", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com", label: "Claude Subscription" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + plan: "Claude Subscription", + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + expect(collectLimitAccounts(input)).toHaveLength(1); + }); + + it("keeps same-email Codex instances apart when their plans differ", () => { + const plus = provider({ + instanceId: ProviderInstanceId.make("codex-plus"), + auth: { + status: "authenticated", + email: "same@example.com", + label: "ChatGPT Plus Subscription", + }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 20 }] }, + }); + const business = provider({ + instanceId: ProviderInstanceId.make("codex-business"), + auth: { + status: "authenticated", + email: "same@example.com", + label: "ChatGPT Business Subscription", + }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 70 }] }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [plus, business] } }], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(2); + const byPlan = new Map(accounts.map((entry) => [entry.plan, entry])); + expect(byPlan.get("ChatGPT Plus Subscription")?.limits.windows[0]?.usedPercent).toBe(20); + expect(byPlan.get("ChatGPT Business Subscription")?.limits.windows[0]?.usedPercent).toBe(70); + }); + it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { const native = provider({ driver: claude, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5c32cc0343b7..f415bcb4e0db 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -100,16 +100,17 @@ export function collectLimitSources( } > { const nativeAccounts = new Set(); + const knownPlans = new Map(); for (const presentation of presentations.values()) { for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { - const key = accountKey(provider.driver, provider.auth.email); - if ( - key !== null && - provider.usageLimits?.windows.length && - !provider.usageLimits.unavailable - ) { - nativeAccounts.add(key); - } + if (!provider.usageLimits?.windows.length || provider.usageLimits.unavailable) continue; + const key = accountGroupKey( + provider.driver, + provider.auth.email, + provider.auth.label, + knownPlans, + ); + if (key !== null) nativeAccounts.add(key); } } const perEnvironment: Array<{ @@ -130,7 +131,7 @@ export function collectLimitSources( return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) => sources.map((source) => { const accounts = source.accounts.filter((account) => { - const key = accountKey(account.driver, account.email); + const key = accountGroupKey(account.driver, account.email, account.plan, knownPlans); return key === null || !nativeAccounts.has(key); }); return { @@ -150,11 +151,39 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) return normalizedEmail ? `${driver}:${normalizedEmail}` : null; } +/** + * accountKey, split further when two same-email accounts carry different plan + * strings -- a personal ChatGPT Plus subscription and a Business workspace + * behind the same address are two independent quotas, not one. An unset plan + * on either side does not split the group: callers do not always know a + * subscription's plan, and treating unknown as a wildcard keeps the existing + * native/hub and multi-environment dedupe intact. `knownPlans` records the + * first plan seen per base key; pass one shared map per collection pass so + * every account examined agrees on which group is "the" unlabeled one. + */ +function accountGroupKey( + driver: ServerProvider["driver"], + email: string | undefined, + plan: string | undefined, + knownPlans: Map, +): string | null { + const base = accountKey(driver, email); + if (base === null || plan === undefined) return base; + const knownPlan = knownPlans.get(base); + if (knownPlan === undefined) { + knownPlans.set(base, plan); + return base; + } + return knownPlan === plan ? base : `${base}::${plan}`; +} + /** * One subscription account as the pooled views see it, whichever way it was * reported. The same email signed in natively on two environments, or reported * by a hub as well as natively, is one account: its quota is one bucket, so - * counting it twice would misstate what is left. + * counting it twice would misstate what is left. The same email can also hold + * two independent subscriptions -- a personal plan and a Business workspace, + * say -- which stay two accounts because their plan strings disagree. */ export interface LimitAccount { readonly key: string; @@ -190,6 +219,7 @@ export function collectLimitAccounts( const accounts = new Map(); const creditSources = new Map(); const hubRedeems = new Map(); + const knownPlans = new Map(); const merge = (key: string, next: LimitAccount) => { // Redeeming through a hub also clears the routing cooldown that hub holds // for the account. Redeeming natively against the same subscription resets @@ -254,7 +284,7 @@ export function collectLimitAccounts( for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; merge( - accountKey(provider.driver, provider.auth.email) ?? + accountGroupKey(provider.driver, provider.auth.email, provider.auth.label, knownPlans) ?? `${environmentId}:${provider.instanceId}`, { key: `${environmentId}:${provider.instanceId}`, @@ -282,27 +312,31 @@ export function collectLimitAccounts( : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { - key: `${source.id}:${account.id}`, - driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), - email: account.email, - plan: account.plan, - accentColor: undefined, - environments: [], - sourceLabel, - redeem: account.usageLimits.resetCredits?.nextCreditId - ? { - environmentId, - input: { - sourceId: source.id, - accountId: account.id, - creditId: account.usageLimits.resetCredits.nextCreditId, - }, - } - : null, - limits: account.usageLimits, - }); + merge( + accountGroupKey(account.driver, account.email, account.plan, knownPlans) ?? + `${source.id}:${account.id}`, + { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: account.usageLimits.resetCredits?.nextCreditId + ? { + environmentId, + input: { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + }, + } + : null, + limits: account.usageLimits, + }, + ); } } } From 2d059ced3703fd40435e5200bbf9e9ae2a42e410 Mon Sep 17 00:00:00 2001 From: Tan Vir Date: Wed, 9 Sep 2026 04:40:43 +0600 Subject: [PATCH 2/4] fix(usage): keep the hub merge call site unchanged to shrink the diff Co-Authored-By: Claude Fable 5.1 --- packages/shared/src/usageLimits.ts | 47 +++++++++++++++--------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index f415bcb4e0db..95e3ad4af300 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -312,31 +312,30 @@ export function collectLimitAccounts( : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge( + const key = accountGroupKey(account.driver, account.email, account.plan, knownPlans) ?? - `${source.id}:${account.id}`, - { - key: `${source.id}:${account.id}`, - driver: account.driver, - displayName: account.email ? null : account.id.replace(/\.json$/i, ""), - email: account.email, - plan: account.plan, - accentColor: undefined, - environments: [], - sourceLabel, - redeem: account.usageLimits.resetCredits?.nextCreditId - ? { - environmentId, - input: { - sourceId: source.id, - accountId: account.id, - creditId: account.usageLimits.resetCredits.nextCreditId, - }, - } - : null, - limits: account.usageLimits, - }, - ); + `${source.id}:${account.id}`; + merge(key, { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: account.usageLimits.resetCredits?.nextCreditId + ? { + environmentId, + input: { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + }, + } + : null, + limits: account.usageLimits, + }); } } } From 38e53e6963957cef7c90dc8691c91b5b0a3f079b Mon Sep 17 00:00:00 2001 From: Tan Vir Date: Wed, 9 Sep 2026 04:47:41 +0600 Subject: [PATCH 3/4] fix(usage): stop the hub claiming a Claude plan it cannot know The hub's Anthropic OAuth usage read carries no subscription tier, but the adapter labelled every Claude account "Claude Subscription". Once the plan label is part of the pooled account identity, that placeholder can never match a native "Claude Max Subscription" and would split one subscription reported natively and by a hub into two accounts. The hub now omits the plan for Claude accounts, so its read merges with the native tier as before. The same-plan merge test uses a Codex pair, where both sides share codexPlanLabel, and a new test pins the native Claude tier plus tierless hub read case. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/usage/cliproxyApi.ts | 4 +- packages/shared/src/usageLimits.test.ts | 52 +++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 6bfd3fe4772e..f867e843d3ff 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -221,9 +221,11 @@ export const makeCliproxyApi = Effect.gen(function* () { ] : [], ); + // The OAuth usage read carries no subscription tier, and the pooled + // views treat a plan label as part of the account's identity, so the + // hub claims none rather than a placeholder a native tier would not match. return { ...base, - plan: "Claude Subscription", usageLimits: claudeUsageResponseToLimits({ checkedAt, response: { diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index c07630371f38..5e2564451fae 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -402,10 +402,54 @@ describe("pools", () => { }); it("still merges same-email accounts when both sides report the same plan", () => { + const native = provider({ + auth: { + status: "authenticated", + email: "same@example.com", + label: "ChatGPT Plus Subscription", + }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "codex-same@example.com.json", + driver: ProviderDriverKind.make("codex"), + email: "same@example.com", + plan: "ChatGPT Plus Subscription", + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + expect(collectLimitAccounts(input)).toHaveLength(1); + }); + + it("merges a native Claude tier with the hub's tierless read of the same account", () => { const native = provider({ driver: claude, instanceId: ProviderInstanceId.make("claude"), - auth: { status: "authenticated", email: "same@example.com", label: "Claude Subscription" }, + auth: { + status: "authenticated", + email: "same@example.com", + label: "Claude Max Subscription", + }, usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, }); const input = new Map([ @@ -423,7 +467,6 @@ describe("pools", () => { id: "claude-same@example.com.json", driver: claude, email: "same@example.com", - plan: "Claude Subscription", usageLimits: { checkedAt: "2026-09-03T11:30:00.000Z", windows: [{ ...window, usedPercent: 55 }], @@ -436,7 +479,10 @@ describe("pools", () => { }, ], ]); - expect(collectLimitAccounts(input)).toHaveLength(1); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.plan).toBe("Claude Max Subscription"); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); }); it("keeps same-email Codex instances apart when their plans differ", () => { From 47b28f48750332e1e47d21c438e3922399765516 Mon Sep 17 00:00:00 2001 From: Tan Vir Date: Wed, 9 Sep 2026 04:51:34 +0600 Subject: [PATCH 4/4] test(usage): assert the merged plan and freshest window in the same-plan case Co-Authored-By: Claude Fable 5.1 --- packages/shared/src/usageLimits.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 5e2564451fae..ecec4b2384b3 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -438,7 +438,10 @@ describe("pools", () => { }, ], ]); - expect(collectLimitAccounts(input)).toHaveLength(1); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.plan).toBe("ChatGPT Plus Subscription"); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); }); it("merges a native Claude tier with the hub's tierless read of the same account", () => {