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/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/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/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index d99ce30b475c..e8543ff9dd31 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 (
@@ -224,6 +255,7 @@ function SegmentPopover({ */ function PoolSegment({ account, + providerLabel, window, reset, color, @@ -232,6 +264,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 +279,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 ? ( + + ) : 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 +414,11 @@ function LegendRow({ Segment {index} - + {remaining}% {resetsIn?.replace("resets in ", "↻ ") ?? ""} @@ -451,35 +500,37 @@ 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} + /> + ) : ( + // The account reports nothing in this window, but keeps its place: + // a row holds the same account across every card of this provider. +
+ ), + )}
); } @@ -490,12 +541,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 +573,7 @@ function PoolWindowCard({ ) : null}
- + {description ? (

{description}

) : null} @@ -550,6 +603,7 @@ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: nu { ]); }); + 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("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, + 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", @@ -953,6 +1045,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 b969ffb1c531..d1288ffe9fac 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -149,11 +149,24 @@ 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: 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.accountId ?? 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 +259,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 +297,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, + }); } } } @@ -657,10 +674,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] : []; }), @@ -674,7 +703,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),