| 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 | 262 | |
| 263 | 263 | type ClaudeCapabilitiesProbe = { |
| 264 | 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 | 270 | readonly subscriptionType: string | undefined; |
| 266 | 271 | readonly tokenSource: string | undefined; |
| 267 | 272 | /** Where the CLI found an API key, when it authenticates with one. */ |
| ... | ... | @@ -679,6 +684,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( |
| 679 | 684 | auth: { |
| 680 | 685 | status: "authenticated", |
| 681 | 686 | ...(capabilities.email ? { email: capabilities.email } : {}), |
| 687 | ...(capabilities.workspaceId ? { workspaceId: capabilities.workspaceId } : {}), | |
| 682 | 688 | ...(authMetadata ? authMetadata : {}), |
| 683 | 689 | }, |
| 684 | 690 | ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), |
apps/server/src/provider/CodexProvider.ts+10-2| ... | ... | @@ -60,6 +60,8 @@ type CodexRateLimitsProbe = |
| 60 | 60 | | null |
| 61 | 61 | | undefined; |
| 62 | 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 | 66 | | { readonly failure: string }; |
| 65 | 67 | |
| ... | ... | @@ -456,6 +458,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 456 | 458 | snapshot: response.rateLimits, |
| 457 | 459 | rateLimitsByLimitId: response.rateLimitsByLimitId, |
| 458 | 460 | resetCredits: response.rateLimitResetCredits, |
| 461 | accountId: response.accountId, | |
| 459 | 462 | })), |
| 460 | 463 | Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), |
| 461 | 464 | Effect.map( |
| ... | ... | @@ -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 | 546 | readonly status: Exclude<ServerProviderState, "disabled">; |
| 544 | 547 | readonly auth: ServerProvider["auth"]; |
| 545 | 548 | readonly message?: string; |
| 546 | 549 | } { |
| 547 | 550 | const authLabel = codexAccountAuthLabel(account.account); |
| 548 | 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 | 556 | const auth = { |
| 550 | 557 | status: account.account ? ("authenticated" as const) : ("unknown" as const), |
| 551 | 558 | ...(account.account?.type ? { type: account.account?.type } : {}), |
| 552 | 559 | ...(authLabel ? { label: authLabel } : {}), |
| 553 | 560 | ...(authEmail ? { email: authEmail } : {}), |
| 561 | ...(workspaceId ? { workspaceId } : {}), | |
| 554 | 562 | } satisfies ServerProvider["auth"]; |
| 555 | 563 | |
| 556 | 564 | if (account.account) { |
| ... | ... | @@ -669,7 +677,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 669 | 677 | const snapshot = probeResult.success.value; |
| 670 | 678 | const accountStatus = managedAuth |
| 671 | 679 | ? { status: "ready" as const, auth: managedAuth, message: undefined } |
| 672 | : accountProbeStatus(snapshot.account); | |
| 680 | : accountProbeStatus(snapshot); | |
| 673 | 681 | const usageLimits = |
| 674 | 682 | snapshot.account.account?.type === "apiKey" |
| 675 | 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 | 203 | capacity: 1, |
| 204 | 204 | timeToLive: CAPABILITIES_PROBE_TTL, |
| 205 | 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 | 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 | 189 | ); |
| 190 | 190 | |
| 191 | 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 | 199 | export const claudeAccountConfigPath = Effect.fn("claudeAccountConfigPath")(function* ( |
| 193 | 200 | configDir: string | undefined, |
| 194 | 201 | ) { |
apps/server/src/usage/cliproxyApi.ts+3| ... | ... | @@ -215,6 +215,9 @@ export const makeCliproxyApi = Effect.gen(function* () { |
| 215 | 215 | id: account.id, |
| 216 | 216 | driver: ProviderDriverKind.make(account.provider === "codex" ? "codex" : "claudeAgent"), |
| 217 | 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 | 222 | const read = Effect.gen(function* () { |
| 220 | 223 | if (account.provider === "claude") { |
packages/contracts/src/providerUsageLimits.ts+2| ... | ... | @@ -92,6 +92,8 @@ export const UsageLimitSourceAccount = Schema.Struct({ |
| 92 | 92 | email: Schema.optional(TrimmedNonEmptyString), |
| 93 | 93 | /** Plan as the matching provider would label it (`ChatGPT Pro 20x Subscription`). */ |
| 94 | 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 | 97 | usageLimits: ServerProviderUsageLimits, |
| 96 | 98 | }); |
| 97 | 99 | export type UsageLimitSourceAccount = typeof UsageLimitSourceAccount.Type; |
packages/contracts/src/server.ts+6| ... | ... | @@ -69,6 +69,12 @@ export const ServerProviderAuth = Schema.Struct({ |
| 69 | 69 | canLogout: Schema.optional(Schema.Boolean), |
| 70 | 70 | subscriptionSharing: Schema.optional(Schema.Boolean), |
| 71 | 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 | 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 | 591 | checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( |
| 592 | 592 | Effect.map((count) => |
| 593 | 593 | count === 1 |
| 594 | ? { ...refreshedSnapshot, usageLimits: probedLimits } | |
| 594 | ? { | |
| 595 | ...refreshedSnapshot, | |
| 596 | auth: { ...refreshedSnapshot.auth, workspaceId: "ws-a" }, | |
| 597 | usageLimits: probedLimits, | |
| 598 | } | |
| 595 | 599 | : { |
| 596 | 600 | ...refreshedSnapshotSecond, |
| 601 | ...(count === 3 | |
| 602 | ? { auth: { ...refreshedSnapshotSecond.auth, workspaceId: "ws-b" } } | |
| 603 | : {}), | |
| 597 | 604 | usageLimits: { |
| 598 | 605 | checkedAt: "2026-04-10T00:00:03.000Z", |
| 599 | 606 | windows: [], |
| ... | ... | @@ -640,6 +647,13 @@ describe("makeManagedServerProvider", () => { |
| 640 | 647 | const refreshed = yield* provider.refresh; |
| 641 | 648 | assert.strictEqual(refreshed.message, refreshedSnapshotSecond.message); |
| 642 | 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 | 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 | 155 | const generation = input.enrichSnapshot |
| 156 | 156 | ? state.enrichmentGeneration + 1 |
| 157 | 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 | 176 | const snapshot = withUsageLimits( |
| 159 | probedSnapshot, | |
| 160 | resolveUsageLimitsAfterProbe({ | |
| 161 | published: state.snapshot.usageLimits, | |
| 162 | probed: probedSnapshot.usageLimits, | |
| 163 | }), | |
| 177 | workspaceId && !probedSnapshot.auth.workspaceId | |
| 178 | ? { ...probedSnapshot, auth: { ...probedSnapshot.auth, workspaceId } } | |
| 179 | : probedSnapshot, | |
| 180 | usageLimits, | |
| 164 | 181 | ); |
| 165 | 182 | return [ |
| 166 | 183 | { snapshot, generation }, |
packages/shared/src/usageLimits.test.ts+190| ... | ... | @@ -496,6 +496,128 @@ describe("pools", () => { |
| 496 | 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 | 621 | it("names an environment once however many of its instances share the account", () => { |
| 500 | 622 | const shared = provider({ |
| 501 | 623 | auth: { status: "authenticated", email: "same@example.com" }, |
| ... | ... | @@ -536,6 +658,45 @@ describe("pools", () => { |
| 536 | 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 | 700 | it("pools windows by id across accounts and orders resets by when they land", () => { |
| 540 | 701 | const input = new Map([ |
| 541 | 702 | [ |
| ... | ... | @@ -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 | 1103 | it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => { |
| 914 | 1104 | const fresher = provider({ |
| 915 | 1105 | usageLimits: { |
packages/shared/src/usageLimits.ts+113-52| ... | ... | @@ -125,11 +125,51 @@ function accountKey( |
| 125 | 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 | 169 | * One subscription account as the pooled views see it, whichever way it was |
| 130 | * reported. Matching emails or credentials across environments name | |
| 131 | * one account. Its quota is one bucket, so counting it twice would misstate | |
| 132 | * what is left. | |
| 170 | * reported. Matching emails or credentials across environments name one | |
| 171 | * account, unless they name different workspaces. Its quota is one bucket, so | |
| 172 | * counting it twice would misstate what is left. | |
| 133 | 173 | */ |
| 134 | 174 | export interface LimitAccount { |
| 135 | 175 | readonly key: string; |
| ... | ... | @@ -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 | 273 | for (const [environmentId, presentation] of presentations) { |
| 226 | 274 | const label = presentation.entry.target.label; |
| 227 | 275 | for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { |
| 228 | 276 | if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; |
| 229 | merge( | |
| 230 | accountKey(provider.driver, provider.auth.email, provider.usageLimits) ?? | |
| 231 | `${environmentId}:${provider.instanceId}`, | |
| 232 | { | |
| 233 | key: `${environmentId}:${provider.instanceId}`, | |
| 234 | driver: provider.driver, | |
| 235 | displayName: provider.displayName?.trim() || null, | |
| 236 | email: provider.auth.email, | |
| 237 | plan: provider.auth.label, | |
| 238 | accentColor: provider.accentColor, | |
| 239 | environments: [{ environmentId, label }], | |
| 240 | sourceLabel: null, | |
| 241 | redeem: { environmentId, input: { instanceId: provider.instanceId } }, | |
| 242 | limits: provider.usageLimits, | |
| 243 | }, | |
| 244 | ); | |
| 277 | merge(resolveKey(nativeReport(provider)) ?? `${environmentId}:${provider.instanceId}`, { | |
| 278 | key: `${environmentId}:${provider.instanceId}`, | |
| 279 | driver: provider.driver, | |
| 280 | displayName: provider.displayName?.trim() || null, | |
| 281 | email: provider.auth.email, | |
| 282 | plan: provider.auth.label, | |
| 283 | accentColor: provider.accentColor, | |
| 284 | environments: [{ environmentId, label }], | |
| 285 | sourceLabel: null, | |
| 286 | redeem: { environmentId, input: { instanceId: provider.instanceId } }, | |
| 287 | limits: provider.usageLimits, | |
| 288 | }); | |
| 245 | 289 | } |
| 246 | 290 | } |
| 247 | 291 | // Every hub account, including those a native instance also knows: the hub |
| ... | ... | @@ -255,31 +299,28 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl |
| 255 | 299 | : source.label; |
| 256 | 300 | for (const account of source.accounts) { |
| 257 | 301 | if (limitsNotice(account.usageLimits) !== null) continue; |
| 258 | merge( | |
| 259 | accountKey(account.driver, account.email, account.usageLimits) ?? | |
| 260 | `${source.id}:${account.id}`, | |
| 261 | { | |
| 262 | key: `${source.id}:${account.id}`, | |
| 263 | driver: account.driver, | |
| 264 | displayName: account.email ? null : account.id.replace(/\.json$/i, ""), | |
| 265 | email: account.email, | |
| 266 | plan: account.plan, | |
| 267 | accentColor: undefined, | |
| 268 | environments: [], | |
| 269 | sourceLabel, | |
| 270 | redeem: account.usageLimits.resetCredits?.nextCreditId | |
| 271 | ? { | |
| 272 | environmentId, | |
| 273 | input: { | |
| 274 | sourceId: source.id, | |
| 275 | accountId: account.id, | |
| 276 | creditId: account.usageLimits.resetCredits.nextCreditId, | |
| 277 | }, | |
| 278 | } | |
| 279 | : null, | |
| 280 | limits: account.usageLimits, | |
| 281 | }, | |
| 282 | ); | |
| 302 | const key = `${source.id}:${account.id}${account.workspaceId ? `:${account.workspaceId}` : ""}`; | |
| 303 | merge(resolveKey(account) ?? key, { | |
| 304 | key, | |
| 305 | driver: account.driver, | |
| 306 | displayName: account.email ? null : account.id.replace(/\.json$/i, ""), | |
| 307 | email: account.email, | |
| 308 | plan: account.plan, | |
| 309 | accentColor: undefined, | |
| 310 | environments: [], | |
| 311 | sourceLabel, | |
| 312 | redeem: account.usageLimits.resetCredits?.nextCreditId | |
| 313 | ? { | |
| 314 | environmentId, | |
| 315 | input: { | |
| 316 | sourceId: source.id, | |
| 317 | accountId: account.id, | |
| 318 | creditId: account.usageLimits.resetCredits.nextCreditId, | |
| 319 | }, | |
| 320 | } | |
| 321 | : null, | |
| 322 | limits: account.usageLimits, | |
| 323 | }); | |
| 283 | 324 | } |
| 284 | 325 | } |
| 285 | 326 | } |
| ... | ... | @@ -401,7 +442,7 @@ export function collectLimitPools( |
| 401 | 442 | .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; |
| 402 | 443 | const orderReset = (account: LimitAccount) => { |
| 403 | 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 | 447 | return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; |
| 407 | 448 | }; |
| ... | ... | @@ -419,19 +460,35 @@ function accountSortName(account: LimitAccount): string { |
| 419 | 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 | 472 | function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { |
| 423 | 473 | const byKey = new Map<string, LimitPoolMember[]>(); |
| 424 | 474 | for (const account of accounts) { |
| 425 | 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 | 481 | const list = byKey.get(key); |
| 428 | 482 | if (list) list.push({ account, window }); |
| 429 | 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 | 487 | const memberByAccount = new Map(members.map((member) => [member.account.key, member])); |
| 434 | 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 | 492 | const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; |
| 436 | 493 | // Pace compares spend against the clock, so it is judged only over the |
| 437 | 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 | 515 | }) |
| 459 | 516 | .sort((left, right) => left.at - right.at); |
| 460 | 517 | return { |
| 461 | id: first.id, | |
| 518 | id, | |
| 462 | 519 | kind: first.kind, |
| 463 | 520 | label: first.label, |
| 464 | 521 | members, |
| ... | ... | @@ -633,9 +690,13 @@ export function collectProviderUsageLimits( |
| 633 | 690 | const native = providersWithLimits(providers).filter( |
| 634 | 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 | 697 | const nativeAccounts = new Set( |
| 637 | 698 | native.flatMap((provider) => { |
| 638 | const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); | |
| 699 | const key = resolveKey(nativeReport(provider)); | |
| 639 | 700 | return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable |
| 640 | 701 | ? [key] |
| 641 | 702 | : []; |
| ... | ... | @@ -645,13 +706,13 @@ export function collectProviderUsageLimits( |
| 645 | 706 | const notices: string[] = []; |
| 646 | 707 | for (const provider of native) { |
| 647 | 708 | if (!provider.usageLimits) continue; |
| 648 | const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits); | |
| 709 | const key = resolveKey(nativeReport(provider)); | |
| 649 | 710 | const hubCredits = sources |
| 650 | 711 | .flatMap((source) => source.accounts.map((account) => ({ source, account }))) |
| 651 | 712 | .filter( |
| 652 | 713 | ({ account }) => |
| 653 | 714 | key !== null && |
| 654 | accountKey(account.driver, account.email, account.usageLimits) === key && | |
| 715 | resolveKey(account) === key && | |
| 655 | 716 | account.usageLimits.resetCredits && |
| 656 | 717 | !limitsNotice(account.usageLimits), |
| 657 | 718 | ) |
| ... | ... | @@ -698,7 +759,7 @@ export function collectProviderUsageLimits( |
| 698 | 759 | for (const source of sources) { |
| 699 | 760 | const matching = source.accounts.filter((account) => account.driver === selected.driver); |
| 700 | 761 | for (const account of matching) { |
| 701 | const key = accountKey(account.driver, account.email, account.usageLimits); | |
| 762 | const key = resolveKey(account); | |
| 702 | 763 | if (key && nativeAccounts.has(key)) continue; |
| 703 | 764 | accounts.push({ |
| 704 | 765 | id: `${source.id}:${account.id}`, |