Skip to content

Commit 0c0cfcb

Browse files
committed
Merge branch 'release/pr-326' into release/integration-1.2.2
2 parents 821553d + 9b6b824 commit 0c0cfcb

3 files changed

Lines changed: 226 additions & 19 deletions

File tree

lib/accounts.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
} from "./accounts/rate-limits.js";
7878

7979
const log = createLogger("accounts");
80+
const ACCOUNT_SELECTION_FRESH_WINDOW_MS = 5 * 60_000;
8081

8182
function initFamilyState(defaultValue: number): Record<ModelFamily, number> {
8283
return Object.fromEntries(
@@ -490,12 +491,45 @@ export class AccountManager {
490491
return account ?? null;
491492
}
492493

494+
private hasFreshAccessToken(account: ManagedAccount): boolean {
495+
if (!account.access) return false;
496+
if (typeof account.expires !== "number" || !Number.isFinite(account.expires)) {
497+
return false;
498+
}
499+
return account.expires > nowMs() + ACCOUNT_SELECTION_FRESH_WINDOW_MS;
500+
}
501+
502+
private isAccountSelectableForFamily(
503+
account: ManagedAccount,
504+
family: ModelFamily,
505+
model?: string | null,
506+
requireFresh = false,
507+
): boolean {
508+
if (account.enabled === false) return false;
509+
clearExpiredRateLimits(account);
510+
if (isRateLimitedForFamily(account, family, model) || this.isAccountCoolingDown(account)) {
511+
return false;
512+
}
513+
return !requireFresh || this.hasFreshAccessToken(account);
514+
}
515+
516+
private hasFreshAvailableAccountForFamily(
517+
family: ModelFamily,
518+
model?: string | null,
519+
): boolean {
520+
return this.accounts.some(
521+
(account) =>
522+
Boolean(account) &&
523+
this.isAccountSelectableForFamily(account, family, model) &&
524+
this.hasFreshAccessToken(account),
525+
);
526+
}
527+
493528
isAccountAvailableForFamily(index: number, family: ModelFamily, model?: string | null): boolean {
494529
const account = this.getAccountByIndex(index);
495530
if (!account) return false;
496-
if (account.enabled === false) return false;
497-
clearExpiredRateLimits(account);
498-
return !isRateLimitedForFamily(account, family, model) && !this.isAccountCoolingDown(account);
531+
const requireFresh = this.hasFreshAvailableAccountForFamily(family, model);
532+
return this.isAccountSelectableForFamily(account, family, model, requireFresh);
499533
}
500534

501535
setActiveIndex(index: number): ManagedAccount | null {
@@ -555,15 +589,13 @@ export class AccountManager {
555589
if (count === 0) return null;
556590

557591
const cursor = this.cursorByFamily[family];
592+
const requireFresh = this.hasFreshAvailableAccountForFamily(family, model);
558593

559594
for (let i = 0; i < count; i++) {
560595
const idx = (cursor + i) % count;
561596
const account = this.accounts[idx];
562597
if (!account) continue;
563-
if (account.enabled === false) continue;
564-
565-
clearExpiredRateLimits(account);
566-
if (isRateLimitedForFamily(account, family, model) || this.isAccountCoolingDown(account)) {
598+
if (!this.isAccountSelectableForFamily(account, family, model, requireFresh)) {
567599
continue;
568600
}
569601

@@ -581,15 +613,13 @@ export class AccountManager {
581613
if (count === 0) return null;
582614

583615
const cursor = this.cursorByFamily[family];
616+
const requireFresh = this.hasFreshAvailableAccountForFamily(family, model);
584617

585618
for (let i = 0; i < count; i++) {
586619
const idx = (cursor + i) % count;
587620
const account = this.accounts[idx];
588621
if (!account) continue;
589-
if (account.enabled === false) continue;
590-
591-
clearExpiredRateLimits(account);
592-
if (isRateLimitedForFamily(account, family, model) || this.isAccountCoolingDown(account)) {
622+
if (!this.isAccountSelectableForFamily(account, family, model, requireFresh)) {
593623
continue;
594624
}
595625

@@ -604,6 +634,7 @@ export class AccountManager {
604634
getCurrentOrNextForFamilyHybrid(family: ModelFamily, model?: string | null, options?: HybridSelectionOptions): ManagedAccount | null {
605635
const count = this.accounts.length;
606636
if (count === 0) return null;
637+
const requireFresh = this.hasFreshAvailableAccountForFamily(family, model);
607638

608639
const currentIndex = this.currentAccountIndexByFamily[family];
609640
if (currentIndex >= 0 && currentIndex < count) {
@@ -612,10 +643,13 @@ export class AccountManager {
612643
if (currentAccount.enabled === false) {
613644
// Fall through to hybrid selection.
614645
} else {
615-
clearExpiredRateLimits(currentAccount);
616646
if (
617-
!isRateLimitedForFamily(currentAccount, family, model) &&
618-
!this.isAccountCoolingDown(currentAccount)
647+
this.isAccountSelectableForFamily(
648+
currentAccount,
649+
family,
650+
model,
651+
requireFresh,
652+
)
619653
) {
620654
currentAccount.lastUsed = nowMs();
621655
return currentAccount;
@@ -631,13 +665,14 @@ export class AccountManager {
631665
const accountsWithMetrics: AccountWithMetrics[] = this.accounts
632666
.map((account): AccountWithMetrics | null => {
633667
if (!account) return null;
634-
if (account.enabled === false) return null;
635-
clearExpiredRateLimits(account);
636-
const isAvailable =
637-
!isRateLimitedForFamily(account, family, model) && !this.isAccountCoolingDown(account);
638668
return {
639669
index: account.index,
640-
isAvailable,
670+
isAvailable: this.isAccountSelectableForFamily(
671+
account,
672+
family,
673+
model,
674+
requireFresh,
675+
),
641676
lastUsed: account.lastUsed,
642677
};
643678
})

test/accounts-load-from-disk.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,4 +268,59 @@ describe("AccountManager loadFromDisk", () => {
268268

269269
expect(manager.getNextForFamily("codex")).toBeNull();
270270
});
271+
272+
it("getNextForFamily falls back to stale accounts when no fresh option exists", () => {
273+
const now = Date.now();
274+
const manager = new AccountManager(undefined, {
275+
version: 3 as const,
276+
activeIndex: 0,
277+
accounts: [
278+
{
279+
refreshToken: "stale-1",
280+
accessToken: "access-1",
281+
expiresAt: now + 60_000,
282+
addedAt: now,
283+
lastUsed: now,
284+
},
285+
{
286+
refreshToken: "stale-2",
287+
accessToken: "access-2",
288+
expiresAt: now + 120_000,
289+
addedAt: now,
290+
lastUsed: now - 5_000,
291+
},
292+
],
293+
} as never);
294+
295+
const selected = manager.getNextForFamily("codex");
296+
expect(selected?.refreshToken).toBe("stale-1");
297+
});
298+
299+
it("getNextForFamily prefers a fresh account when one is available", () => {
300+
const now = Date.now();
301+
const manager = new AccountManager(undefined, {
302+
version: 3 as const,
303+
activeIndex: 0,
304+
activeIndexByFamily: { codex: 0 },
305+
accounts: [
306+
{
307+
refreshToken: "stale-1",
308+
accessToken: "access-1",
309+
expiresAt: now + 60_000,
310+
addedAt: now,
311+
lastUsed: now,
312+
},
313+
{
314+
refreshToken: "token-fresh",
315+
accessToken: "access-fresh",
316+
expiresAt: now + 10 * 60_000,
317+
addedAt: now,
318+
lastUsed: now - 5_000,
319+
},
320+
],
321+
} as never);
322+
323+
const selected = manager.getNextForFamily("codex");
324+
expect(selected?.refreshToken).toBe("token-fresh");
325+
});
271326
});

test/accounts.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,62 @@ describe("AccountManager", () => {
431431
expect(account?.rateLimitResetTimes?.codex).toBeUndefined();
432432
});
433433

434+
it("prefers accounts with fresh access tokens in availability checks", () => {
435+
const now = Date.now();
436+
const stored = {
437+
version: 3 as const,
438+
activeIndex: 0,
439+
accounts: [
440+
{
441+
refreshToken: "token-stale",
442+
accessToken: "access-stale",
443+
expiresAt: now + 60_000,
444+
addedAt: now,
445+
lastUsed: now,
446+
},
447+
{
448+
refreshToken: "token-fresh",
449+
accessToken: "access-fresh",
450+
expiresAt: now + 10 * 60_000,
451+
addedAt: now,
452+
lastUsed: now,
453+
},
454+
],
455+
};
456+
const manager = new AccountManager(undefined, stored);
457+
458+
expect(manager.isAccountAvailableForFamily(0, "codex")).toBe(false);
459+
expect(manager.isAccountAvailableForFamily(1, "codex")).toBe(true);
460+
});
461+
462+
it("keeps stale accounts available when the whole pool is stale", () => {
463+
const now = Date.now();
464+
const stored = {
465+
version: 3 as const,
466+
activeIndex: 0,
467+
accounts: [
468+
{
469+
refreshToken: "token-stale-1",
470+
accessToken: "access-stale-1",
471+
expiresAt: now + 60_000,
472+
addedAt: now,
473+
lastUsed: now,
474+
},
475+
{
476+
refreshToken: "token-stale-2",
477+
accessToken: "access-stale-2",
478+
expiresAt: now + 120_000,
479+
addedAt: now,
480+
lastUsed: now,
481+
},
482+
],
483+
};
484+
const manager = new AccountManager(undefined, stored);
485+
486+
expect(manager.isAccountAvailableForFamily(0, "codex")).toBe(true);
487+
expect(manager.isAccountAvailableForFamily(1, "codex")).toBe(true);
488+
});
489+
434490
it("rotates when the active account is rate-limited", () => {
435491
const now = Date.now();
436492
const stored = {
@@ -639,6 +695,36 @@ describe("AccountManager", () => {
639695
expect(gpt51Second?.refreshToken).toBe("token-2");
640696
});
641697

698+
it("skips a stale active account when a fresher account is available", () => {
699+
const now = Date.now();
700+
const stored = {
701+
version: 3 as const,
702+
activeIndex: 0,
703+
activeIndexByFamily: { codex: 0 },
704+
accounts: [
705+
{
706+
refreshToken: "token-stale",
707+
accessToken: "access-stale",
708+
expiresAt: now + 60_000,
709+
addedAt: now,
710+
lastUsed: now,
711+
},
712+
{
713+
refreshToken: "token-fresh",
714+
accessToken: "access-fresh",
715+
expiresAt: now + 10 * 60_000,
716+
addedAt: now,
717+
lastUsed: now - 5_000,
718+
},
719+
],
720+
};
721+
722+
const manager = new AccountManager(undefined, stored as never);
723+
const selected = manager.getCurrentOrNextForFamily("codex");
724+
725+
expect(selected?.refreshToken).toBe("token-fresh");
726+
});
727+
642728
it("hybrid selection prefers active index when available", () => {
643729
const now = Date.now();
644730
const stored = {
@@ -2603,6 +2689,37 @@ describe("AccountManager", () => {
26032689
expect(secondCall?.index).toBe(selected?.index);
26042690
});
26052691

2692+
it("prefers a fresh alternate account over a stale current account", () => {
2693+
const now = Date.now();
2694+
const stored = {
2695+
version: 3 as const,
2696+
activeIndex: 0,
2697+
activeIndexByFamily: { codex: 0 },
2698+
accounts: [
2699+
{
2700+
refreshToken: "token-stale",
2701+
accessToken: "access-stale",
2702+
expiresAt: now + 60_000,
2703+
addedAt: now,
2704+
lastUsed: now,
2705+
},
2706+
{
2707+
refreshToken: "token-fresh",
2708+
accessToken: "access-fresh",
2709+
expiresAt: now + 10 * 60_000,
2710+
addedAt: now,
2711+
lastUsed: now - 5_000,
2712+
},
2713+
],
2714+
};
2715+
2716+
const manager = new AccountManager(undefined, stored as never);
2717+
const selected = manager.getCurrentOrNextForFamilyHybrid("codex");
2718+
2719+
expect(selected?.refreshToken).toBe("token-fresh");
2720+
expect(selected?.index).toBe(1);
2721+
});
2722+
26062723
it("falls back to least-recently-used when all accounts are rate-limited", () => {
26072724
const now = Date.now();
26082725
const stored = {

0 commit comments

Comments
 (0)