icebear0828 commited on
Commit
f14db3a
·
1 Parent(s): 51d99e7

Revert "fix: fall back to all accounts when model plan doesn't match (#91)"

Browse files

This reverts commit 5abff3872cdb12703c51d0eb110c7e67f0dd415d.

src/auth/account-pool.ts CHANGED
@@ -96,10 +96,7 @@ 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
- // 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,8 +105,10 @@ export class AccountPool {
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
 
 
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
  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
 
tests/unit/auth/account-pool.test.ts DELETED
@@ -1,504 +0,0 @@
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
- });