From 68c409f7f8f65916c8c74a4dd1a13ecd394629cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 17:19:48 +0000 Subject: [PATCH 1/2] test(shared): pooled limits weight accounts by plan capacity (repro #13110) Fails on main: a used-up Pro 20x pooled with an unused Plus shows 50% left and a 50% Pro reset instead of about 5% left and a 95% reset. AI-assisted; verified with the tests and tools listed above. --- packages/shared/src/usageLimits.test.ts | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index bed48428a93c..8f7fa26d7e72 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -804,6 +804,52 @@ describe("pools", () => { ).toEqual(["weekly", "weekly:secondary"]); }); + it("weights pooled Codex quota and resets by each account's plan capacity", () => { + const [pool] = collectLimitPools( + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [ + ["pro", "ChatGPT Pro 200 Subscription", 100], + ["plus", "ChatGPT Plus Subscription", 0], + ].map(([id, plan, usedPercent]) => ({ + id: String(id), + email: `${id}@example.com`, + driver: ProviderDriverKind.make("codex"), + plan: String(plan), + usageLimits: { + checkedAt, + windows: [{ ...weekly, id: "secondary", usedPercent: Number(usedPercent) }], + }, + })), + }, + ], + }, + }, + ], + ]), + ), + now, + ); + const [pooledWeekly] = pool?.windows ?? []; + // Pro 20x holds 20 of the pool's 21 capacity parts. + expect(pooledWeekly?.remainingPercent).toBe(5); + expect( + pooledWeekly?.resets.map(({ member, restoresPercent }) => [ + member.account.email, + restoresPercent, + ]), + ).toContainEqual(["pro@example.com", 95]); + }); + it("pools windows by id across accounts and orders resets by when they land", () => { const input = new Map([ [ From c8217e85ed05673d2a4b988316369d3df1f5356c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 17:32:23 +0000 Subject: [PATCH 2/2] fix(shared): pooled limits weight each account by its plan size Pooled Limits averaged members evenly, so a used-up ChatGPT Pro 20x pooled with an unused Plus showed 50% left and a +50% Pro reset. Weight pooled used percent, pace and reset shares by plan capacity: Codex session and weekly Plus 1, Pro 100 5, Pro 200 20; Claude session Pro 1, Max 5, Ultra 20. A pool with any unknown plan keeps equal weights. Fixes pingdotgg/t3code#13110 AI-assisted; verified with the tests and tools listed above. --- packages/shared/src/usageLimits.ts | 44 ++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 716103ac2fc9..ed38fdeb45b4 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -539,6 +539,25 @@ function accountSortName(account: LimitAccount): string { return (account.displayName ?? account.email ?? account.key).toLowerCase(); } +const CODEX_PLAN_CAPACITY: Record = { + "ChatGPT Plus Subscription": 1, + "ChatGPT Pro 100 Subscription": 5, + "ChatGPT Pro 200 Subscription": 20, +}; +// Claude's published plan multipliers describe the session allowance only. +const CLAUDE_SESSION_CAPACITY: Record = { Pro: 1, Max: 5, Ultra: 20 }; + +function planCapacity(account: LimitAccount, kind: ServerProviderUsageWindow["kind"]) { + if (!account.plan) return undefined; + if (account.driver === "codex" && (kind === "session" || kind === "weekly")) { + return CODEX_PLAN_CAPACITY[account.plan]; + } + if (account.driver === "claudeAgent" && kind === "session") { + return CLAUDE_SESSION_CAPACITY[account.plan]; + } + return undefined; +} + // Codex ids name slots, so pool by kind; other drivers' ids identify distinct allowances. function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; @@ -607,15 +626,28 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; const id = members[0]!.account.driver === "codex" ? key : first.id; - const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; + // One unknown plan makes the pool incomparable by plan, so every member then counts once. + const capacities = members.map((m) => planCapacity(m.account, first.kind)); + const weights = capacities.every((capacity) => capacity !== undefined) + ? capacities + : members.map(() => 1); + const weightOf = new Map(members.map((m, index) => [m, weights[index]!])); + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); + const usedPercent = + members.reduce((sum, m) => sum + m.window.usedPercent * weightOf.get(m)!, 0) / totalWeight; // Judge pace only for windows with reset clocks; others add spend without elapsed time. const timed = members.flatMap((m) => { const share = elapsedShare(m.window, now); - return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; + return share === null + ? [] + : [{ used: m.window.usedPercent, elapsed: share, weight: weightOf.get(m)! }]; }); - const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length; + const timedWeight = timed.reduce((sum, t) => sum + t.weight, 0); + const timedUsed = timed.reduce((sum, t) => sum + t.used * t.weight, 0) / timedWeight; const meanElapsed = - timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null; + timed.length > 0 + ? timed.reduce((sum, t) => sum + t.elapsed * t.weight, 0) / timedWeight + : null; const resets = members .flatMap((member) => { const at = resetMillis(member.window); @@ -625,7 +657,9 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L { member, at, - restoresPercent: Math.round(member.refillPercent / members.length), + restoresPercent: Math.round( + (member.refillPercent * weightOf.get(member)!) / totalWeight, + ), }, ]; })