authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-10 02:47:53-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 02:47:53-03:00
log0be20c86c2962b7b581dfc79df9ab34b4fbdab14
treec8a4a416296ae369640176c1659ecbfcca1936e6
parentc899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e
signature Signed by PGP key B5690EEEBB952194

fix(usage): keep one email in two workspaces as two accounts (#17711)


11 files changed, 382 insertions(+), 61 deletions(-)

apps/server/src/provider/ClaudeProvider.ts+6
......@@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined {
262262
263263type ClaudeCapabilitiesProbe = {
264264 readonly email: string | undefined;
265 /**
266 * The organization UUID from the login's `.claude.json`. The driver reads it
267 * in the same cached probe, so it describes the same login as the rest.
268 */
269 readonly workspaceId?: string | undefined;
265270 readonly subscriptionType: string | undefined;
266271 readonly tokenSource: string | undefined;
267272 /** Where the CLI found an API key, when it authenticates with one. */
......@@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
679684 auth: {
680685 status: "authenticated",
681686 ...(capabilities.email ? { email: capabilities.email } : {}),
687 ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}),
682688 ...(authMetadata ? authMetadata : {}),
683689 },
684690 ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}),
apps/server/src/provider/CodexProvider.ts+10-2
......@@ -60,6 +60,8 @@ type CodexRateLimitsProbe =
6060 | null
6161 | undefined;
6262 readonly resetCredits: CodexResetCreditsSummary | null | undefined;
63 /** The ChatGPT workspace the usage belongs to, when the backend names it. */
64 readonly accountId?: string | null | undefined;
6365 }
6466 | { readonly failure: string };
6567
......@@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
456458 snapshot: response.rateLimits,
457459 rateLimitsByLimitId: response.rateLimitsByLimitId,
458460 resetCredits: response.rateLimitResetCredits,
461 accountId: response.accountId,
459462 })),
460463 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
461464 Effect.map(
......@@ -539,18 +542,23 @@ const makePendingCodexProvider = (
539542 });
540543 });
541544
542function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): {
545function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): {
543546 readonly status: Exclude<ServerProviderState, "disabled">;
544547 readonly auth: ServerProvider["auth"];
545548 readonly message?: string;
546549} {
547550 const authLabel = codexAccountAuthLabel(account.account);
548551 const authEmail = codexAccountEmail(account.account);
552 // One email can hold several workspaces, each with its own quota; the usage
553 // read names the one this login draws on.
554 const workspaceId =
555 rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined;
549556 const auth = {
550557 status: account.account ? ("authenticated" as const) : ("unknown" as const),
551558 ...(account.account?.type ? { type: account.account?.type } : {}),
552559 ...(authLabel ? { label: authLabel } : {}),
553560 ...(authEmail ? { email: authEmail } : {}),
561 ...(workspaceId ? { workspaceId } : {}),
554562 } satisfies ServerProvider["auth"];
555563
556564 if (account.account) {
......@@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
669677 const snapshot = probeResult.success.value;
670678 const accountStatus = managedAuth
671679 ? { status: "ready" as const, auth: managedAuth, message: undefined }
672 : accountProbeStatus(snapshot.account);
680 : accountProbeStatus(snapshot);
673681 const usageLimits =
674682 snapshot.account.account?.type === "apiKey"
675683 ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
apps/server/src/provider/Drivers/ClaudeDriver.ts+8-1
......@@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
203203 capacity: 1,
204204 timeToLive: CAPABILITIES_PROBE_TTL,
205205 lookup: () =>
206 probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe(
206 Effect.all([
207 probeClaudeCapabilities(effectiveConfig, processEnv, cwd),
208 ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath),
209 ]).pipe(
210 Effect.map(([capabilities, workspaceId]) =>
211 capabilities && workspaceId ? { ...capabilities, workspaceId } : capabilities,
212 ),
213 Effect.provideService(FileSystem.FileSystem, fileSystem),
207214 Effect.provideService(Path.Path, path),
208215 ),
209216 });
apps/server/src/provider/claudeResetCredits.ts+7
......@@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")(
189189);
190190
191191/** The CLI keeps the account record beside its settings, or in the home directory by default. */
192/** The organization the login is signed in to; one email can belong to several. */
193export const readClaudeOrganizationId = (accountConfigPath: string) =>
194 readJson(Config, accountConfigPath).pipe(
195 Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined),
196 Effect.orElseSucceed(() => undefined),
197 );
198
192199export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* (
193200 configDir: string | undefined,
194201) {
apps/server/src/usage/cliproxyApi.ts+3
......@@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () {
215215 id: account.id,
216216 driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"),
217217 ...(account.email ? { email: account.email } : {}),
218 ...(account.id_token?.chatgpt_account_id?.trim()
219 ? { workspaceId: account.id_token.chatgpt_account_id.trim() }
220 : {}),
218221 };
219222 const read = Effect.gen(function* () {
220223 if (account.provider === "claude") {
packages/contracts/src/providerUsageLimits.ts+2
......@@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({
9292 email: Schema.optional(TrimmedNonEmptyString),
9393 /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */
9494 plan: Schema.optional(TrimmedNonEmptyString),
95 /** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */
96 workspaceId: Schema.optional(TrimmedNonEmptyString),
9597 usageLimits: ServerProviderUsageLimits,
9698});
9799export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type;
packages/contracts/src/server.ts+6
......@@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({
6969 canLogout: Schema.optional(Schema.Boolean),
7070 subscriptionSharing: Schema.optional(Schema.Boolean),
7171 profileId: Schema.optional(TrimmedNonEmptyString),
72 /**
73 * The workspace or organization whose quota the login draws on, such as a
74 * ChatGPT workspace or Claude organization id. One email can belong to
75 * several, each with its own quota.
76 */
77 workspaceId: Schema.optional(TrimmedNonEmptyString),
7278});
7379export type ServerProviderAuth = typeof ServerProviderAuth.Type;
7480
packages/provider-core/src/server/managedProvider.test.ts+15-1
......@@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => {
591591 checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe(
592592 Effect.map((count) =>
593593 count === 1
594 ? { ...refreshedSnapshot, usageLimits: probedLimits }
594 ? {
595 ...refreshedSnapshot,
596 auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" },
597 usageLimits: probedLimits,
598 }
595599 : {
596600 ...refreshedSnapshotSecond,
601 ...(count === 3
602 ? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } }
603 : {}),
597604 usageLimits: {
598605 checkedAt: "2026-04-10T00:00:03.000Z",
599606 windows: [],
......@@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => {
640647 const refreshed = yield* provider.refresh;
641648 assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message);
642649 assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]);
650 // ...and the workspace they were read for.
651 assert.strictEqual(refreshed.auth.workspaceId, "ws-a");
652
653 // A failed read for another workspace does not inherit those windows.
654 const switched = yield* provider.refresh;
655 assert.strictEqual(switched.auth.workspaceId, "ws-b");
656 assert.strictEqual(switched.usageLimits?.unavailable?.reason, "probeFailed");
643657 }),
644658 ).pipe(Effect.provide(layerAlwaysRunTest)),
645659 );
packages/provider-core/src/server/managedProvider.ts+22-5
......@@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
155155 const generation = input.enrichSnapshot
156156 ? state.enrichmentGeneration + 1
157157 : state.enrichmentGeneration;
158 // A failed read keeps the last good limits only for the same account,
159 // and they keep the workspace they were read for.
160 const previous = state.snapshot.auth;
161 const differs = (a: string | undefined, b: string | undefined) =>
162 a !== undefined && b !== undefined && a !== b;
163 // Limits read without a workspace cannot be attributed to a new one.
164 const switchedAccount =
165 differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) ||
166 (probedSnapshot.auth.workspaceId !== undefined &&
167 probedSnapshot.auth.workspaceId !== previous.workspaceId);
168 const usageLimits = switchedAccount
169 ? probedSnapshot.usageLimits
170 : resolveUsageLimitsAfterProbe({
171 published: state.snapshot.usageLimits,
172 probed: probedSnapshot.usageLimits,
173 });
174 const workspaceId =
175 usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId;
158176 const snapshot = withUsageLimits(
159 probedSnapshot,
160 resolveUsageLimitsAfterProbe({
161 published: state.snapshot.usageLimits,
162 probed: probedSnapshot.usageLimits,
163 }),
177 workspaceId && !probedSnapshot.auth.workspaceId
178 ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } }
179 : probedSnapshot,
180 usageLimits,
164181 );
165182 return [
166183 { snapshot, generation },
packages/shared/src/usageLimits.test.ts+190
......@@ -496,6 +496,128 @@ describe("pools", () => {
496496 expect(account?.redeem?.environmentId).toBe("env-2");
497497 });
498498
499 it("keeps one email in two workspaces apart while merging each workspace's duplicates", () => {
500 const login = (instanceId: string, workspaceId: string, usedPercent: number) =>
501 provider({
502 instanceId: ProviderInstanceId.make(instanceId),
503 auth: { status: "authenticated", email: "same@example.com", workspaceId },
504 usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent }] },
505 });
506 const hubAccount = (id: string, workspaceId?: string) => ({
507 id,
508 driver: ProviderDriverKind.make("codex"),
509 email: "same@example.com",
510 ...(workspaceId ? { workspaceId } : {}),
511 usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] },
512 });
513 const input = new Map([
514 [
515 EnvironmentId.make("env-a"),
516 {
517 ...laptop,
518 serverConfig: {
519 providers: [login("business", "ws-business", 8), login("personal", "ws-plus", 26)],
520 usageLimitSources: [
521 // A hub that names the workspace joins it; one that does not
522 // cannot say which of the two it read, so it keeps its own row.
523 { ...source, accounts: [hubAccount("plus.json", "ws-plus"), hubAccount("x.json")] },
524 ],
525 },
526 },
527 ],
528 [
529 EnvironmentId.make("env-b"),
530 {
531 entry: { target: { label: "Desktop" } },
532 serverConfig: { providers: [login("personal", "ws-plus", 26)] },
533 },
534 ],
535 ]);
536 const accounts = collectLimitAccounts(input);
537 expect(accounts.map((account) => account.key)).toEqual([
538 "env-a:business",
539 "env-a:personal",
540 "hub:x.json",
541 ]);
542 expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(8);
543 // One hub file name in two workspaces stays two accounts, even without an
544 // email to tell them apart.
545 const withoutEmail = (id: string, workspaceId: string) => {
546 const { email: _email, ...account } = hubAccount(id, workspaceId);
547 return account;
548 };
549 const sameFile = new Map([
550 [
551 EnvironmentId.make("env-a"),
552 {
553 ...laptop,
554 serverConfig: {
555 usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-1")] }],
556 },
557 },
558 ],
559 [
560 EnvironmentId.make("env-b"),
561 {
562 ...laptop,
563 serverConfig: {
564 usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-2")] }],
565 },
566 },
567 ],
568 ]);
569 expect(collectLimitAccounts(sameFile).map((account) => account.key)).toEqual([
570 "hub:a.json:ws-1",
571 "hub:a.json:ws-2",
572 ]);
573 expect(accounts[1]?.environments.map((environment) => environment.label)).toEqual([
574 "Laptop",
575 "Desktop",
576 ]);
577 });
578
579 it("joins a report without a workspace to the only workspace signed in with its email", () => {
580 const native = provider({
581 driver: claude,
582 auth: { status: "authenticated", email: "same@example.com", workspaceId: "org-a" },
583 usageLimits: { checkedAt, windows: [window] },
584 });
585 const hubAccount = {
586 id: "claude.json",
587 driver: claude,
588 email: "same@example.com",
589 usageLimits: { checkedAt, windows: [window] },
590 };
591 const withOther = (other: ServerProvider) =>
592 new Map([
593 [
594 EnvironmentId.make("env-a"),
595 {
596 ...laptop,
597 serverConfig: {
598 providers: [native, other],
599 usageLimitSources: [{ ...source, accounts: [hubAccount] }],
600 },
601 },
602 ],
603 ]);
604 const unrelated = provider({ instanceId: ProviderInstanceId.make("codex-2") });
605 expect(collectLimitAccounts(withOther(unrelated)).map((account) => account.key)).toEqual([
606 "env-a:codex",
607 ]);
608 // A second org whose limits could not be read still makes the hub ambiguous.
609 const unreadable = provider({
610 ...native,
611 instanceId: ProviderInstanceId.make("team"),
612 auth: { ...native.auth, workspaceId: "org-b" },
613 usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } },
614 });
615 expect(collectLimitAccounts(withOther(unreadable)).map((account) => account.key)).toEqual([
616 "env-a:codex",
617 "hub:claude.json",
618 ]);
619 });
620
499621 it("names an environment once however many of its instances share the account", () => {
500622 const shared = provider({
501623 auth: { status: "authenticated", email: "same@example.com" },
......@@ -536,6 +658,45 @@ describe("pools", () => {
536658 expect(accounts[0]?.displayName).toBe("claude-team-seat");
537659 });
538660
661 it("pools Codex windows by kind, whichever slot reported them", () => {
662 const codexAccount = (id: string, slots: readonly string[]) => ({
663 id,
664 driver: ProviderDriverKind.make("codex"),
665 usageLimits: {
666 checkedAt,
667 windows: slots.map((slot) => ({ ...weekly, id: slot, usedPercent: 50 })),
668 },
669 });
670 const pooled = (accounts: ReturnType<typeof codexAccount>[]) =>
671 collectLimitPools(
672 collectLimitAccounts(
673 new Map([
674 [
675 EnvironmentId.make("env-a"),
676 { ...laptop, serverConfig: { usageLimitSources: [{ ...source, accounts }] } },
677 ],
678 ]),
679 ),
680 now,
681 )[0]?.windows ?? [];
682 // A weekly-only plan reports weekly as `primary`. The pool keeps one id
683 // whichever account sorts first.
684 for (const order of [
685 [codexAccount("business", ["primary"]), codexAccount("plus", ["secondary"])],
686 [codexAccount("plus", ["secondary"]), codexAccount("business", ["primary"])],
687 ]) {
688 const windows = pooled(order);
689 expect(windows.map((window) => window.id)).toEqual(["weekly"]);
690 expect(windows[0]?.members).toHaveLength(2);
691 }
692 // A second window of the same kind on one account stays apart, with its own id.
693 expect(
694 pooled([codexAccount("a", ["secondary"]), codexAccount("b", ["primary", "secondary"])]).map(
695 (window) => window.id,
696 ),
697 ).toEqual(["weekly", "weekly:secondary"]);
698 });
699
539700 it("pools windows by id across accounts and orders resets by when they land", () => {
540701 const input = new Map([
541702 [
......@@ -910,6 +1071,35 @@ describe("/usage-limits", () => {
9101071 });
9111072 });
9121073
1074 it("gives a hub credit to no native row when its email is signed in to two workspaces", () => {
1075 const workspaces = ["ws-a", "ws-b"].map((workspaceId) =>
1076 provider({
1077 instanceId: ProviderInstanceId.make(workspaceId),
1078 usageLimits: limits,
1079 auth: { status: "authenticated", email: "same@example.com", workspaceId },
1080 }),
1081 );
1082 const hub = [
1083 {
1084 ...sources[0]!,
1085 accounts: [
1086 {
1087 id: "duplicate",
1088 driver: selected.driver,
1089 email: "same@example.com",
1090 usageLimits: { ...limits, resetCredits: { availableCount: 2, nextCreditId: "c" } },
1091 },
1092 ],
1093 },
1094 ];
1095 const report = collectProviderUsageLimits(workspaces[0]!.instanceId, workspaces, hub, now);
1096 expect(report?.accounts.map((account) => account.resetCreditInput)).toEqual([
1097 { instanceId: "ws-a" },
1098 { instanceId: "ws-b" },
1099 { sourceId: "hub", accountId: "duplicate", creditId: "c" },
1100 ]);
1101 });
1102
9131103 it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => {
9141104 const fresher = provider({
9151105 usageLimits: {
packages/shared/src/usageLimits.ts+113-52
......@@ -125,11 +125,51 @@ function accountKey(
125125 : null;
126126}
127127
128interface AccountReport {
129 readonly driver: ServerProvider["driver"];
130 readonly email?: string | undefined;
131 readonly workspaceId?: string | undefined;
132 readonly usageLimits?: ServerProviderUsageLimits | undefined;
133}
134
135const nativeReport = (provider: ServerProvider): AccountReport => ({
136 driver: provider.driver,
137 email: provider.auth.email,
138 workspaceId: provider.auth.workspaceId,
139 usageLimits: provider.usageLimits,
140});
141
142/**
143 * Keys each report by the account it draws on. One email can belong to
144 * several workspaces, each with its own quota, so a report that names its
145 * workspace is keyed by it too. One that does not (a hub, an older server)
146 * joins the only workspace known for its email, and gets no key when there
147 * are several, since it cannot say which one it read. Pass every enabled
148 * login, read or not, so an unreadable workspace still counts as one.
149 */
150function accountKeyResolver(reports: Iterable<AccountReport>) {
151 const workspaces = new Map<string, Set<string>>();
152 for (const report of reports) {
153 const key = accountKey(report.driver, report.email, report.usageLimits);
154 if (key && report.workspaceId) {
155 workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId));
156 }
157 }
158 return (report: AccountReport): string | null => {
159 const key = accountKey(report.driver, report.email, report.usageLimits);
160 if (!key) return null;
161 if (report.workspaceId) return `${key}:${report.workspaceId}`;
162 const known = workspaces.get(key);
163 if (!known) return key;
164 return known.size === 1 ? `${key}:${[...known][0]}` : null;
165 };
166}
167
128168/**
129169 * One subscription account as the pooled views see it, whichever way it was
130 * reported. Matching emails or credentials across environments name
131 * one account. Its quota is one bucket, so counting it twice would misstate
132 * what is left.
170 * reported. Matching emails or credentials across environments name one
171 * account, unless they name different workspaces. Its quota is one bucket, so
172 * counting it twice would misstate what is left.
133173 */
134174export interface LimitAccount {
135175 readonly key: string;
......@@ -222,26 +262,30 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
222262 },
223263 });
224264 };
265 const resolveKey = accountKeyResolver(
266 [...presentations.values()].flatMap((presentation) => [
267 ...(presentation.serverConfig?.providers ?? [])
268 .filter((provider) => provider.enabled)
269 .map(nativeReport),
270 ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts),
271 ]),
272 );
225273 for (const [environmentId, presentation] of presentations) {
226274 const label = presentation.entry.target.label;
227275 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
228276 if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue;
229 merge(
230 accountKey(provider.driver, provider.auth.email, provider.usageLimits) ??
231 `${environmentId}:${provider.instanceId}`,
232 {
233 key: `${environmentId}:${provider.instanceId}`,
234 driver: provider.driver,
235 displayName: provider.displayName?.trim() || null,
236 email: provider.auth.email,
237 plan: provider.auth.label,
238 accentColor: provider.accentColor,
239 environments: [{ environmentId, label }],
240 sourceLabel: null,
241 redeem: { environmentId, input: { instanceId: provider.instanceId } },
242 limits: provider.usageLimits,
243 },
244 );
277 merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, {
278 key: `${environmentId}:${provider.instanceId}`,
279 driver: provider.driver,
280 displayName: provider.displayName?.trim() || null,
281 email: provider.auth.email,
282 plan: provider.auth.label,
283 accentColor: provider.accentColor,
284 environments: [{ environmentId, label }],
285 sourceLabel: null,
286 redeem: { environmentId, input: { instanceId: provider.instanceId } },
287 limits: provider.usageLimits,
288 });
245289 }
246290 }
247291 // Every hub account, including those a native instance also knows: the hub
......@@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
255299 : source.label;
256300 for (const account of source.accounts) {
257301 if (limitsNotice(account.usageLimits) !== null) continue;
258 merge(
259 accountKey(account.driver, account.email, account.usageLimits) ??
260 `${source.id}:${account.id}`,
261 {
262 key: `${source.id}:${account.id}`,
263 driver: account.driver,
264 displayName: account.email ? null : account.id.replace(/\.json$/i, ""),
265 email: account.email,
266 plan: account.plan,
267 accentColor: undefined,
268 environments: [],
269 sourceLabel,
270 redeem: account.usageLimits.resetCredits?.nextCreditId
271 ? {
272 environmentId,
273 input: {
274 sourceId: source.id,
275 accountId: account.id,
276 creditId: account.usageLimits.resetCredits.nextCreditId,
277 },
278 }
279 : null,
280 limits: account.usageLimits,
281 },
282 );
302 const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`;
303 merge(resolveKey(account) ?? key, {
304 key,
305 driver: account.driver,
306 displayName: account.email ? null : account.id.replace(/\.json$/i, ""),
307 email: account.email,
308 plan: account.plan,
309 accentColor: undefined,
310 environments: [],
311 sourceLabel,
312 redeem: account.usageLimits.resetCredits?.nextCreditId
313 ? {
314 environmentId,
315 input: {
316 sourceId: source.id,
317 accountId: account.id,
318 creditId: account.usageLimits.resetCredits.nextCreditId,
319 },
320 }
321 : null,
322 limits: account.usageLimits,
323 });
283324 }
284325 }
285326 }
......@@ -401,7 +442,7 @@ export function collectLimitPools(
401442 .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0];
402443 const orderReset = (account: LimitAccount) => {
403444 const window = account.limits.windows.find(
404 (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id,
445 (window) => orderWindow && poolKey(driver, window) === poolKey(driver, orderWindow),
405446 );
406447 return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY;
407448 };
......@@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string {
419460 return (account.displayName ?? account.email ?? account.key).toLowerCase();
420461}
421462
463/**
464 * Codex ids are slots, not allowances: a weekly-only plan reports its weekly
465 * window as `primary`, other plans as `secondary`. Its windows pool by kind;
466 * other drivers' ids name distinct allowances.
467 */
468function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string {
469 return driver === "codex" ? window.kind : `${window.kind}:${window.id}`;
470}
471
422472function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] {
423473 const byKey = new Map<string, LimitPoolMember[]>();
424474 for (const account of accounts) {
425475 for (const window of account.limits.windows) {
426 const key = `${window.kind}:${window.id}`;
476 const pooled = poolKey(account.driver, window);
477 // An account with two windows of one kind keeps the second apart.
478 const key = byKey.get(pooled)?.some((member) => member.account === account)
479 ? `${window.kind}:${window.id}`
480 : pooled;
427481 const list = byKey.get(key);
428482 if (list) list.push({ account, window });
429483 else byKey.set(key, [{ account, window }]);
430484 }
431485 }
432 const pools = [...byKey.values()].map((members): LimitPoolWindow => {
486 const pools = [...byKey].map(([key, members]): LimitPoolWindow => {
433487 const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
434488 const first = members[0]!.window;
489 // Pooled Codex slots take the pool's own key, so the id stays the same
490 // whichever account sorts first.
491 const id = members[0]!.account.driver === "codex" ? key : first.id;
435492 const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
436493 // Pace compares spend against the clock, so it is judged only over the
437494 // members that have a clock; a window with no reset would otherwise
......@@ -458,7 +515,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
458515 })
459516 .sort((left, right) => left.at - right.at);
460517 return {
461 id: first.id,
518 id,
462519 kind: first.kind,
463520 label: first.label,
464521 members,
......@@ -633,9 +690,13 @@ export function collectProviderUsageLimits(
633690 const native = providersWithLimits(providers).filter(
634691 (provider) => provider.driver === selected.driver,
635692 );
693 const resolveKey = accountKeyResolver([
694 ...providers.filter((provider) => provider.enabled).map(nativeReport),
695 ...sources.flatMap((source) => source.accounts),
696 ]);
636697 const nativeAccounts = new Set(
637698 native.flatMap((provider) => {
638 const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits);
699 const key = resolveKey(nativeReport(provider));
639700 return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable
640701 ? [key]
641702 : [];
......@@ -645,13 +706,13 @@ export function collectProviderUsageLimits(
645706 const notices: string[] = [];
646707 for (const provider of native) {
647708 if (!provider.usageLimits) continue;
648 const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits);
709 const key = resolveKey(nativeReport(provider));
649710 const hubCredits = sources
650711 .flatMap((source) => source.accounts.map((account) => ({ source, account })))
651712 .filter(
652713 ({ account }) =>
653714 key !== null &&
654 accountKey(account.driver, account.email, account.usageLimits) === key &&
715 resolveKey(account) === key &&
655716 account.usageLimits.resetCredits &&
656717 !limitsNotice(account.usageLimits),
657718 )
......@@ -698,7 +759,7 @@ export function collectProviderUsageLimits(
698759 for (const source of sources) {
699760 const matching = source.accounts.filter((account) => account.driver === selected.driver);
700761 for (const account of matching) {
701 const key = accountKey(account.driver, account.email, account.usageLimits);
762 const key = resolveKey(account);
702763 if (key && nativeAccounts.has(key)) continue;
703764 accounts.push({
704765 id: `${source.id}:${account.id}`,