| author | |
| committer | |
| log | 0be20c86c2962b7b581dfc79df9ab34b4fbdab14 |
| tree | c8a4a416296ae369640176c1659ecbfcca1936e6 |
| parent | c899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 382 insertions(+), 61 deletions(-)
apps/server/src/provider/ClaudeProvider.ts+6| ... | @@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined { | ... | @@ -262,6 +262,11 @@ function nonEmptyProbeString(value: string): string | undefined { |
| 262 | 262 | ||
| 263 | type ClaudeCapabilitiesProbe = { | 263 | type ClaudeCapabilitiesProbe = { |
| 264 | readonly email: string | undefined; | 264 | 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; | ||
| 265 | readonly subscriptionType: string | undefined; | 270 | readonly subscriptionType: string | undefined; |
| 266 | readonly tokenSource: string | undefined; | 271 | readonly tokenSource: string | undefined; |
| 267 | /** Where the CLI found an API key, when it authenticates with one. */ | 272 | /** Where the CLI found an API key, when it authenticates with one. */ |
| ... | @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( | ... | @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 679 | auth: { | 684 | auth: { |
| 680 | status: "authenticated", | 685 | status: "authenticated", |
| 681 | ...(capabilities.email ? { email: capabilities.email } : {}), | 686 | ...(capabilities.email ? { email: capabilities.email } : {}), |
| 687 | ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}), | ||
| 682 | ...(authMetadata ? authMetadata : {}), | 688 | ...(authMetadata ? authMetadata : {}), |
| 683 | }, | 689 | }, |
| 684 | ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), | 690 | ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), |
apps/server/src/provider/CodexProvider.ts+10-2| ... | @@ -60,6 +60,8 @@ type CodexRateLimitsProbe = | ... | @@ -60,6 +60,8 @@ type CodexRateLimitsProbe = |
| 60 | | null | 60 | | null |
| 61 | | undefined; | 61 | | undefined; |
| 62 | readonly resetCredits: CodexResetCreditsSummary | null | undefined; | 62 | readonly resetCredits: CodexResetCreditsSummary | null | undefined; |
| 63 | /** The ChatGPT workspace the usage belongs to, when the backend names it. */ | ||
| 64 | readonly accountId?: string | null | undefined; | ||
| 63 | } | 65 | } |
| 64 | | { readonly failure: string }; | 66 | | { readonly failure: string }; |
| 65 | 67 | ||
| ... | @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun | ... | @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 456 | snapshot: response.rateLimits, | 458 | snapshot: response.rateLimits, |
| 457 | rateLimitsByLimitId: response.rateLimitsByLimitId, | 459 | rateLimitsByLimitId: response.rateLimitsByLimitId, |
| 458 | resetCredits: response.rateLimitResetCredits, | 460 | resetCredits: response.rateLimitResetCredits, |
| 461 | accountId: response.accountId, | ||
| 459 | })), | 462 | })), |
| 460 | Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), | 463 | Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), |
| 461 | Effect.map( | 464 | Effect.map( |
| ... | @@ -539,18 +542,23 @@ const makePendingCodexProvider = ( | ... | @@ -539,18 +542,23 @@ const makePendingCodexProvider = ( |
| 539 | }); | 542 | }); |
| 540 | }); | 543 | }); |
| 541 | 544 | ||
| 542 | function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { | 545 | function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): { |
| 543 | readonly status: Exclude<ServerProviderState, "disabled">; | 546 | readonly status: Exclude<ServerProviderState, "disabled">; |
| 544 | readonly auth: ServerProvider["auth"]; | 547 | readonly auth: ServerProvider["auth"]; |
| 545 | readonly message?: string; | 548 | readonly message?: string; |
| 546 | } { | 549 | } { |
| 547 | const authLabel = codexAccountAuthLabel(account.account); | 550 | const authLabel = codexAccountAuthLabel(account.account); |
| 548 | const authEmail = codexAccountEmail(account.account); | 551 | 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; | ||
| 549 | const auth = { | 556 | const auth = { |
| 550 | status: account.account ? ("authenticated" as const) : ("unknown" as const), | 557 | status: account.account ? ("authenticated" as const) : ("unknown" as const), |
| 551 | ...(account.account?.type ? { type: account.account?.type } : {}), | 558 | ...(account.account?.type ? { type: account.account?.type } : {}), |
| 552 | ...(authLabel ? { label: authLabel } : {}), | 559 | ...(authLabel ? { label: authLabel } : {}), |
| 553 | ...(authEmail ? { email: authEmail } : {}), | 560 | ...(authEmail ? { email: authEmail } : {}), |
| 561 | ...(workspaceId ? { workspaceId } : {}), | ||
| 554 | } satisfies ServerProvider["auth"]; | 562 | } satisfies ServerProvider["auth"]; |
| 555 | 563 | ||
| 556 | if (account.account) { | 564 | if (account.account) { |
| ... | @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu | ... | @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 669 | const snapshot = probeResult.success.value; | 677 | const snapshot = probeResult.success.value; |
| 670 | const accountStatus = managedAuth | 678 | const accountStatus = managedAuth |
| 671 | ? { status: "ready" as const, auth: managedAuth, message: undefined } | 679 | ? { status: "ready" as const, auth: managedAuth, message: undefined } |
| 672 | : accountProbeStatus(snapshot.account); | 680 | : accountProbeStatus(snapshot); |
| 673 | const usageLimits = | 681 | const usageLimits = |
| 674 | snapshot.account.account?.type === "apiKey" | 682 | snapshot.account.account?.type === "apiKey" |
| 675 | ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) | 683 | ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) |
apps/server/src/provider/Drivers/ClaudeDriver.ts+8-1| ... | @@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path. | ... | @@ -203,7 +203,14 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path. |
| 203 | capacity: 1, | 203 | capacity: 1, |
| 204 | timeToLive: CAPABILITIES_PROBE_TTL, | 204 | timeToLive: CAPABILITIES_PROBE_TTL, |
| 205 | lookup: () => | 205 | 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), | ||
| 207 | Effect.provideService(Path.Path, path), | 214 | Effect.provideService(Path.Path, path), |
| 208 | ), | 215 | ), |
| 209 | }); | 216 | }); |
apps/server/src/provider/claudeResetCredits.ts+7| ... | @@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( | ... | @@ -189,6 +189,13 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( |
| 189 | ); | 189 | ); |
| 190 | 190 | ||
| 191 | /** The CLI keeps the account record beside its settings, or in the home directory by default. */ | 191 | /** 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. */ | ||
| 193 | export const readClaudeOrganizationId = (accountConfigPath: string) => | ||
| 194 | readJson(Config, accountConfigPath).pipe( | ||
| 195 | Effect.map((config) => config.oauthAccount?.organizationUuid?.trim() || undefined), | ||
| 196 | Effect.orElseSucceed(() => undefined), | ||
| 197 | ); | ||
| 198 | |||
| 192 | export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( | 199 | export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( |
| 193 | configDir: string | undefined, | 200 | configDir: string | undefined, |
| 194 | ) { | 201 | ) { |
apps/server/src/usage/cliproxyApi.ts+3| ... | @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () { | ... | @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () { |
| 215 | id: account.id, | 215 | id: account.id, |
| 216 | driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), | 216 | driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), |
| 217 | ...(account.email ? { email: account.email } : {}), | 217 | ...(account.email ? { email: account.email } : {}), |
| 218 | ...(account.id_token?.chatgpt_account_id?.trim() | ||
| 219 | ? { workspaceId: account.id_token.chatgpt_account_id.trim() } | ||
| 220 | : {}), | ||
| 218 | }; | 221 | }; |
| 219 | const read = Effect.gen(function* () { | 222 | const read = Effect.gen(function* () { |
| 220 | if (account.provider === "claude") { | 223 | if (account.provider === "claude") { |
packages/contracts/src/providerUsageLimits.ts+2| ... | @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ | ... | @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ |
| 92 | email: Schema.optional(TrimmedNonEmptyString), | 92 | email: Schema.optional(TrimmedNonEmptyString), |
| 93 | /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ | 93 | /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ |
| 94 | plan: Schema.optional(TrimmedNonEmptyString), | 94 | plan: Schema.optional(TrimmedNonEmptyString), |
| 95 | /** The workspace the quota belongs to, when the source names one; see `ServerProviderAuth`. */ | ||
| 96 | workspaceId: Schema.optional(TrimmedNonEmptyString), | ||
| 95 | usageLimits: ServerProviderUsageLimits, | 97 | usageLimits: ServerProviderUsageLimits, |
| 96 | }); | 98 | }); |
| 97 | export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; | 99 | export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; |
packages/contracts/src/server.ts+6| ... | @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({ | ... | @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({ |
| 69 | canLogout: Schema.optional(Schema.Boolean), | 69 | canLogout: Schema.optional(Schema.Boolean), |
| 70 | subscriptionSharing: Schema.optional(Schema.Boolean), | 70 | subscriptionSharing: Schema.optional(Schema.Boolean), |
| 71 | profileId: Schema.optional(TrimmedNonEmptyString), | 71 | 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), | ||
| 72 | }); | 78 | }); |
| 73 | export type ServerProviderAuth = typeof ServerProviderAuth.Type; | 79 | export type ServerProviderAuth = typeof ServerProviderAuth.Type; |
| 74 | 80 |
packages/provider-core/src/server/managedProvider.test.ts+15-1| ... | @@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => { | ... | @@ -591,9 +591,16 @@ describe("makeManagedServerProvider", () => { |
| 591 | checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( | 591 | checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( |
| 592 | Effect.map((count) => | 592 | Effect.map((count) => |
| 593 | count === 1 | 593 | count === 1 |
| 594 | ? { ...refreshedSnapshot, usageLimits: probedLimits } | 594 | ? { |
| 595 | ...refreshedSnapshot, | ||
| 596 | auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" }, | ||
| 597 | usageLimits: probedLimits, | ||
| 598 | } | ||
| 595 | : { | 599 | : { |
| 596 | ...refreshedSnapshotSecond, | 600 | ...refreshedSnapshotSecond, |
| 601 | ...(count === 3 | ||
| 602 | ? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } } | ||
| 603 | : {}), | ||
| 597 | usageLimits: { | 604 | usageLimits: { |
| 598 | checkedAt: "2026-04-10T00:00:03.000Z", | 605 | checkedAt: "2026-04-10T00:00:03.000Z", |
| 599 | windows: [], | 606 | windows: [], |
| ... | @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => { | ... | @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => { |
| 640 | const refreshed = yield* provider.refresh; | 647 | const refreshed = yield* provider.refresh; |
| 641 | assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); | 648 | assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); |
| 642 | assert.deepStrictEqual(refreshed.usageLimits?.windows, [liveWindow]); | 649 | 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"); | ||
| 643 | }), | 657 | }), |
| 644 | ).pipe(Effect.provide(layerAlwaysRunTest)), | 658 | ).pipe(Effect.provide(layerAlwaysRunTest)), |
| 645 | ); | 659 | ); |
packages/provider-core/src/server/managedProvider.ts+22-5| ... | @@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( | ... | @@ -155,12 +155,29 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( |
| 155 | const generation = input.enrichSnapshot | 155 | const generation = input.enrichSnapshot |
| 156 | ? state.enrichmentGeneration + 1 | 156 | ? state.enrichmentGeneration + 1 |
| 157 | : state.enrichmentGeneration; | 157 | : 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; | ||
| 158 | const snapshot = withUsageLimits( | 176 | const snapshot = withUsageLimits( |
| 159 | probedSnapshot, | 177 | workspaceId && !probedSnapshot.auth.workspaceId |
| 160 | resolveUsageLimitsAfterProbe({ | 178 | ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } |
| 161 | published: state.snapshot.usageLimits, | 179 | : probedSnapshot, |
| 162 | probed: probedSnapshot.usageLimits, | 180 | usageLimits, |
| 163 | }), | ||
| 164 | ); | 181 | ); |
| 165 | return [ | 182 | return [ |
| 166 | { snapshot, generation }, | 183 | { snapshot, generation }, |
packages/shared/src/usageLimits.test.ts+190| ... | @@ -496,6 +496,128 @@ describe("pools", () => { | ... | @@ -496,6 +496,128 @@ describe("pools", () => { |
| 496 | expect(account?.redeem?.environmentId).toBe("env-2"); | 496 | expect(account?.redeem?.environmentId).toBe("env-2"); |
| 497 | }); | 497 | }); |
| 498 | 498 | ||
| 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 | |||
| 499 | it("names an environment once however many of its instances share the account", () => { | 621 | it("names an environment once however many of its instances share the account", () => { |
| 500 | const shared = provider({ | 622 | const shared = provider({ |
| 501 | auth: { status: "authenticated", email: "same@example.com" }, | 623 | auth: { status: "authenticated", email: "same@example.com" }, |
| ... | @@ -536,6 +658,45 @@ describe("pools", () => { | ... | @@ -536,6 +658,45 @@ describe("pools", () => { |
| 536 | expect(accounts[0]?.displayName).toBe("claude-team-seat"); | 658 | expect(accounts[0]?.displayName).toBe("claude-team-seat"); |
| 537 | }); | 659 | }); |
| 538 | 660 | ||
| 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 | |||
| 539 | it("pools windows by id across accounts and orders resets by when they land", () => { | 700 | it("pools windows by id across accounts and orders resets by when they land", () => { |
| 540 | const input = new Map([ | 701 | const input = new Map([ |
| 541 | [ | 702 | [ |
| ... | @@ -910,6 +1071,35 @@ describe("/usage-limits", () => { | ... | @@ -910,6 +1071,35 @@ describe("/usage-limits", () => { |
| 910 | }); | 1071 | }); |
| 911 | }); | 1072 | }); |
| 912 | 1073 | ||
| 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 | |||
| 913 | it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => { | 1103 | it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => { |
| 914 | const fresher = provider({ | 1104 | const fresher = provider({ |
| 915 | usageLimits: { | 1105 | usageLimits: { |
packages/shared/src/usageLimits.ts+113-52| ... | @@ -125,11 +125,51 @@ function accountKey( | ... | @@ -125,11 +125,51 @@ function accountKey( |
| 125 | : null; | 125 | : null; |
| 126 | } | 126 | } |
| 127 | 127 | ||
| 128 | interface AccountReport { | ||
| 129 | readonly driver: ServerProvider["driver"]; | ||
| 130 | readonly email?: string | undefined; | ||
| 131 | readonly workspaceId?: string | undefined; | ||
| 132 | readonly usageLimits?: ServerProviderUsageLimits | undefined; | ||
| 133 | } | ||
| 134 | |||
| 135 | const 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 | */ | ||
| 150 | function 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 | |||
| 128 | /** | 168 | /** |
| 129 | * One subscription account as the pooled views see it, whichever way it was | 169 | * One subscription account as the pooled views see it, whichever way it was |
| 130 | * reported. Matching emails or credentials across environments name | 170 | * reported. Matching emails or credentials across environments name one |
| 131 | * one account. Its quota is one bucket, so counting it twice would misstate | 171 | * account, unless they name different workspaces. Its quota is one bucket, so |
| 132 | * what is left. | 172 | * counting it twice would misstate what is left. |
| 133 | */ | 173 | */ |
| 134 | export interface LimitAccount { | 174 | export interface LimitAccount { |
| 135 | readonly key: string; | 175 | readonly key: string; |
| ... | @@ -222,26 +262,30 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl | ... | @@ -222,26 +262,30 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl |
| 222 | }, | 262 | }, |
| 223 | }); | 263 | }); |
| 224 | }; | 264 | }; |
| 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 | ); | ||
| 225 | for (const [environmentId, presentation] of presentations) { | 273 | for (const [environmentId, presentation] of presentations) { |
| 226 | const label = presentation.entry.target.label; | 274 | const label = presentation.entry.target.label; |
| 227 | for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { | 275 | for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { |
| 228 | if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; | 276 | if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; |
| 229 | merge( | 277 | merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, { |
| 230 | accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? | 278 | key: `${environmentId}:${provider.instanceId}`, |
| 231 | `${environmentId}:${provider.instanceId}`, | 279 | driver: provider.driver, |
| 232 | { | 280 | displayName: provider.displayName?.trim() || null, |
| 233 | key: `${environmentId}:${provider.instanceId}`, | 281 | email: provider.auth.email, |
| 234 | driver: provider.driver, | 282 | plan: provider.auth.label, |
| 235 | displayName: provider.displayName?.trim() || null, | 283 | accentColor: provider.accentColor, |
| 236 | email: provider.auth.email, | 284 | environments: [{ environmentId, label }], |
| 237 | plan: provider.auth.label, | 285 | sourceLabel: null, |
| 238 | accentColor: provider.accentColor, | 286 | redeem: { environmentId, input: { instanceId: provider.instanceId } }, |
| 239 | environments: [{ environmentId, label }], | 287 | limits: provider.usageLimits, |
| 240 | sourceLabel: null, | 288 | }); |
| 241 | redeem: { environmentId, input: { instanceId: provider.instanceId } }, | ||
| 242 | limits: provider.usageLimits, | ||
| 243 | }, | ||
| 244 | ); | ||
| 245 | } | 289 | } |
| 246 | } | 290 | } |
| 247 | // Every hub account, including those a native instance also knows: the hub | 291 | // Every hub account, including those a native instance also knows: the hub |
| ... | @@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl | ... | @@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl |
| 255 | : source.label; | 299 | : source.label; |
| 256 | for (const account of source.accounts) { | 300 | for (const account of source.accounts) { |
| 257 | if (limitsNotice(account.usageLimits) !== null) continue; | 301 | if (limitsNotice(account.usageLimits) !== null) continue; |
| 258 | merge( | 302 | const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; |
| 259 | accountKey(account.driver, account.email, account.usageLimits) ?? | 303 | merge(resolveKey(account) ?? key, { |
| 260 | `${source.id}:${account.id}`, | 304 | key, |
| 261 | { | 305 | driver: account.driver, |
| 262 | key: `${source.id}:${account.id}`, | 306 | displayName: account.email ? null : account.id.replace(/\.json$/i, ""), |
| 263 | driver: account.driver, | 307 | email: account.email, |
| 264 | displayName: account.email ? null : account.id.replace(/\.json$/i, ""), | 308 | plan: account.plan, |
| 265 | email: account.email, | 309 | accentColor: undefined, |
| 266 | plan: account.plan, | 310 | environments: [], |
| 267 | accentColor: undefined, | 311 | sourceLabel, |
| 268 | environments: [], | 312 | redeem: account.usageLimits.resetCredits?.nextCreditId |
| 269 | sourceLabel, | 313 | ? { |
| 270 | redeem: account.usageLimits.resetCredits?.nextCreditId | 314 | environmentId, |
| 271 | ? { | 315 | input: { |
| 272 | environmentId, | 316 | sourceId: source.id, |
| 273 | input: { | 317 | accountId: account.id, |
| 274 | sourceId: source.id, | 318 | creditId: account.usageLimits.resetCredits.nextCreditId, |
| 275 | accountId: account.id, | 319 | }, |
| 276 | creditId: account.usageLimits.resetCredits.nextCreditId, | 320 | } |
| 277 | }, | 321 | : null, |
| 278 | } | 322 | limits: account.usageLimits, |
| 279 | : null, | 323 | }); |
| 280 | limits: account.usageLimits, | ||
| 281 | }, | ||
| 282 | ); | ||
| 283 | } | 324 | } |
| 284 | } | 325 | } |
| 285 | } | 326 | } |
| ... | @@ -401,7 +442,7 @@ export function collectLimitPools( | ... | @@ -401,7 +442,7 @@ export function collectLimitPools( |
| 401 | .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; | 442 | .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; |
| 402 | const orderReset = (account: LimitAccount) => { | 443 | const orderReset = (account: LimitAccount) => { |
| 403 | const window = account.limits.windows.find( | 444 | 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), |
| 405 | ); | 446 | ); |
| 406 | return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; | 447 | return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; |
| 407 | }; | 448 | }; |
| ... | @@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string { | ... | @@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string { |
| 419 | return (account.displayName ?? account.email ?? account.key).toLowerCase(); | 460 | return (account.displayName ?? account.email ?? account.key).toLowerCase(); |
| 420 | } | 461 | } |
| 421 | 462 | ||
| 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 | */ | ||
| 468 | function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { | ||
| 469 | return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; | ||
| 470 | } | ||
| 471 | |||
| 422 | function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { | 472 | function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { |
| 423 | const byKey = new Map<string, LimitPoolMember[]>(); | 473 | const byKey = new Map<string, LimitPoolMember[]>(); |
| 424 | for (const account of accounts) { | 474 | for (const account of accounts) { |
| 425 | for (const window of account.limits.windows) { | 475 | 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; | ||
| 427 | const list = byKey.get(key); | 481 | const list = byKey.get(key); |
| 428 | if (list) list.push({ account, window }); | 482 | if (list) list.push({ account, window }); |
| 429 | else byKey.set(key, [{ account, window }]); | 483 | else byKey.set(key, [{ account, window }]); |
| 430 | } | 484 | } |
| 431 | } | 485 | } |
| 432 | const pools = [...byKey.values()].map((members): LimitPoolWindow => { | 486 | const pools = [...byKey].map(([key, members]): LimitPoolWindow => { |
| 433 | const memberByAccount = new Map(members.map((member) => [member.account.key, member])); | 487 | const memberByAccount = new Map(members.map((member) => [member.account.key, member])); |
| 434 | const first = members[0]!.window; | 488 | 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; | ||
| 435 | const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; | 492 | const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; |
| 436 | // Pace compares spend against the clock, so it is judged only over the | 493 | // Pace compares spend against the clock, so it is judged only over the |
| 437 | // members that have a clock; a window with no reset would otherwise | 494 | // 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 | ... | @@ -458,7 +515,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L |
| 458 | }) | 515 | }) |
| 459 | .sort((left, right) => left.at - right.at); | 516 | .sort((left, right) => left.at - right.at); |
| 460 | return { | 517 | return { |
| 461 | id: first.id, | 518 | id, |
| 462 | kind: first.kind, | 519 | kind: first.kind, |
| 463 | label: first.label, | 520 | label: first.label, |
| 464 | members, | 521 | members, |
| ... | @@ -633,9 +690,13 @@ export function collectProviderUsageLimits( | ... | @@ -633,9 +690,13 @@ export function collectProviderUsageLimits( |
| 633 | const native = providersWithLimits(providers).filter( | 690 | const native = providersWithLimits(providers).filter( |
| 634 | (provider) => provider.driver === selected.driver, | 691 | (provider) => provider.driver === selected.driver, |
| 635 | ); | 692 | ); |
| 693 | const resolveKey = accountKeyResolver([ | ||
| 694 | ...providers.filter((provider) => provider.enabled).map(nativeReport), | ||
| 695 | ...sources.flatMap((source) => source.accounts), | ||
| 696 | ]); | ||
| 636 | const nativeAccounts = new Set( | 697 | const nativeAccounts = new Set( |
| 637 | native.flatMap((provider) => { | 698 | native.flatMap((provider) => { |
| 638 | const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); | 699 | const key = resolveKey(nativeReport(provider)); |
| 639 | return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable | 700 | return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable |
| 640 | ? [key] | 701 | ? [key] |
| 641 | : []; | 702 | : []; |
| ... | @@ -645,13 +706,13 @@ export function collectProviderUsageLimits( | ... | @@ -645,13 +706,13 @@ export function collectProviderUsageLimits( |
| 645 | const notices: string[] = []; | 706 | const notices: string[] = []; |
| 646 | for (const provider of native) { | 707 | for (const provider of native) { |
| 647 | if (!provider.usageLimits) continue; | 708 | if (!provider.usageLimits) continue; |
| 648 | const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); | 709 | const key = resolveKey(nativeReport(provider)); |
| 649 | const hubCredits = sources | 710 | const hubCredits = sources |
| 650 | .flatMap((source) => source.accounts.map((account) => ({ source, account }))) | 711 | .flatMap((source) => source.accounts.map((account) => ({ source, account }))) |
| 651 | .filter( | 712 | .filter( |
| 652 | ({ account }) => | 713 | ({ account }) => |
| 653 | key !== null && | 714 | key !== null && |
| 654 | accountKey(account.driver, account.email, account.usageLimits) === key && | 715 | resolveKey(account) === key && |
| 655 | account.usageLimits.resetCredits && | 716 | account.usageLimits.resetCredits && |
| 656 | !limitsNotice(account.usageLimits), | 717 | !limitsNotice(account.usageLimits), |
| 657 | ) | 718 | ) |
| ... | @@ -698,7 +759,7 @@ export function collectProviderUsageLimits( | ... | @@ -698,7 +759,7 @@ export function collectProviderUsageLimits( |
| 698 | for (const source of sources) { | 759 | for (const source of sources) { |
| 699 | const matching = source.accounts.filter((account) => account.driver === selected.driver); | 760 | const matching = source.accounts.filter((account) => account.driver === selected.driver); |
| 700 | for (const account of matching) { | 761 | for (const account of matching) { |
| 701 | const key = accountKey(account.driver, account.email, account.usageLimits); | 762 | const key = resolveKey(account); |
| 702 | if (key && nativeAccounts.has(key)) continue; | 763 | if (key && nativeAccounts.has(key)) continue; |
| 703 | accounts.push({ | 764 | accounts.push({ |
| 704 | id: `${source.id}:${account.id}`, | 765 | id: `${source.id}:${account.id}`, |