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 b814e66da459..ecec4b2384b3 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,122 @@ 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({ + 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 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + 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", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { + status: "authenticated", + email: "same@example.com", + label: "Claude Max 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", + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + 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", () => { + 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..95e3ad4af300 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,7 +312,10 @@ 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}`, { + const key = + accountGroupKey(account.driver, account.email, account.plan, knownPlans) ?? + `${source.id}:${account.id}`; + merge(key, { key: `${source.id}:${account.id}`, driver: account.driver, displayName: account.email ? null : account.id.replace(/\.json$/i, ""),