From 0be20c86c2962b7b581dfc79df9ab34b4fbdab14 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:47:53 -0300 Subject: [PATCH] fix(usage): keep one email in two workspaces as two accounts (#17711) --- apps/server/src/provider/ClaudeProvider.ts | 6 + apps/server/src/provider/CodexProvider.ts | 12 +- .../src/provider/Drivers/ClaudeDriver.ts | 9 +- .../server/src/provider/claudeResetCredits.ts | 7 + apps/server/src/usage/cliproxyApi.ts | 3 + packages/contracts/src/providerUsageLimits.ts | 2 + packages/contracts/src/server.ts | 6 + .../src/server/managedProvider.test.ts | 16 +- .../src/server/managedProvider.ts | 27 ++- packages/shared/src/usageLimits.test.ts | 190 ++++++++++++++++++ packages/shared/src/usageLimits.ts | 165 ++++++++++----- 11 files changed, 382 insertions(+), 61 deletions(-) diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 553d3d2e5fefc21ff2b93bea76f00492044c0584..8bc961834f270ee3027d7cc7e41a1d1734d8cb73 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined { type ClaudeCapabilitiesProbe = { readonly email: string | undefined; + /** + * The organization UUID from the login's `.claude.json`. The driver reads it + * in the same cached probe, so it describes the same login as the rest. + */ + readonly workspaceId?: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; /** Where the CLI found an API key, when it authenticates with one. */ @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "authenticated", ...(capabilities.email ? { email: capabilities.email } : {}), + ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}), ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index cb1a5513cd01c96a4904a05aea032865d3eb26d4..5109bb840f561919ea9d443f42545a0341a975d1 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -60,6 +60,8 @@ type CodexRateLimitsProbe = | null | undefined; readonly resetCredits: CodexResetCreditsSummary | null | undefined; + /** The ChatGPT workspace the usage belongs to, when the backend names it. */ + readonly accountId?: string | null | undefined; } | { readonly failure: string }; @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun snapshot: response.rateLimits, rateLimitsByLimitId: response.rateLimitsByLimitId, resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, })), Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), Effect.map( @@ -539,18 +542,23 @@ const makePendingCodexProvider = ( }); }); -function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { +function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { readonly status: Exclude; readonly auth: ServerProvider["auth"]; readonly message?: string; } { const authLabel = codexAccountAuthLabel(account.account); const authEmail = codexAccountEmail(account.account); + // One email can hold several workspaces, each with its own quota; the usage + // read names the one this login draws on. + const workspaceId = + rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined; const auth = { status: account.account ? ("authenticated" as const) : ("unknown" as const), ...(account.account?.type ? { type: account.account?.type } : {}), ...(authLabel ? { label: authLabel } : {}), ...(authEmail ? { email: authEmail } : {}), + ...(workspaceId ? { workspaceId } : {}), } satisfies ServerProvider["auth"]; if (account.account) { @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } - : accountProbeStatus(snapshot.account); + : accountProbeStatus(snapshot); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index be7872a7df28377c8b35b2a1e00e1ddcedae46fa..0169eb87ba7618d38842334795f2700041d512b9 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver - probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( + Effect.all([ + probeClaudeCapabilities(effectiveConfig, processEnv, cwd), + ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath), + ]).pipe( + Effect.map(([capabilities, workspaceId]) => + capabilities && workspaceId ? { ...capabilities, workspaceId } : capabilities, + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), }); diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index e381fa7cabe285aba0224aa1569c16d56ad71033..3c4a54742082ca341fd8238af19af8a03a5d4a5f 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( ); /** The CLI keeps the account record beside its settings, or in the home directory by default. */ +/** The organization the login is signed in to; one email can belong to several. */ +export const readClaudeOrganizationId = (accountConfigPath: string) => + readJson(Config, accountConfigPath).pipe( + Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), + Effect.orElseSucceed(() => undefined), + ); + export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( configDir: string | undefined, ) { diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index c50ebb57f40eb2ee43196c64ad563fdb97604ee2..b80ebe59261e7110c1ec1074e332d17577ec7a78 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () { id: account.id, driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), ...(account.email ? { email: account.email } : {}), + ...(account.id_token?.chatgpt_account_id?.trim() + ? { workspaceId: account.id_token.chatgpt_account_id.trim() } + : {}), }; const read = Effect.gen(function* () { if (account.provider === "claude") { diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index c692a9b7d519be58512d13c8121bf04f8f4c6f1d..0108bbb669eb27d5490f119f7e22259707fa2ccc 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ email: Schema.optional(TrimmedNonEmptyString), /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ plan: Schema.optional(TrimmedNonEmptyString), + /** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */ + workspaceId: Schema.optional(TrimmedNonEmptyString), usageLimits: ServerProviderUsageLimits, }); export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 5855477855d19b1e040070317a5477173b2c8126..a726f05a311c8e755896f4ff223bccc078e8bf22 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({ canLogout: Schema.optional(Schema.Boolean), subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), + /** + * The workspace or organization whose quota the login draws on, such as a + * ChatGPT workspace or Claude organization id. One email can belong to + * several, each with its own quota. + */ + workspaceId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 239508cec41c61b5216c8de5309dce4059b589c9..1d73e112b815f6ebafd0a44d2105e75388391deb 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => { checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( Effect.map((count) => count === 1 - ? { ...refreshedSnapshot, usageLimits: probedLimits } + ? { + ...refreshedSnapshot, + auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" }, + usageLimits: probedLimits, + } : { ...refreshedSnapshotSecond, + ...(count === 3 + ? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } } + : {}), usageLimits: { checkedAt: "2026-04-10T00:00:03.000Z", windows: [], @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => { const refreshed = yield* provider.refresh; assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); + // ...and the workspace they were read for. + assert.strictEqual(refreshed.auth.workspaceId, "ws-a"); + + // A failed read for another workspace does not inherit those windows. + const switched = yield* provider.refresh; + assert.strictEqual(switched.auth.workspaceId, "ws-b"); + assert.strictEqual(switched.usageLimits?.unavailable?.reason, "probeFailed"); }), ).pipe(Effect.provide(layerAlwaysRunTest)), ); diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index b74788a38bada01f0257812a537109fc9e9d7e29..a11734a1661b4538562ee94503c81a0963ceb004 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const generation = input.enrichSnapshot ? state.enrichmentGeneration + 1 : state.enrichmentGeneration; + // A failed read keeps the last good limits only for the same account, + // and they keep the workspace they were read for. + const previous = state.snapshot.auth; + const differs = (a: string | undefined, b: string | undefined) => + a !== undefined && b !== undefined && a !== b; + // Limits read without a workspace cannot be attributed to a new one. + const switchedAccount = + differs(probedSnapshot.auth.email?.toLowerCase(), previous.email?.toLowerCase()) || + (probedSnapshot.auth.workspaceId !== undefined && + probedSnapshot.auth.workspaceId !== previous.workspaceId); + const usageLimits = switchedAccount + ? probedSnapshot.usageLimits + : resolveUsageLimitsAfterProbe({ + published: state.snapshot.usageLimits, + probed: probedSnapshot.usageLimits, + }); + const workspaceId = + usageLimits === probedSnapshot.usageLimits ? undefined : state.snapshot.auth.workspaceId; const snapshot = withUsageLimits( - probedSnapshot, - resolveUsageLimitsAfterProbe({ - published: state.snapshot.usageLimits, - probed: probedSnapshot.usageLimits, - }), + workspaceId && !probedSnapshot.auth.workspaceId + ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } + : probedSnapshot, + usageLimits, ); return [ { snapshot, generation }, diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index b2bef55cb10826a664ac95a822c15b192257bf0a..6681033024175028425b7250ce7f5c526d65cf1c 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -496,6 +496,128 @@ describe("pools", () => { expect(account?.redeem?.environmentId).toBe("env-2"); }); + it("keeps one email in two workspaces apart while merging each workspace's duplicates", () => { + const login = (instanceId: string, workspaceId: string, usedPercent: number) => + provider({ + instanceId: ProviderInstanceId.make(instanceId), + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent }] }, + }); + const hubAccount = (id: string, workspaceId?: string) => ({ + id, + driver: ProviderDriverKind.make("codex"), + email: "same@example.com", + ...(workspaceId ? { workspaceId } : {}), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [login("business", "ws-business", 8), login("personal", "ws-plus", 26)], + usageLimitSources: [ + // A hub that names the workspace joins it; one that does not + // cannot say which of the two it read, so it keeps its own row. + { ...source, accounts: [hubAccount("plus.json", "ws-plus"), hubAccount("x.json")] }, + ], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [login("personal", "ws-plus", 26)] }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual([ + "env-a:business", + "env-a:personal", + "hub:x.json", + ]); + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(8); + // One hub file name in two workspaces stays two accounts, even without an + // email to tell them apart. + const withoutEmail = (id: string, workspaceId: string) => { + const { email: _email, ...account } = hubAccount(id, workspaceId); + return account; + }; + const sameFile = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-1")] }], + }, + }, + ], + [ + EnvironmentId.make("env-b"), + { + ...laptop, + serverConfig: { + usageLimitSources: [{ ...source, accounts: [withoutEmail("a.json", "ws-2")] }], + }, + }, + ], + ]); + expect(collectLimitAccounts(sameFile).map((account) => account.key)).toEqual([ + "hub:a.json:ws-1", + "hub:a.json:ws-2", + ]); + expect(accounts[1]?.environments.map((environment) => environment.label)).toEqual([ + "Laptop", + "Desktop", + ]); + }); + + it("joins a report without a workspace to the only workspace signed in with its email", () => { + const native = provider({ + driver: claude, + auth: { status: "authenticated", email: "same@example.com", workspaceId: "org-a" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const hubAccount = { + id: "claude.json", + driver: claude, + email: "same@example.com", + usageLimits: { checkedAt, windows: [window] }, + }; + const withOther = (other: ServerProvider) => + new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native, other], + usageLimitSources: [{ ...source, accounts: [hubAccount] }], + }, + }, + ], + ]); + const unrelated = provider({ instanceId: ProviderInstanceId.make("codex-2") }); + expect(collectLimitAccounts(withOther(unrelated)).map((account) => account.key)).toEqual([ + "env-a:codex", + ]); + // A second org whose limits could not be read still makes the hub ambiguous. + const unreadable = provider({ + ...native, + instanceId: ProviderInstanceId.make("team"), + auth: { ...native.auth, workspaceId: "org-b" }, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + expect(collectLimitAccounts(withOther(unreadable)).map((account) => account.key)).toEqual([ + "env-a:codex", + "hub:claude.json", + ]); + }); + it("names an environment once however many of its instances share the account", () => { const shared = provider({ auth: { status: "authenticated", email: "same@example.com" }, @@ -536,6 +658,45 @@ describe("pools", () => { expect(accounts[0]?.displayName).toBe("claude-team-seat"); }); + it("pools Codex windows by kind, whichever slot reported them", () => { + const codexAccount = (id: string, slots: readonly string[]) => ({ + id, + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt, + windows: slots.map((slot) => ({ ...weekly, id: slot, usedPercent: 50 })), + }, + }); + const pooled = (accounts: ReturnType[]) => + collectLimitPools( + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { ...laptop, serverConfig: { usageLimitSources: [{ ...source, accounts }] } }, + ], + ]), + ), + now, + )[0]?.windows ?? []; + // A weekly-only plan reports weekly as `primary`. The pool keeps one id + // whichever account sorts first. + for (const order of [ + [codexAccount("business", ["primary"]), codexAccount("plus", ["secondary"])], + [codexAccount("plus", ["secondary"]), codexAccount("business", ["primary"])], + ]) { + const windows = pooled(order); + expect(windows.map((window) => window.id)).toEqual(["weekly"]); + expect(windows[0]?.members).toHaveLength(2); + } + // A second window of the same kind on one account stays apart, with its own id. + expect( + pooled([codexAccount("a", ["secondary"]), codexAccount("b", ["primary", "secondary"])]).map( + (window) => window.id, + ), + ).toEqual(["weekly", "weekly:secondary"]); + }); + it("pools windows by id across accounts and orders resets by when they land", () => { const input = new Map([ [ @@ -910,6 +1071,35 @@ describe("/usage-limits", () => { }); }); + it("gives a hub credit to no native row when its email is signed in to two workspaces", () => { + const workspaces = ["ws-a", "ws-b"].map((workspaceId) => + provider({ + instanceId: ProviderInstanceId.make(workspaceId), + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com", workspaceId }, + }), + ); + const hub = [ + { + ...sources[0]!, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "same@example.com", + usageLimits: { ...limits, resetCredits: { availableCount: 2, nextCreditId: "c" } }, + }, + ], + }, + ]; + const report = collectProviderUsageLimits(workspaces[0]!.instanceId, workspaces, hub, now); + expect(report?.accounts.map((account) => account.resetCreditInput)).toEqual([ + { instanceId: "ws-a" }, + { instanceId: "ws-b" }, + { sourceId: "hub", accountId: "duplicate", creditId: "c" }, + ]); + }); + it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => { const fresher = provider({ usageLimits: { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b1211bffd54d54205777d614dc0a428219296482..85c5e6e39f886ffb178b7212564e3a4f86ba142d 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -125,11 +125,51 @@ function accountKey( : null; } +interface AccountReport { + readonly driver: ServerProvider["driver"]; + readonly email?: string | undefined; + readonly workspaceId?: string | undefined; + readonly usageLimits?: ServerProviderUsageLimits | undefined; +} + +const nativeReport = (provider: ServerProvider): AccountReport => ({ + driver: provider.driver, + email: provider.auth.email, + workspaceId: provider.auth.workspaceId, + usageLimits: provider.usageLimits, +}); + +/** + * Keys each report by the account it draws on. One email can belong to + * several workspaces, each with its own quota, so a report that names its + * workspace is keyed by it too. One that does not (a hub, an older server) + * joins the only workspace known for its email, and gets no key when there + * are several, since it cannot say which one it read. Pass every enabled + * login, read or not, so an unreadable workspace still counts as one. + */ +function accountKeyResolver(reports: Iterable) { + const workspaces = new Map>(); + for (const report of reports) { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (key && report.workspaceId) { + workspaces.set(key, (workspaces.get(key) ?? new Set()).add(report.workspaceId)); + } + } + return (report: AccountReport): string | null => { + const key = accountKey(report.driver, report.email, report.usageLimits); + if (!key) return null; + if (report.workspaceId) return `${key}:${report.workspaceId}`; + const known = workspaces.get(key); + if (!known) return key; + return known.size === 1 ? `${key}:${[...known][0]}` : null; + }; +} + /** * One subscription account as the pooled views see it, whichever way it was - * reported. Matching emails or credentials across environments name - * one account. Its quota is one bucket, so counting it twice would misstate - * what is left. + * reported. Matching emails or credentials across environments name one + * account, unless they name different workspaces. Its quota is one bucket, so + * counting it twice would misstate what is left. */ export interface LimitAccount { readonly key: string; @@ -222,26 +262,30 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl }, }); }; + const resolveKey = accountKeyResolver( + [...presentations.values()].flatMap((presentation) => [ + ...(presentation.serverConfig?.providers ?? []) + .filter((provider) => provider.enabled) + .map(nativeReport), + ...(presentation.serverConfig?.usageLimitSources ?? []).flatMap((source) => source.accounts), + ]), + ); for (const [environmentId, presentation] of presentations) { const label = presentation.entry.target.label; for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; - merge( - accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? - `${environmentId}:${provider.instanceId}`, - { - key: `${environmentId}:${provider.instanceId}`, - driver: provider.driver, - displayName: provider.displayName?.trim() || null, - email: provider.auth.email, - plan: provider.auth.label, - accentColor: provider.accentColor, - environments: [{ environmentId, label }], - sourceLabel: null, - redeem: { environmentId, input: { instanceId: provider.instanceId } }, - limits: provider.usageLimits, - }, - ); + merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, input: { instanceId: provider.instanceId } }, + limits: provider.usageLimits, + }); } } // Every hub account, including those a native instance also knows: the hub @@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl : source.label; for (const account of source.accounts) { if (limitsNotice(account.usageLimits) !== null) continue; - merge( - accountKey(account.driver, account.email, account.usageLimits) ?? - `${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, - }, - ); + const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; + merge(resolveKey(account) ?? key, { + key, + 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, + }); } } } @@ -401,7 +442,7 @@ export function collectLimitPools( .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; const orderReset = (account: LimitAccount) => { const window = account.limits.windows.find( - (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id, + (window) => orderWindow && poolKey(driver, window) === poolKey(driver, orderWindow), ); return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; }; @@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string { return (account.displayName ?? account.email ?? account.key).toLowerCase(); } +/** + * Codex ids are slots, not allowances: a weekly-only plan reports its weekly + * window as `primary`, other plans as `secondary`. Its windows pool by kind; + * other drivers' ids name distinct allowances. + */ +function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { + return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; +} + function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { const byKey = new Map(); for (const account of accounts) { for (const window of account.limits.windows) { - const key = `${window.kind}:${window.id}`; + const pooled = poolKey(account.driver, window); + // An account with two windows of one kind keeps the second apart. + const key = byKey.get(pooled)?.some((member) => member.account === account) + ? `${window.kind}:${window.id}` + : pooled; const list = byKey.get(key); if (list) list.push({ account, window }); else byKey.set(key, [{ account, window }]); } } - const pools = [...byKey.values()].map((members): LimitPoolWindow => { + const pools = [...byKey].map(([key, members]): LimitPoolWindow => { const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; + // Pooled Codex slots take the pool's own key, so the id stays the same + // whichever account sorts first. + const id = members[0]!.account.driver === "codex" ? key : first.id; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; // Pace compares spend against the clock, so it is judged only over the // 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 }) .sort((left, right) => left.at - right.at); return { - id: first.id, + id, kind: first.kind, label: first.label, members, @@ -633,9 +690,13 @@ export function collectProviderUsageLimits( const native = providersWithLimits(providers).filter( (provider) => provider.driver === selected.driver, ); + const resolveKey = accountKeyResolver([ + ...providers.filter((provider) => provider.enabled).map(nativeReport), + ...sources.flatMap((source) => source.accounts), + ]); const nativeAccounts = new Set( native.flatMap((provider) => { - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable ? [key] : []; @@ -645,13 +706,13 @@ export function collectProviderUsageLimits( const notices: string[] = []; for (const provider of native) { if (!provider.usageLimits) continue; - const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = resolveKey(nativeReport(provider)); const hubCredits = sources .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => key !== null && - accountKey(account.driver, account.email, account.usageLimits) === key && + resolveKey(account) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), ) @@ -698,7 +759,7 @@ export function collectProviderUsageLimits( for (const source of sources) { const matching = source.accounts.filter((account) => account.driver === selected.driver); for (const account of matching) { - const key = accountKey(account.driver, account.email, account.usageLimits); + const key = resolveKey(account); if (key && nativeAccounts.has(key)) continue; accounts.push({ id: `${source.id}:${account.id}`, -- 2.54.0