Spaces:
Paused
Paused
icebear icebear0828 commited on
fix: fall back to all accounts when model plan doesn't match (#91)
Browse filesThe backend's /codex/models API returns different model lists per plan
(e.g. free doesn't list gpt-5.4), but free accounts can actually use
those models. Previously, acquire() returned null when no account
matched the model's plan requirements, blocking valid requests.
Now falls back to all available accounts instead of hard-blocking,
since the backend's plan-based model list is incomplete.
Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
- src/auth/account-pool.ts +5 -4
- tests/unit/auth/account-pool.test.ts +504 -0
src/auth/account-pool.ts
CHANGED
|
@@ -96,7 +96,10 @@ export class AccountPool {
|
|
| 96 |
|
| 97 |
if (available.length === 0) return null;
|
| 98 |
|
| 99 |
-
// Model-aware selection: prefer accounts whose planType matches the model's known plans
|
|
|
|
|
|
|
|
|
|
| 100 |
let candidates = available;
|
| 101 |
if (options?.model) {
|
| 102 |
const preferredPlans = getModelPlanTypes(options.model);
|
|
@@ -105,10 +108,8 @@ export class AccountPool {
|
|
| 105 |
const matched = available.filter((a) => a.planType && planSet.has(a.planType));
|
| 106 |
if (matched.length > 0) {
|
| 107 |
candidates = matched;
|
| 108 |
-
} else {
|
| 109 |
-
// No account matches the model's plan requirements — don't fallback to incompatible accounts
|
| 110 |
-
return null;
|
| 111 |
}
|
|
|
|
| 112 |
}
|
| 113 |
}
|
| 114 |
|
|
|
|
| 96 |
|
| 97 |
if (available.length === 0) return null;
|
| 98 |
|
| 99 |
+
// Model-aware selection: prefer accounts whose planType matches the model's known plans.
|
| 100 |
+
// If no account matches, fall back to all available accounts — the backend API's
|
| 101 |
+
// model list per plan is incomplete (e.g. free can use gpt-5.4 but /codex/models
|
| 102 |
+
// doesn't return it), so hard-blocking would reject valid requests.
|
| 103 |
let candidates = available;
|
| 104 |
if (options?.model) {
|
| 105 |
const preferredPlans = getModelPlanTypes(options.model);
|
|
|
|
| 108 |
const matched = available.filter((a) => a.planType && planSet.has(a.planType));
|
| 109 |
if (matched.length > 0) {
|
| 110 |
candidates = matched;
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
+
// else: no matching plan accounts — fall back to all available
|
| 113 |
}
|
| 114 |
}
|
| 115 |
|
tests/unit/auth/account-pool.test.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests for AccountPool core scheduling logic.
|
| 3 |
+
* Migrated from src/auth/__tests__/ with @src/ path aliases.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
| 7 |
+
|
| 8 |
+
// Mock fs before importing AccountPool
|
| 9 |
+
vi.mock("fs", () => ({
|
| 10 |
+
readFileSync: vi.fn(() => { throw new Error("ENOENT"); }),
|
| 11 |
+
writeFileSync: vi.fn(),
|
| 12 |
+
renameSync: vi.fn(),
|
| 13 |
+
existsSync: vi.fn(() => false),
|
| 14 |
+
mkdirSync: vi.fn(),
|
| 15 |
+
}));
|
| 16 |
+
|
| 17 |
+
// Mock paths
|
| 18 |
+
vi.mock("@src/paths.js", () => ({
|
| 19 |
+
getDataDir: vi.fn(() => "/tmp/test-data"),
|
| 20 |
+
getConfigDir: vi.fn(() => "/tmp/test-config"),
|
| 21 |
+
}));
|
| 22 |
+
|
| 23 |
+
// Mock config
|
| 24 |
+
vi.mock("@src/config.js", () => ({
|
| 25 |
+
getConfig: vi.fn(() => ({
|
| 26 |
+
auth: {
|
| 27 |
+
jwt_token: null,
|
| 28 |
+
rotation_strategy: "least_used",
|
| 29 |
+
rate_limit_backoff_seconds: 60,
|
| 30 |
+
},
|
| 31 |
+
server: {
|
| 32 |
+
proxy_api_key: null,
|
| 33 |
+
},
|
| 34 |
+
})),
|
| 35 |
+
}));
|
| 36 |
+
|
| 37 |
+
// Mock JWT utilities — all tokens are "valid"
|
| 38 |
+
vi.mock("@src/auth/jwt-utils.js", () => ({
|
| 39 |
+
decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
|
| 40 |
+
extractChatGptAccountId: vi.fn((token: string) => `acct-${token.slice(0, 8)}`),
|
| 41 |
+
extractUserProfile: vi.fn((token: string) => ({
|
| 42 |
+
email: `${token.slice(0, 4)}@test.com`,
|
| 43 |
+
chatgpt_plan_type: "free",
|
| 44 |
+
})),
|
| 45 |
+
isTokenExpired: vi.fn(() => false),
|
| 46 |
+
}));
|
| 47 |
+
|
| 48 |
+
// Mock jitter to return the exact value (no randomness in tests)
|
| 49 |
+
vi.mock("@src/utils/jitter.js", () => ({
|
| 50 |
+
jitter: vi.fn((val: number) => val),
|
| 51 |
+
}));
|
| 52 |
+
|
| 53 |
+
// Mock model-store for model-aware selection tests
|
| 54 |
+
vi.mock("@src/models/model-store.js", () => ({
|
| 55 |
+
getModelPlanTypes: vi.fn(() => []),
|
| 56 |
+
}));
|
| 57 |
+
|
| 58 |
+
import { AccountPool } from "@src/auth/account-pool.js";
|
| 59 |
+
import { getConfig } from "@src/config.js";
|
| 60 |
+
import { isTokenExpired } from "@src/auth/jwt-utils.js";
|
| 61 |
+
import { getModelPlanTypes } from "@src/models/model-store.js";
|
| 62 |
+
|
| 63 |
+
describe("AccountPool", () => {
|
| 64 |
+
let pool: AccountPool;
|
| 65 |
+
|
| 66 |
+
beforeEach(() => {
|
| 67 |
+
vi.mocked(isTokenExpired).mockReturnValue(false);
|
| 68 |
+
vi.mocked(getConfig).mockReturnValue({
|
| 69 |
+
auth: {
|
| 70 |
+
jwt_token: null,
|
| 71 |
+
rotation_strategy: "least_used",
|
| 72 |
+
rate_limit_backoff_seconds: 60,
|
| 73 |
+
},
|
| 74 |
+
server: { proxy_api_key: null },
|
| 75 |
+
} as ReturnType<typeof getConfig>);
|
| 76 |
+
pool = new AccountPool();
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
afterEach(() => {
|
| 80 |
+
pool.destroy();
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
describe("addAccount + acquire", () => {
|
| 84 |
+
it("adds an account and acquires it", () => {
|
| 85 |
+
pool.addAccount("token-aaa");
|
| 86 |
+
const acquired = pool.acquire();
|
| 87 |
+
expect(acquired).not.toBeNull();
|
| 88 |
+
expect(acquired!.token).toBe("token-aaa");
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
it("deduplicates by accountId", () => {
|
| 92 |
+
const id1 = pool.addAccount("token-aaa");
|
| 93 |
+
const id2 = pool.addAccount("token-aaa");
|
| 94 |
+
expect(id1).toBe(id2);
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
it("returns null when no accounts exist", () => {
|
| 98 |
+
expect(pool.acquire()).toBeNull();
|
| 99 |
+
});
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
describe("least_used rotation", () => {
|
| 103 |
+
it("selects the account with lowest request_count", () => {
|
| 104 |
+
pool.addAccount("token-aaa");
|
| 105 |
+
pool.addAccount("token-bbb");
|
| 106 |
+
|
| 107 |
+
const first = pool.acquire()!;
|
| 108 |
+
pool.release(first.entryId, { input_tokens: 10, output_tokens: 5 });
|
| 109 |
+
|
| 110 |
+
const second = pool.acquire()!;
|
| 111 |
+
expect(second.entryId).not.toBe(first.entryId);
|
| 112 |
+
});
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
describe("round_robin rotation", () => {
|
| 116 |
+
it("cycles through accounts in order", () => {
|
| 117 |
+
vi.mocked(getConfig).mockReturnValue({
|
| 118 |
+
auth: {
|
| 119 |
+
jwt_token: null,
|
| 120 |
+
rotation_strategy: "round_robin",
|
| 121 |
+
rate_limit_backoff_seconds: 60,
|
| 122 |
+
},
|
| 123 |
+
server: { proxy_api_key: null },
|
| 124 |
+
} as ReturnType<typeof getConfig>);
|
| 125 |
+
|
| 126 |
+
const rrPool = new AccountPool();
|
| 127 |
+
rrPool.addAccount("token-aaa");
|
| 128 |
+
rrPool.addAccount("token-bbb");
|
| 129 |
+
|
| 130 |
+
const a1 = rrPool.acquire()!;
|
| 131 |
+
rrPool.release(a1.entryId);
|
| 132 |
+
|
| 133 |
+
const a2 = rrPool.acquire()!;
|
| 134 |
+
rrPool.release(a2.entryId);
|
| 135 |
+
|
| 136 |
+
const a3 = rrPool.acquire()!;
|
| 137 |
+
rrPool.release(a3.entryId);
|
| 138 |
+
|
| 139 |
+
expect(a3.entryId).toBe(a1.entryId);
|
| 140 |
+
expect(a1.entryId).not.toBe(a2.entryId);
|
| 141 |
+
|
| 142 |
+
rrPool.destroy();
|
| 143 |
+
});
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
describe("release", () => {
|
| 147 |
+
it("increments request_count and token usage", () => {
|
| 148 |
+
pool.addAccount("token-aaa");
|
| 149 |
+
|
| 150 |
+
const acquired = pool.acquire()!;
|
| 151 |
+
pool.release(acquired.entryId, { input_tokens: 100, output_tokens: 50 });
|
| 152 |
+
|
| 153 |
+
const accounts = pool.getAccounts();
|
| 154 |
+
expect(accounts[0].usage.request_count).toBe(1);
|
| 155 |
+
expect(accounts[0].usage.input_tokens).toBe(100);
|
| 156 |
+
expect(accounts[0].usage.output_tokens).toBe(50);
|
| 157 |
+
expect(accounts[0].usage.last_used).not.toBeNull();
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
it("unlocks account after release", () => {
|
| 161 |
+
pool.addAccount("token-aaa");
|
| 162 |
+
|
| 163 |
+
const a1 = pool.acquire()!;
|
| 164 |
+
expect(pool.acquire()).toBeNull();
|
| 165 |
+
|
| 166 |
+
pool.release(a1.entryId);
|
| 167 |
+
expect(pool.acquire()).not.toBeNull();
|
| 168 |
+
});
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
describe("markRateLimited", () => {
|
| 172 |
+
it("marks account as rate_limited and skips it in acquire", () => {
|
| 173 |
+
pool.addAccount("token-aaa");
|
| 174 |
+
pool.addAccount("token-bbb");
|
| 175 |
+
|
| 176 |
+
const first = pool.acquire()!;
|
| 177 |
+
pool.markRateLimited(first.entryId);
|
| 178 |
+
|
| 179 |
+
const summary = pool.getPoolSummary();
|
| 180 |
+
expect(summary.rate_limited).toBe(1);
|
| 181 |
+
expect(summary.active).toBe(1);
|
| 182 |
+
|
| 183 |
+
const second = pool.acquire()!;
|
| 184 |
+
expect(second.entryId).not.toBe(first.entryId);
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
it("countRequest option increments usage on 429", () => {
|
| 188 |
+
pool.addAccount("token-aaa");
|
| 189 |
+
|
| 190 |
+
const acquired = pool.acquire()!;
|
| 191 |
+
pool.markRateLimited(acquired.entryId, { countRequest: true });
|
| 192 |
+
|
| 193 |
+
const accounts = pool.getAccounts();
|
| 194 |
+
expect(accounts[0].usage.request_count).toBe(1);
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
it("auto-recovers after rate_limit_until passes", () => {
|
| 198 |
+
pool.addAccount("token-aaa");
|
| 199 |
+
|
| 200 |
+
const acquired = pool.acquire()!;
|
| 201 |
+
pool.markRateLimited(acquired.entryId, { retryAfterSec: -1 });
|
| 202 |
+
|
| 203 |
+
const summary = pool.getPoolSummary();
|
| 204 |
+
expect(summary.active).toBe(1);
|
| 205 |
+
expect(summary.rate_limited).toBe(0);
|
| 206 |
+
});
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
describe("stale lock auto-release", () => {
|
| 210 |
+
it("releases locks older than 5 minutes", () => {
|
| 211 |
+
pool.addAccount("token-aaa");
|
| 212 |
+
|
| 213 |
+
const acquired = pool.acquire()!;
|
| 214 |
+
|
| 215 |
+
const locks = (pool as unknown as { acquireLocks: Map<string, number> }).acquireLocks;
|
| 216 |
+
locks.set(acquired.entryId, Date.now() - 6 * 60 * 1000);
|
| 217 |
+
|
| 218 |
+
const reacquired = pool.acquire()!;
|
| 219 |
+
expect(reacquired).not.toBeNull();
|
| 220 |
+
expect(reacquired.entryId).toBe(acquired.entryId);
|
| 221 |
+
});
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
describe("expired tokens", () => {
|
| 225 |
+
it("skips expired accounts in acquire", () => {
|
| 226 |
+
vi.mocked(isTokenExpired).mockReturnValue(true);
|
| 227 |
+
pool.addAccount("token-expired");
|
| 228 |
+
|
| 229 |
+
expect(pool.acquire()).toBeNull();
|
| 230 |
+
expect(pool.getPoolSummary().expired).toBe(1);
|
| 231 |
+
});
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
describe("removeAccount", () => {
|
| 235 |
+
it("removes an account and clears its lock", () => {
|
| 236 |
+
pool.addAccount("token-aaa");
|
| 237 |
+
|
| 238 |
+
const acquired = pool.acquire()!;
|
| 239 |
+
pool.removeAccount(acquired.entryId);
|
| 240 |
+
|
| 241 |
+
expect(pool.getPoolSummary().total).toBe(0);
|
| 242 |
+
expect(pool.acquire()).toBeNull();
|
| 243 |
+
});
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
describe("resetUsage", () => {
|
| 247 |
+
it("resets counters to zero", () => {
|
| 248 |
+
pool.addAccount("token-aaa");
|
| 249 |
+
|
| 250 |
+
const acquired = pool.acquire()!;
|
| 251 |
+
pool.release(acquired.entryId, { input_tokens: 100, output_tokens: 50 });
|
| 252 |
+
pool.resetUsage(acquired.entryId);
|
| 253 |
+
|
| 254 |
+
const accounts = pool.getAccounts();
|
| 255 |
+
expect(accounts[0].usage.request_count).toBe(0);
|
| 256 |
+
expect(accounts[0].usage.input_tokens).toBe(0);
|
| 257 |
+
expect(accounts[0].usage.output_tokens).toBe(0);
|
| 258 |
+
});
|
| 259 |
+
});
|
| 260 |
+
|
| 261 |
+
describe("validateProxyApiKey", () => {
|
| 262 |
+
it("validates per-account proxy API key", () => {
|
| 263 |
+
pool.addAccount("token-aaa");
|
| 264 |
+
|
| 265 |
+
const accounts = pool.getAccounts();
|
| 266 |
+
const entry = pool.getEntry(accounts[0].id)!;
|
| 267 |
+
expect(pool.validateProxyApiKey(entry.proxyApiKey)).toBe(true);
|
| 268 |
+
expect(pool.validateProxyApiKey("wrong-key")).toBe(false);
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
it("validates config-level proxy API key", () => {
|
| 272 |
+
vi.mocked(getConfig).mockReturnValue({
|
| 273 |
+
auth: {
|
| 274 |
+
jwt_token: null,
|
| 275 |
+
rotation_strategy: "least_used",
|
| 276 |
+
rate_limit_backoff_seconds: 60,
|
| 277 |
+
},
|
| 278 |
+
server: { proxy_api_key: "global-key-123" },
|
| 279 |
+
} as ReturnType<typeof getConfig>);
|
| 280 |
+
|
| 281 |
+
expect(pool.validateProxyApiKey("global-key-123")).toBe(true);
|
| 282 |
+
});
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
// ── Tier 1: Branch coverage additions ────────────────────────────
|
| 286 |
+
|
| 287 |
+
describe("release without usage", () => {
|
| 288 |
+
it("increments window_request_count but not tokens when no usage provided", () => {
|
| 289 |
+
pool.addAccount("token-aaa");
|
| 290 |
+
|
| 291 |
+
const acquired = pool.acquire()!;
|
| 292 |
+
pool.release(acquired.entryId); // no usage param
|
| 293 |
+
|
| 294 |
+
const accounts = pool.getAccounts();
|
| 295 |
+
expect(accounts[0].usage.request_count).toBe(1);
|
| 296 |
+
expect(accounts[0].usage.last_used).not.toBeNull();
|
| 297 |
+
expect(accounts[0].usage.window_request_count).toBe(1);
|
| 298 |
+
// Token counts should stay at zero
|
| 299 |
+
expect(accounts[0].usage.input_tokens).toBe(0);
|
| 300 |
+
expect(accounts[0].usage.output_tokens).toBe(0);
|
| 301 |
+
expect(accounts[0].usage.window_input_tokens).toBe(0);
|
| 302 |
+
expect(accounts[0].usage.window_output_tokens).toBe(0);
|
| 303 |
+
});
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
describe("markRateLimited without countRequest", () => {
|
| 307 |
+
it("does not increment request_count when countRequest not set", () => {
|
| 308 |
+
pool.addAccount("token-aaa");
|
| 309 |
+
|
| 310 |
+
const acquired = pool.acquire()!;
|
| 311 |
+
pool.markRateLimited(acquired.entryId); // no options
|
| 312 |
+
|
| 313 |
+
const accounts = pool.getAccounts();
|
| 314 |
+
expect(accounts[0].usage.request_count).toBe(0);
|
| 315 |
+
expect(accounts[0].usage.window_request_count).toBe(0);
|
| 316 |
+
});
|
| 317 |
+
});
|
| 318 |
+
|
| 319 |
+
describe("model-aware selection", () => {
|
| 320 |
+
it("falls back to all accounts when model has plan requirements but no account matches", () => {
|
| 321 |
+
pool.addAccount("token-aaa"); // planType defaults to "free"
|
| 322 |
+
pool.addAccount("token-bbb");
|
| 323 |
+
|
| 324 |
+
// Mock getModelPlanTypes to require "pro" plan
|
| 325 |
+
vi.mocked(getModelPlanTypes).mockReturnValue(["pro"]);
|
| 326 |
+
|
| 327 |
+
// Should fall back to available accounts instead of returning null,
|
| 328 |
+
// because the backend model list per plan is incomplete
|
| 329 |
+
const acquired = pool.acquire({ model: "gpt-pro-model" });
|
| 330 |
+
expect(acquired).not.toBeNull();
|
| 331 |
+
});
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
describe("window auto-reset", () => {
|
| 335 |
+
it("catches up across multiple expired windows", () => {
|
| 336 |
+
pool.addAccount("token-aaa");
|
| 337 |
+
|
| 338 |
+
const acquired = pool.acquire()!;
|
| 339 |
+
pool.release(acquired.entryId, { input_tokens: 10, output_tokens: 5 });
|
| 340 |
+
|
| 341 |
+
const entry = pool.getEntry(acquired.entryId)!;
|
| 342 |
+
const nowSec = Math.floor(Date.now() / 1000);
|
| 343 |
+
// Set window_reset_at 3 windows ago (window = 3600s)
|
| 344 |
+
entry.usage.window_reset_at = nowSec - 3 * 3600;
|
| 345 |
+
entry.usage.limit_window_seconds = 3600;
|
| 346 |
+
entry.usage.window_request_count = 5;
|
| 347 |
+
entry.usage.window_input_tokens = 100;
|
| 348 |
+
entry.usage.window_output_tokens = 50;
|
| 349 |
+
|
| 350 |
+
// Trigger refreshStatus via getAccounts
|
| 351 |
+
const accounts = pool.getAccounts();
|
| 352 |
+
|
| 353 |
+
// Window counters should be reset
|
| 354 |
+
expect(accounts[0].usage.window_request_count).toBe(0);
|
| 355 |
+
expect(accounts[0].usage.window_input_tokens).toBe(0);
|
| 356 |
+
expect(accounts[0].usage.window_output_tokens).toBe(0);
|
| 357 |
+
// Next window_reset_at should be in the future
|
| 358 |
+
expect(accounts[0].usage.window_reset_at).not.toBeNull();
|
| 359 |
+
expect(accounts[0].usage.window_reset_at!).toBeGreaterThan(nowSec);
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
it("sets window_reset_at to null when limit_window_seconds is null", () => {
|
| 363 |
+
pool.addAccount("token-aaa");
|
| 364 |
+
|
| 365 |
+
const acquired = pool.acquire()!;
|
| 366 |
+
pool.release(acquired.entryId);
|
| 367 |
+
|
| 368 |
+
const entry = pool.getEntry(acquired.entryId)!;
|
| 369 |
+
const nowSec = Math.floor(Date.now() / 1000);
|
| 370 |
+
entry.usage.window_reset_at = nowSec - 100; // expired
|
| 371 |
+
entry.usage.limit_window_seconds = null;
|
| 372 |
+
entry.usage.window_request_count = 5;
|
| 373 |
+
|
| 374 |
+
const accounts = pool.getAccounts();
|
| 375 |
+
|
| 376 |
+
expect(accounts[0].usage.window_request_count).toBe(0);
|
| 377 |
+
expect(accounts[0].usage.window_reset_at).toBeNull();
|
| 378 |
+
});
|
| 379 |
+
});
|
| 380 |
+
|
| 381 |
+
describe("syncRateLimitWindow", () => {
|
| 382 |
+
it("does not reset counters when timestamp is unchanged", () => {
|
| 383 |
+
pool.addAccount("token-aaa");
|
| 384 |
+
|
| 385 |
+
const acquired = pool.acquire()!;
|
| 386 |
+
pool.release(acquired.entryId, { input_tokens: 10, output_tokens: 5 });
|
| 387 |
+
|
| 388 |
+
const entry = pool.getEntry(acquired.entryId)!;
|
| 389 |
+
// Use a future timestamp so refreshStatus does NOT auto-reset
|
| 390 |
+
const futureResetAt = Math.floor(Date.now() / 1000) + 7200;
|
| 391 |
+
entry.usage.window_reset_at = futureResetAt;
|
| 392 |
+
|
| 393 |
+
// Sync with the SAME timestamp — should be a no-op
|
| 394 |
+
pool.syncRateLimitWindow(acquired.entryId, futureResetAt, 3600);
|
| 395 |
+
|
| 396 |
+
// Counters should NOT be reset (same timestamp)
|
| 397 |
+
const afterEntry = pool.getEntry(acquired.entryId)!;
|
| 398 |
+
expect(afterEntry.usage.window_request_count).toBe(1);
|
| 399 |
+
expect(afterEntry.usage.window_input_tokens).toBe(10);
|
| 400 |
+
expect(afterEntry.usage.window_output_tokens).toBe(5);
|
| 401 |
+
});
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
describe("least_used window_reset_at tiebreaker", () => {
|
| 405 |
+
it("prefers accounts with earlier window_reset_at on same request_count", () => {
|
| 406 |
+
pool.addAccount("token-aaa");
|
| 407 |
+
pool.addAccount("token-bbb");
|
| 408 |
+
|
| 409 |
+
const accounts = pool.getAccounts();
|
| 410 |
+
const entryA = pool.getEntry(accounts[0].id)!;
|
| 411 |
+
const entryB = pool.getEntry(accounts[1].id)!;
|
| 412 |
+
|
| 413 |
+
const nowSec = Math.floor(Date.now() / 1000);
|
| 414 |
+
// A resets in 1 hour, B resets in 5 hours
|
| 415 |
+
entryA.usage.window_reset_at = nowSec + 3600;
|
| 416 |
+
entryB.usage.window_reset_at = nowSec + 18000;
|
| 417 |
+
|
| 418 |
+
const acquired = pool.acquire()!;
|
| 419 |
+
pool.release(acquired.entryId);
|
| 420 |
+
// Should prefer A (sooner reset)
|
| 421 |
+
expect(acquired.entryId).toBe(entryA.id);
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
it("ranks accounts with null window_reset_at after those with known values", () => {
|
| 425 |
+
pool.addAccount("token-aaa");
|
| 426 |
+
pool.addAccount("token-bbb");
|
| 427 |
+
|
| 428 |
+
const accounts = pool.getAccounts();
|
| 429 |
+
const entryA = pool.getEntry(accounts[0].id)!;
|
| 430 |
+
const entryB = pool.getEntry(accounts[1].id)!;
|
| 431 |
+
|
| 432 |
+
// A has no window info, B has a known reset
|
| 433 |
+
entryA.usage.window_reset_at = null;
|
| 434 |
+
entryB.usage.window_reset_at = Math.floor(Date.now() / 1000) + 7200;
|
| 435 |
+
|
| 436 |
+
const acquired = pool.acquire()!;
|
| 437 |
+
pool.release(acquired.entryId);
|
| 438 |
+
// Should prefer B (known reset) over A (unknown/Infinity)
|
| 439 |
+
expect(acquired.entryId).toBe(entryB.id);
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
it("falls back to last_used LRU when window_reset_at is equal", () => {
|
| 443 |
+
pool.addAccount("token-aaa");
|
| 444 |
+
pool.addAccount("token-bbb");
|
| 445 |
+
|
| 446 |
+
const accounts = pool.getAccounts();
|
| 447 |
+
const entryA = pool.getEntry(accounts[0].id)!;
|
| 448 |
+
const entryB = pool.getEntry(accounts[1].id)!;
|
| 449 |
+
|
| 450 |
+
const resetAt = Math.floor(Date.now() / 1000) + 3600;
|
| 451 |
+
entryA.usage.window_reset_at = resetAt;
|
| 452 |
+
entryB.usage.window_reset_at = resetAt;
|
| 453 |
+
// A used more recently, B used earlier → B should be preferred (LRU)
|
| 454 |
+
entryA.usage.last_used = new Date(Date.now() - 1000).toISOString();
|
| 455 |
+
entryB.usage.last_used = new Date(Date.now() - 60000).toISOString();
|
| 456 |
+
|
| 457 |
+
const acquired = pool.acquire()!;
|
| 458 |
+
pool.release(acquired.entryId);
|
| 459 |
+
expect(acquired.entryId).toBe(entryB.id);
|
| 460 |
+
});
|
| 461 |
+
});
|
| 462 |
+
|
| 463 |
+
describe("loadPersisted edge cases", () => {
|
| 464 |
+
it("skips entries without id or token", async () => {
|
| 465 |
+
const { existsSync: mockExistsSync, readFileSync: mockReadFileSync } = await import("fs");
|
| 466 |
+
|
| 467 |
+
vi.mocked(mockExistsSync).mockReturnValue(false); // no legacy
|
| 468 |
+
vi.mocked(mockReadFileSync).mockImplementation(() => {
|
| 469 |
+
throw new Error("ENOENT");
|
| 470 |
+
});
|
| 471 |
+
|
| 472 |
+
// Create fresh pool with specific persisted data
|
| 473 |
+
vi.mocked(mockExistsSync).mockImplementation((path: unknown) => {
|
| 474 |
+
if (typeof path === "string" && path.includes("accounts.json")) return true;
|
| 475 |
+
return false;
|
| 476 |
+
});
|
| 477 |
+
vi.mocked(mockReadFileSync).mockReturnValue(JSON.stringify({
|
| 478 |
+
accounts: [
|
| 479 |
+
{ id: null, token: "valid-token", usage: {} }, // missing id
|
| 480 |
+
{ id: "entry1", token: null, usage: {} }, // missing token
|
| 481 |
+
{ id: "entry2", token: "valid-token-2", usage: { request_count: 0, input_tokens: 0, output_tokens: 0, last_used: null, rate_limit_until: null }, planType: "free", email: "test@test.com", accountId: "acct-123" },
|
| 482 |
+
],
|
| 483 |
+
}));
|
| 484 |
+
|
| 485 |
+
const freshPool = new AccountPool();
|
| 486 |
+
expect(freshPool.getPoolSummary().total).toBe(1); // only entry2 loaded
|
| 487 |
+
freshPool.destroy();
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
it("handles invalid JSON gracefully", async () => {
|
| 491 |
+
const { existsSync: mockExistsSync, readFileSync: mockReadFileSync } = await import("fs");
|
| 492 |
+
|
| 493 |
+
vi.mocked(mockExistsSync).mockImplementation((path: unknown) => {
|
| 494 |
+
if (typeof path === "string" && path.includes("accounts.json")) return true;
|
| 495 |
+
return false;
|
| 496 |
+
});
|
| 497 |
+
vi.mocked(mockReadFileSync).mockReturnValue("NOT VALID JSON {{{");
|
| 498 |
+
|
| 499 |
+
const freshPool = new AccountPool();
|
| 500 |
+
expect(freshPool.getPoolSummary().total).toBe(0);
|
| 501 |
+
freshPool.destroy();
|
| 502 |
+
});
|
| 503 |
+
});
|
| 504 |
+
});
|