authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 20:39:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 20:39:48-07:00
log6abdf37a50ce6c1c9fabc499f4d0e159a6182d90
tree0d30e4ceb0550ccd97b00ba3f0b34612ef495218
parentf729e8fd837e4d6de86781351c3aee5a4ea19ab3
signature Signed by PGP key B5690EEEBB952194

fix(shared): redeem reset credits through the hub when it holds the account (#10462)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

2 files changed, 117 insertions(+), 8 deletions(-)

packages/shared/src/usageLimits.test.ts+89
......@@ -433,6 +433,55 @@ describe("pools", () => {
433433 expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]);
434434 });
435435
436 it("redeems through the hub when it holds a credit, even with a fresher native read", () => {
437 const native = provider({
438 driver: claude,
439 instanceId: ProviderInstanceId.make("claude"),
440 auth: { status: "authenticated", email: "same@example.com" },
441 usageLimits: {
442 checkedAt: "2026-09-03T11:30:00.000Z",
443 windows: [{ ...window, usedPercent: 40 }],
444 resetCredits: { availableCount: 3, nextCreditId: "native-credit" },
445 },
446 });
447 const input = new Map([
448 [
449 EnvironmentId.make("env-a"),
450 {
451 ...laptop,
452 serverConfig: {
453 providers: [native],
454 usageLimitSources: [
455 {
456 ...source,
457 accounts: [
458 {
459 id: "claude-same@example.com.json",
460 driver: claude,
461 email: "same@example.com",
462 usageLimits: {
463 checkedAt,
464 windows: [{ ...window, usedPercent: 55 }],
465 resetCredits: { availableCount: 2, nextCreditId: "hub-credit" },
466 },
467 },
468 ],
469 },
470 ],
471 },
472 },
473 ],
474 ]);
475 const [account] = collectLimitAccounts(input);
476 // Only the hub path clears the routing cooldown it holds for this account.
477 expect(account?.redeem).toEqual({
478 environmentId: "env-a",
479 input: { sourceId: "hub", accountId: "claude-same@example.com.json", creditId: "hub-credit" },
480 });
481 // The fresher native balance is still the one shown.
482 expect(account?.limits.resetCredits?.availableCount).toBe(3);
483 });
484
436485 it("redeems on the environment whose snapshot supplied the credits on show", () => {
437486 const stale = provider({
438487 auth: { status: "authenticated", email: "same@example.com" },
......@@ -839,6 +888,46 @@ describe("/usage-limits", () => {
839888 });
840889 });
841890
891 it("redeems a native duplicate through the hub even when the native snapshot is fresher", () => {
892 const fresher = provider({
893 usageLimits: {
894 checkedAt: "2026-09-03T11:30:00.000Z",
895 windows: [window],
896 resetCredits: { availableCount: 3, nextCreditId: "native-credit" },
897 },
898 auth: { status: "authenticated", email: "same@example.com" },
899 });
900 const stale = [
901 {
902 id: UsageLimitSourceId.make("hub"),
903 kind: "cliproxy" as const,
904 label: "Accounts",
905 checkedAt: limits.checkedAt,
906 accounts: [
907 {
908 id: "duplicate",
909 driver: fresher.driver,
910 email: "SAME@example.com",
911 usageLimits: {
912 ...limits,
913 resetCredits: { availableCount: 2, nextCreditId: "hub-credit" },
914 },
915 },
916 ],
917 },
918 ];
919 const report = collectProviderUsageLimits(fresher.instanceId, [fresher], stale, now);
920 // Only redeeming through the hub clears the routing cooldown it holds for
921 // this account, so the hub wins the path even with a staler balance.
922 expect(report?.accounts[0]?.resetCreditInput).toEqual({
923 sourceId: "hub",
924 accountId: "duplicate",
925 creditId: "hub-credit",
926 });
927 // The fresher native balance is still the one shown.
928 expect(report?.accounts[0]?.limits.resetCredits?.availableCount).toBe(3);
929 });
930
842931 it("keeps accounts and custom instances separate, filtering by driver", () => {
843932 const report = collectProviderUsageLimits(
844933 selected.instanceId,
packages/shared/src/usageLimits.ts+28-8
......@@ -189,7 +189,21 @@ export function collectLimitAccounts(
189189): readonly LimitAccount[] {
190190 const accounts = new Map<string, LimitAccount>();
191191 const creditSources = new Map<string, LimitAccount>();
192 const hubRedeems = new Map<string, LimitAccount>();
192193 const merge = (key: string, next: LimitAccount) => {
194 // Redeeming through a hub also clears the routing cooldown that hub holds
195 // for the account. Redeeming natively against the same subscription resets
196 // it upstream but leaves the hub refusing to route to the account until
197 // its own cooldown expires, so a hub target wins the redemption outright
198 // while the displayed balance still follows the freshest read.
199 const previousHub = hubRedeems.get(key);
200 if (
201 next.redeem &&
202 "sourceId" in next.redeem.input &&
203 (!previousHub || Date.parse(next.limits.checkedAt) > Date.parse(previousHub.limits.checkedAt))
204 ) {
205 hubRedeems.set(key, next);
206 }
193207 const previousCredit = creditSources.get(key);
194208 if (
195209 next.limits.resetCredits &&
......@@ -224,9 +238,9 @@ export function collectLimitAccounts(
224238 environments,
225239 // A hub only names the account when no environment has it natively.
226240 sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel),
227 redeem: creditSource
228 ? creditSource.redeem
229 : (winner.redeem ?? previous.redeem ?? next.redeem),
241 redeem:
242 hubRedeems.get(key)?.redeem ??
243 (creditSource ? creditSource.redeem : (winner.redeem ?? previous.redeem ?? next.redeem)),
230244 limits: {
231245 ...winner.limits,
232246 ...(creditSource?.limits.resetCredits
......@@ -645,14 +659,20 @@ export function collectProviderUsageLimits(
645659 (a, b) =>
646660 Date.parse(b.account.usageLimits.checkedAt) - Date.parse(a.account.usageLimits.checkedAt),
647661 )[0];
648 const useHubCredits =
662 // Two independent decisions. Which balance to *display* follows whichever
663 // snapshot is fresher. Which path to *redeem through* always prefers the
664 // hub, because only the hub path clears the routing cooldown it holds for
665 // that account; redeeming natively against the same account resets the
666 // subscription upstream but leaves the hub refusing to route to it until
667 // its own cooldown expires. A hub credit id that a fresher native redeem
668 // already spent comes back as `alreadyRedeemed`, which still clears the
669 // cooldown, so preferring it is safe even when the hub snapshot is stale.
670 const hubCreditId = hubCredits?.account.usageLimits.resetCredits?.nextCreditId;
671 const showHubCredits =
649672 hubCredits &&
650673 (!provider.usageLimits.resetCredits ||
651674 Date.parse(hubCredits.account.usageLimits.checkedAt) >
652675 Date.parse(provider.usageLimits.checkedAt));
653 const hubCreditId = useHubCredits
654 ? hubCredits.account.usageLimits.resetCredits?.nextCreditId
655 : undefined;
656676 accounts.push({
657677 id: provider.instanceId,
658678 driver: provider.driver,
......@@ -670,7 +690,7 @@ export function collectProviderUsageLimits(
670690 ...(provider.displayName ? { displayName: provider.displayName } : {}),
671691 ...(provider.accentColor ? { accentColor: provider.accentColor } : {}),
672692 ...(provider.auth.email ? { email: provider.auth.email } : {}),
673 limits: useHubCredits
693 limits: showHubCredits
674694 ? { ...provider.usageLimits, resetCredits: hubCredits.account.usageLimits.resetCredits }
675695 : provider.usageLimits,
676696 });