From ca4153fe17b2e93a217835b897c1861e9f08603f Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Tue, 22 Sep 2026 18:41:42 +0200 Subject: [PATCH 1/6] fix(usage): keep one email in two Claude orgs as two accounts Limits keyed accounts by driver and email, so a Claude login signed in to a personal org and a Team org with the same email collapsed into one bar, showing whichever org was probed last. The Claude SDK reports the org on its account info; carry it on the provider auth and add it to the native account key. A hub account, which names no org, still joins a native one when exactly one org uses its email. Refs #10835 Co-Authored-By: Claude Opus 5 (1M context) --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 3 +- .../src/provider/Layers/ClaudeProvider.ts | 4 + .../provider/Layers/ProviderRegistry.test.ts | 8 +- packages/contracts/src/server.ts | 5 + packages/shared/src/usageLimits.test.ts | 64 +++++++++++ packages/shared/src/usageLimits.ts | 104 ++++++++++-------- 6 files changed, 141 insertions(+), 47 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 232b8cc02d00..3a032668b5e3 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -115,7 +115,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ' output_style: "default",', ' available_output_styles: ["default"],', " models: [],", - ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + ' account: { email: "dev@example.com", organization: "Acme", subscriptionType: "pro", tokenSource: "oauth" },', " });", " }", " // The probe follows initialize with get_usage on the same process.", @@ -147,6 +147,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { assert.deepEqual(capabilities, { email: "dev@example.com", + organization: "Acme", subscriptionType: "pro", tokenSource: "oauth", apiProvider: undefined, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index db06557c7c8e..950608c7d583 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -227,6 +227,7 @@ function nonEmptyProbeString(value: string): string | undefined { type ClaudeCapabilitiesProbe = { readonly email: string | undefined; + readonly organization: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; /** @@ -376,6 +377,7 @@ const probeClaudeCapabilities = ( const account = init.account as | { readonly email?: string; + readonly organization?: string; readonly subscriptionType?: string; readonly tokenSource?: string; readonly apiProvider?: string; @@ -383,6 +385,7 @@ const probeClaudeCapabilities = ( | undefined; return { email: account?.email, + organization: nonEmptyProbeString(account?.organization ?? ""), subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, @@ -592,6 +595,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( auth: { status: "authenticated", ...(capabilities.email ? { email: capabilities.email } : {}), + ...(capabilities.organization ? { organization: capabilities.organization } : {}), ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 0970ea88748b..c6ee78033a91 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -151,6 +151,7 @@ function booleanDescriptor(id: string, label: string) { type TestClaudeCapabilities = { readonly email: string | undefined; + readonly organization: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; readonly apiProvider: string | undefined; @@ -161,6 +162,7 @@ function claudeCapabilities(overrides: Partial = {}) { return () => Effect.succeed({ email: undefined, + organization: undefined, subscriptionType: undefined, tokenSource: undefined, apiProvider: undefined, @@ -2828,6 +2830,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test () => Effect.succeed({ email: undefined, + organization: undefined, subscriptionType: undefined, tokenSource: undefined, apiProvider: undefined, @@ -2900,14 +2903,15 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test ), ); - it.effect("returns claude auth email from initialization result", () => + it.effect("returns claude auth email and organization from initialization result", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, - claudeCapabilities({ email: "claude@example.com" }), + claudeCapabilities({ email: "claude@example.com", organization: "Acme" }), ); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.email, "claude@example.com"); + assert.strictEqual(status.auth.organization, "Acme"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c8983988ead1..fe7098c04eae 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -65,6 +65,11 @@ export const ServerProviderAuth = Schema.Struct({ email: Schema.optional(TrimmedNonEmptyString), subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), + /** + * The organization the login is signed in to, when the provider reports + * one. One email can belong to several orgs, each with its own quota. + */ + organization: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index e93c68d1afdb..f2f9ea250300 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -520,6 +520,70 @@ describe("pools", () => { ]); }); + it("keeps one email signed in to two orgs as two accounts", () => { + const personal = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com", organization: "Personal" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 36 }] }, + }); + const work = { + ...personal, + instanceId: ProviderInstanceId.make("work"), + auth: { status: "authenticated" as const, email: "same@example.com", organization: "Acme" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 2 }] }, + }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [personal, work] } }], + ]); + expect( + collectLimitAccounts(input).map((account) => [ + account.key, + account.limits.windows[0]?.usedPercent, + ]), + ).toEqual([ + ["env-a:claude", 36], + ["env-a:work", 2], + ]); + }); + + it("joins a hub account to the native org only when one org uses the email", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com", organization: "Acme" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const hub = { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + usageLimits: { checkedAt, windows: [window] }, + }, + ], + }; + const accountsFor = (providers: ServerProvider[]) => + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { ...laptop, serverConfig: { providers, usageLimitSources: [hub] } }, + ], + ]), + ); + expect(accountsFor([native])).toHaveLength(1); + // With two orgs the hub cannot say which it read, so it stays its own row. + const otherOrg = { + ...native, + instanceId: ProviderInstanceId.make("work"), + auth: { ...native.auth, organization: "Personal" }, + }; + expect(accountsFor([native, otherOrg])).toHaveLength(3); + }); + it("keys a hub account without an email by hub, so two environments on one hub share it", () => { const seat = { id: "claude-team-seat.json", diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b969ffb1c531..863753150169 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -149,11 +149,23 @@ function accountKey( : null; } +/** + * One email can belong to several orgs, each with its own quota, so a native + * login that names its org is keyed by it too. Hubs report no org; see + * `collectLimitAccounts` for how they are matched. + */ +function nativeAccountKey(provider: ServerProvider): string | null { + const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const organization = provider.auth.organization?.trim().toLowerCase(); + return key && organization ? `${key}:${organization}` : key; +} + /** * 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, and so does a hub report of a native one. Its quota is one bucket, + * so counting it twice would misstate what is left. The same email in two orgs + * is two accounts with separate quotas. */ export interface LimitAccount { readonly key: string; @@ -246,26 +258,31 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl }, }); }; + // Native keys seen per email, so a hub account (which names no org) joins + // the native one only when exactly one org is signed in with that email. + const nativeKeysByEmail = new Map>(); 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, - }, - ); + const emailKey = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + const key = nativeAccountKey(provider); + if (emailKey && key) { + const keys = nativeKeysByEmail.get(emailKey) ?? new Set(); + nativeKeysByEmail.set(emailKey, keys.add(key)); + } + merge(key ?? `${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 @@ -279,31 +296,30 @@ 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 emailKey = accountKey(account.driver, account.email, account.usageLimits); + const nativeKeys = emailKey ? nativeKeysByEmail.get(emailKey) : undefined; + const key = nativeKeys?.size === 1 ? [...nativeKeys][0] : emailKey; + merge(key ?? `${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 bfe8ed7da515f6bc11b74b260527b52de5b9b540 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Tue, 22 Sep 2026 18:59:37 +0200 Subject: [PATCH 2/6] feat(usage): stack pooled accounts as labeled rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accounts in a window shared one row, each a column of the same bar, so two accounts halved the width and the name was the only thing telling them apart. Give each account its own full-width row, named " · ", and keep its legend row beside it. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/usage/UsageLimitsPooled.tsx | 177 ++++++++++-------- 1 file changed, 98 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index d99ce30b475c..b51754d393e1 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -224,6 +224,7 @@ function SegmentPopover({ */ function PoolSegment({ account, + providerLabel, window, reset, color, @@ -232,6 +233,7 @@ function PoolSegment({ showAccountName, }: { readonly account: LimitAccount; + readonly providerLabel: string; readonly window: LimitPoolMember["window"]; readonly reset: LimitPoolWindow["resets"][number] | undefined; readonly color: string; @@ -246,68 +248,79 @@ function PoolSegment({ const credits = account.limits.resetCredits?.availableCount ?? 0; return ( - - } - > - {/* Translucent so the label reads over the fill for any provider colour and theme. */} -
- {/* The spent share is hatched, not blank: it is what the countdown restores. */} - {remaining < 100 && reset ? ( +
+ + } + > + {/* Translucent so the label reads over the fill for any provider colour and theme. */}
- ) : null} - - {index} - -
- {showAccountName ? ( - ) : null} - {remaining}% - {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} - - {resetsIn?.replace("resets in ", "↻ ") ?? ""} - {credits ? ( - <> - {resetsIn ? ( - - · - - ) : null} - - - {credits} - - - ) : null} + + {index} -
- - +
+ {showAccountName ? ( + + {providerLabel} + · + + + ) : null} + + {remaining}% + + {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? ( + + · + + ) : null} + + + {credits} + + + ) : null} + +
+ + +
{account.redeem ? ( } className="min-w-0 @2xl/pool:hidden" > @@ -369,7 +383,11 @@ function LegendRow({ Segment {index} - + + {providerLabel} + · + + {remaining}% {resetsIn?.replace("resets in ", "↻ ") ?? ""} @@ -451,35 +469,33 @@ function RedeemableSegmentPopup({ */ function PoolBar({ pool, + providerLabel, color, now, }: { readonly pool: LimitPoolWindow; + readonly providerLabel: string; readonly color: string; readonly now: number; }) { const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); return ( -
-
- {pool.columns.map((member, position) => - member.window ? ( - 1} - /> - ) : null, - )} -
+
+ {pool.columns.map((member, position) => + member.window ? ( + 1} + /> + ) : null, + )}
); } @@ -490,12 +506,14 @@ function PoolBar({ */ function PoolWindowCard({ pool, + providerLabel, color, now, label, description, }: { readonly pool: LimitPoolWindow; + readonly providerLabel: string; readonly color: string; readonly now: number; readonly label?: string | undefined; @@ -520,7 +538,7 @@ function PoolWindowCard({ ) : null}
- + {description ? (

{description}

) : null} @@ -550,6 +568,7 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu Date: Tue, 22 Sep 2026 19:08:10 +0200 Subject: [PATCH 3/6] fix(usage): do not repeat the provider name on an unnamed instance Co-Authored-By: Claude Opus 5 (1M context) --- .../components/usage/UsageLimitsPooled.tsx | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index b51754d393e1..d92859742688 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -121,6 +121,37 @@ function AccountName({ ); } +/** + * " · ", so two accounts on one provider are told apart by + * the name their owner gave them. An account with no name of its own would + * render the provider label twice, so it keeps the provider label alone. + */ +function PoolRowName({ + account, + providerLabel, + className, +}: { + readonly account: LimitAccount; + readonly providerLabel: string; + readonly className?: string; +}) { + // An unnamed instance carries the provider's own name, which would print twice. + const named = account.displayName + ? account.displayName.toLowerCase() !== providerLabel.toLowerCase() + : account.email !== undefined; + return ( + + {providerLabel} + {named ? ( + <> + · + + + ) : null} + + ); +} + function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) { return (
@@ -284,11 +315,11 @@ function PoolSegment({
{showAccountName ? ( - - {providerLabel} - · - - + ) : null} {remaining}% @@ -383,11 +414,11 @@ function LegendRow({ Segment {index} - - {providerLabel} - · - - + {remaining}% {resetsIn?.replace("resets in ", "↻ ") ?? ""} From 1805e70637f149f2e23034823bf2c20e406a9724 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Wed, 23 Sep 2026 10:32:44 +0200 Subject: [PATCH 4/6] fix(usage): keep an account's place when a window is missing Stacked rows dropped a column whose account reports nothing in that window, so later accounts moved up and no longer lined up with the same account in the provider's other cards. Reserve the row instead. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/usage/UsageLimitsPooled.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index d92859742688..e8543ff9dd31 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -525,7 +525,11 @@ function PoolBar({ index={position + 1} showAccountName={pool.columns.length > 1} /> - ) : null, + ) : ( + // The account reports nothing in this window, but keeps its place: + // a row holds the same account across every card of this provider. +
+ ), )}
); From b5a901cdafc167ef844714da4369c12e7b634889 Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Fri, 25 Sep 2026 08:56:20 +0200 Subject: [PATCH 5/6] fix(usage): keep a hub reset credit off an email signed in to two orgs A hub reports no org, so when one email is signed in natively to two Claude orgs it cannot say which one it read. Attaching its credit to both rows let a redeem from one org spend the other's reset. Match it to a native login only when that email uses one org, as the pooled view already does; otherwise it keeps its own row and credit. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/shared/src/usageLimits.test.ts | 36 +++++++++++++++++++++++++ packages/shared/src/usageLimits.ts | 16 +++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index f2f9ea250300..3942f6845a75 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1017,6 +1017,42 @@ describe("/usage-limits", () => { expect(report?.accounts[0]?.limits.resetCredits?.availableCount).toBe(3); }); + it("keeps a hub credit off both orgs when one email is signed in to two", () => { + const personal = provider({ + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com", organization: "Personal" }, + }); + const work = { + ...personal, + instanceId: ProviderInstanceId.make("work"), + auth: { ...personal.auth, organization: "Acme" }, + }; + const hub = [ + { + ...sources[0]!, + accounts: [ + { + id: "duplicate", + driver: personal.driver, + email: "same@example.com", + usageLimits: { + ...limits, + resetCredits: { availableCount: 1, nextCreditId: "hub-credit" }, + }, + }, + ], + }, + ]; + const report = collectProviderUsageLimits(personal.instanceId, [personal, work], hub, now); + // The hub cannot say which org it read, so redeeming its credit from + // either native row could spend the other org's reset. + expect(report?.accounts.map((account) => [account.id, account.resetCreditInput])).toEqual([ + [personal.instanceId, { instanceId: personal.instanceId }], + ["work", { instanceId: "work" }], + ["hub:duplicate", { sourceId: "hub", accountId: "duplicate", creditId: "hub-credit" }], + ]); + }); + it("keeps accounts and custom instances separate, filtering by driver", () => { const report = collectProviderUsageLimits( selected.instanceId, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 863753150169..30f71d51423f 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -673,10 +673,22 @@ export function collectProviderUsageLimits( const native = providersWithLimits(providers).filter( (provider) => provider.driver === selected.driver, ); + // A hub reports no org, so it is the same account as a native login only + // when that email is signed in to one org; see `collectLimitAccounts`. + const orgsByEmail = new Map>(); + for (const provider of native) { + const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); + if (key) + orgsByEmail.set(key, (orgsByEmail.get(key) ?? new Set()).add(nativeAccountKey(provider))); + } + const soleOrg = (key: string | null): key is string => + key !== null && orgsByEmail.get(key)?.size === 1; const nativeAccounts = new Set( native.flatMap((provider) => { const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); - return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable + return soleOrg(key) && + provider.usageLimits?.windows.length && + !provider.usageLimits.unavailable ? [key] : []; }), @@ -690,7 +702,7 @@ export function collectProviderUsageLimits( .flatMap((source) => source.accounts.map((account) => ({ source, account }))) .filter( ({ account }) => - key !== null && + soleOrg(key) && accountKey(account.driver, account.email, account.usageLimits) === key && account.usageLimits.resetCredits && !limitsNotice(account.usageLimits), From 80004ea2a1f82e81fb18ebbd71b1f16583907a6f Mon Sep 17 00:00:00 2001 From: lorenzomicheli Date: Mon, 28 Sep 2026 09:08:09 +0200 Subject: [PATCH 6/6] fix(usage): tell Claude orgs apart by their id, not their name Two orgs can share a display name and a name can change, so keying the account on it could merge two quotas or split one. The Claude driver now reads the org UUID the CLI keeps in `.claude.json` (the same one reset redemption uses) onto a new `ServerProviderAuth.accountId`, and the Limits key prefers it, falling back to the display name when no id is known. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/provider/Drivers/ClaudeDriver.ts | 9 ++++++ .../Layers/claudeResetCredits.test.ts | 16 +++++++++++ .../src/provider/Layers/claudeResetCredits.ts | 10 +++++++ packages/contracts/src/server.ts | 10 +++++-- packages/shared/src/usageLimits.test.ts | 28 +++++++++++++++++++ packages/shared/src/usageLimits.ts | 5 ++-- 6 files changed, 74 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b0d7ec6d3ba6..a7902cd3fee7 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -215,6 +215,15 @@ export const ClaudeDriver: ProviderDriver = { ), ), ), + Effect.flatMap((provider) => + provider.auth.status === "authenticated" + ? ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath).pipe( + Effect.map((accountId) => + accountId ? { ...provider, auth: { ...provider.auth, accountId } } : provider, + ), + ) + : Effect.succeed(provider), + ), Effect.map(stampIdentity), ), ), diff --git a/apps/server/src/provider/Layers/claudeResetCredits.test.ts b/apps/server/src/provider/Layers/claudeResetCredits.test.ts index 17f9a03c48bd..5365187e0d98 100644 --- a/apps/server/src/provider/Layers/claudeResetCredits.test.ts +++ b/apps/server/src/provider/Layers/claudeResetCredits.test.ts @@ -257,3 +257,19 @@ effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { }), ); }); + +effectIt.layer(NodeServices.layer)("readClaudeOrganizationId", (it) => { + it.effect("reads the login's org id, and nothing from a missing or garbled account", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { configDir, accountConfigPath } = yield* writeLogin; + const signedIn = yield* ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath); + const missing = yield* ClaudeResetCredits.readClaudeOrganizationId( + `${configDir}/absent.json`, + ); + yield* fs.writeFileString(accountConfigPath, "{not json"); + const garbled = yield* ClaudeResetCredits.readClaudeOrganizationId(accountConfigPath); + expect([signedIn, missing, garbled]).toEqual(["org-1", undefined, undefined]); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/claudeResetCredits.ts b/apps/server/src/provider/Layers/claudeResetCredits.ts index f02c17214c83..fcd2a4d09d1e 100644 --- a/apps/server/src/provider/Layers/claudeResetCredits.ts +++ b/apps/server/src/provider/Layers/claudeResetCredits.ts @@ -195,6 +195,16 @@ export const claudeAccountConfigPath = (configDir: string | undefined) => configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), ); +/** + * The org the login in `accountConfigPath` draws its quota from, or undefined + * when the account record is missing or unreadable. + */ +export const readClaudeOrganizationId = (accountConfigPath: string) => + readJson(Config, accountConfigPath).pipe( + Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), + Effect.orElseSucceed(() => undefined), + ); + const CLAIM_OUTCOMES = { reset: "reset", not_limited: "nothingToReset", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index fe7098c04eae..cd4b7e6fd086 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -66,10 +66,16 @@ export const ServerProviderAuth = Schema.Struct({ subscriptionSharing: Schema.optional(Schema.Boolean), profileId: Schema.optional(TrimmedNonEmptyString), /** - * The organization the login is signed in to, when the provider reports - * one. One email can belong to several orgs, each with its own quota. + * The display name of the organization the login is signed in to, when the + * provider reports one. Names can repeat and change; see `accountId`. */ organization: Schema.optional(TrimmedNonEmptyString), + /** + * A stable id for the organization or workspace whose quota the login draws + * on, such as Claude's organization UUID. One email can belong to several, + * each with its own quota. + */ + accountId: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 3942f6845a75..1e4bfec002b5 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -547,6 +547,34 @@ describe("pools", () => { ]); }); + it("tells orgs apart by their id, not their display name", () => { + const first = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { + status: "authenticated", + email: "same@example.com", + organization: "Acme", + accountId: "org-1", + }, + usageLimits: { checkedAt, windows: [window] }, + }); + const withOrg = (instanceId: string, organization: string, accountId: string) => ({ + ...first, + instanceId: ProviderInstanceId.make(instanceId), + auth: { ...first.auth, organization, accountId }, + }); + const keysFor = (providers: ServerProvider[]) => + collectLimitAccounts( + new Map([[EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers } }]]), + ).map((account) => account.key); + expect(keysFor([first, withOrg("namesake", "Acme", "org-2")])).toEqual([ + "env-a:claude", + "env-a:namesake", + ]); + expect(keysFor([first, withOrg("renamed", "Acme Inc", "org-1")])).toEqual(["env-a:claude"]); + }); + it("joins a hub account to the native org only when one org uses the email", () => { const native = provider({ driver: claude, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 30f71d51423f..d1288ffe9fac 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -151,12 +151,13 @@ function accountKey( /** * One email can belong to several orgs, each with its own quota, so a native - * login that names its org is keyed by it too. Hubs report no org; see + * login that names its org is keyed by it too: by the org's stable id, or by + * its display name when the provider reports no id. Hubs report no org; see * `collectLimitAccounts` for how they are matched. */ function nativeAccountKey(provider: ServerProvider): string | null { const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); - const organization = provider.auth.organization?.trim().toLowerCase(); + const organization = provider.auth.accountId ?? provider.auth.organization?.trim().toLowerCase(); return key && organization ? `${key}:${organization}` : key; }