icebear icebear0828 commited on
Commit
d0eeb87
·
unverified ·
1 Parent(s): 360774d

feat: add sticky rotation strategy (#107) (#110)

Browse files

* feat: add sticky rotation strategy with admin UI (#107)

Keep using the same account until rate-limited or quota exhausted,
instead of rotating across multiple accounts.

- Add "sticky" to rotation_strategy config enum
- Implement sticky sort in selectByStrategy() (last_used desc)
- Add GET/POST /admin/rotation-settings endpoints
- Add RotationSettings component (sticky vs rotation radio group)
- Add i18n translations (en + zh)
- 13 new tests (account-pool-sticky + rotation-settings routes)

* refactor: extract ROTATION_STRATEGIES constant to eliminate hardcoded duplication

Single source of truth for valid rotation strategies — route handler
and Zod schema both reference the exported constant.

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -8,6 +8,13 @@
8
 
9
  ### Added
10
 
 
 
 
 
 
 
 
11
  - `POST /admin/refresh-models` 端点:手动触发模型列表刷新,解决 model-fetcher ~1h 缓存过时导致新模型不可用的问题;支持 Bearer auth(当配置 proxy_api_key 时)
12
  - Plan routing integration tests:通过 proxy handler 完整路径验证 free/team 账号的模型路由(7 cases),覆盖 plan map 更新后请求解除阻塞的场景
13
 
 
8
 
9
  ### Added
10
 
11
+ - Sticky rotation strategy(#107):新增 `sticky` 账号轮换策略,持续使用同一账号直到限速或额度耗尽
12
+ - `src/config.ts`:`rotation_strategy` 枚举新增 `"sticky"` 选项
13
+ - `selectByStrategy()` 按 `last_used` 降序排列,优先复用最近使用的账号
14
+ - `GET/POST /admin/rotation-settings` 端点:读取和更新轮换策略(支持 Bearer auth)
15
+ - Dashboard:RotationSettings 组件(粘滞 vs 轮换两层 radio group)
16
+ - i18n:中英文翻译(策略名称 + 描述)
17
+ - 13 个新测试覆盖 sticky 选择逻辑 + 路由端点
18
  - `POST /admin/refresh-models` 端点:手动触发模型列表刷新,解决 model-fetcher ~1h 缓存过时导致新模型不可用的问题;支持 Bearer auth(当配置 proxy_api_key 时)
19
  - Plan routing integration tests:通过 proxy handler 完整路径验证 free/team 账号的模型路由(7 cases),覆盖 plan map 更新后请求解除阻塞的场景
20
 
shared/hooks/use-rotation-settings.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "preact/hooks";
2
+
3
+ export type RotationStrategy = "least_used" | "round_robin" | "sticky";
4
+
5
+ export interface RotationSettingsData {
6
+ rotation_strategy: RotationStrategy;
7
+ }
8
+
9
+ export function useRotationSettings(apiKey: string | null) {
10
+ const [data, setData] = useState<RotationSettingsData | null>(null);
11
+ const [saving, setSaving] = useState(false);
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [saved, setSaved] = useState(false);
14
+
15
+ const load = useCallback(async () => {
16
+ try {
17
+ const resp = await fetch("/admin/rotation-settings");
18
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
19
+ const result: RotationSettingsData = await resp.json();
20
+ setData(result);
21
+ setError(null);
22
+ } catch (err) {
23
+ setError(err instanceof Error ? err.message : String(err));
24
+ }
25
+ }, []);
26
+
27
+ const save = useCallback(async (patch: Partial<RotationSettingsData>) => {
28
+ setSaving(true);
29
+ setSaved(false);
30
+ setError(null);
31
+ try {
32
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
33
+ if (apiKey) {
34
+ headers["Authorization"] = `Bearer ${apiKey}`;
35
+ }
36
+ const resp = await fetch("/admin/rotation-settings", {
37
+ method: "POST",
38
+ headers,
39
+ body: JSON.stringify(patch),
40
+ });
41
+ if (!resp.ok) {
42
+ const body = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` }));
43
+ throw new Error((body as { error?: string }).error ?? `HTTP ${resp.status}`);
44
+ }
45
+ const result = await resp.json() as { success: boolean } & RotationSettingsData;
46
+ setData({ rotation_strategy: result.rotation_strategy });
47
+ setSaved(true);
48
+ setTimeout(() => setSaved(false), 3000);
49
+ } catch (err) {
50
+ setError(err instanceof Error ? err.message : String(err));
51
+ } finally {
52
+ setSaving(false);
53
+ }
54
+ }, [apiKey]);
55
+
56
+ useEffect(() => { load(); }, [load]);
57
+
58
+ return { data, saving, saved, error, save, load };
59
+ }
shared/i18n/translations.ts CHANGED
@@ -189,6 +189,18 @@ export const translations = {
189
  quotaThresholdsHint: "Comma-separated percentages (1–100). Highest = critical, rest = warning.",
190
  quotaSkipExhausted: "Auto-skip exhausted accounts",
191
  quotaSaved: "Saved",
 
 
 
 
 
 
 
 
 
 
 
 
192
  },
193
  zh: {
194
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -383,6 +395,18 @@ export const translations = {
383
  quotaThresholdsHint: "\u9017\u53f7\u5206\u9694\u7684\u767e\u5206\u6bd4 (1\u2013100)\uff0c\u6700\u9ad8\u503c=\u4e25\u91cd\uff0c\u5176\u4f59=\u8b66\u544a\u3002",
384
  quotaSkipExhausted: "\u81ea\u52a8\u8df3\u8fc7\u989d\u5ea6\u8017\u5c3d\u8d26\u53f7",
385
  quotaSaved: "\u5df2\u4fdd\u5b58",
 
 
 
 
 
 
 
 
 
 
 
 
386
  },
387
  } as const;
388
 
 
189
  quotaThresholdsHint: "Comma-separated percentages (1–100). Highest = critical, rest = warning.",
190
  quotaSkipExhausted: "Auto-skip exhausted accounts",
191
  quotaSaved: "Saved",
192
+ rotationSettings: "Account Selection",
193
+ rotationStrategy: "Selection Mode",
194
+ rotationStrategyHint: "How accounts are selected for each request.",
195
+ rotationSticky: "Sticky",
196
+ rotationStickyDesc: "Keep using the same account until rate-limited or quota exhausted.",
197
+ rotationRotate: "Rotation",
198
+ rotationRotateDesc: "Distribute requests across multiple accounts.",
199
+ rotationLeastUsed: "Least Used",
200
+ rotationRoundRobin: "Round Robin",
201
+ rotationLeastUsedDesc: "Prefer the account with fewest requests.",
202
+ rotationRoundRobinDesc: "Cycle through accounts in order.",
203
+ rotationSaved: "Saved",
204
  },
205
  zh: {
206
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
395
  quotaThresholdsHint: "\u9017\u53f7\u5206\u9694\u7684\u767e\u5206\u6bd4 (1\u2013100)\uff0c\u6700\u9ad8\u503c=\u4e25\u91cd\uff0c\u5176\u4f59=\u8b66\u544a\u3002",
396
  quotaSkipExhausted: "\u81ea\u52a8\u8df3\u8fc7\u989d\u5ea6\u8017\u5c3d\u8d26\u53f7",
397
  quotaSaved: "\u5df2\u4fdd\u5b58",
398
+ rotationSettings: "\u8d26\u53f7\u9009\u62e9",
399
+ rotationStrategy: "\u9009\u62e9\u6a21\u5f0f",
400
+ rotationStrategyHint: "\u6bcf\u6b21\u8bf7\u6c42\u65f6\u5982\u4f55\u9009\u62e9\u8d26\u53f7\u3002",
401
+ rotationSticky: "\u7c98\u6ede",
402
+ rotationStickyDesc: "\u6301\u7eed\u4f7f\u7528\u540c\u4e00\u8d26\u53f7\uff0c\u76f4\u5230\u9650\u901f\u6216\u989d\u5ea6\u8017\u5c3d\u3002",
403
+ rotationRotate: "\u8f6e\u6362",
404
+ rotationRotateDesc: "\u5c06\u8bf7\u6c42\u5206\u914d\u5230\u591a\u4e2a\u8d26\u53f7\u3002",
405
+ rotationLeastUsed: "\u6700\u5c11\u4f7f\u7528",
406
+ rotationRoundRobin: "\u8f6e\u8be2",
407
+ rotationLeastUsedDesc: "\u4f18\u5148\u4f7f\u7528\u8bf7\u6c42\u6b21\u6570\u6700\u5c11\u7684\u8d26\u53f7\u3002",
408
+ rotationRoundRobinDesc: "\u6309\u987a\u5e8f\u8f6e\u6d41\u4f7f\u7528\u5404\u8d26\u53f7\u3002",
409
+ rotationSaved: "\u5df2\u4fdd\u5b58",
410
  },
411
  } as const;
412
 
src/auth/__tests__/account-pool-sticky.test.ts ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for sticky rotation strategy in AccountPool.
3
+ *
4
+ * Sticky: prefer the most recently used account, keeping it in use
5
+ * until rate-limited or quota-exhausted.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
+
10
+ let mockStrategy: "least_used" | "round_robin" | "sticky" = "sticky";
11
+
12
+ const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
13
+
14
+ vi.mock("../../models/model-store.js", () => ({
15
+ getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
16
+ }));
17
+
18
+ vi.mock("../../config.js", () => ({
19
+ getConfig: vi.fn(() => ({
20
+ server: { proxy_api_key: null },
21
+ auth: { jwt_token: "", rotation_strategy: mockStrategy, rate_limit_backoff_seconds: 60 },
22
+ })),
23
+ }));
24
+
25
+ let profileForToken: Record<string, { chatgpt_plan_type: string; email: string }> = {};
26
+
27
+ vi.mock("../../auth/jwt-utils.js", () => ({
28
+ isTokenExpired: vi.fn(() => false),
29
+ decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
30
+ extractChatGptAccountId: vi.fn((token: string) => `aid-${token}`),
31
+ extractUserProfile: vi.fn((token: string) => profileForToken[token] ?? null),
32
+ }));
33
+
34
+ vi.mock("../../utils/jitter.js", () => ({
35
+ jitter: vi.fn((val: number) => val),
36
+ }));
37
+
38
+ vi.mock("fs", () => ({
39
+ readFileSync: vi.fn(() => JSON.stringify({ accounts: [] })),
40
+ writeFileSync: vi.fn(),
41
+ existsSync: vi.fn(() => false),
42
+ mkdirSync: vi.fn(),
43
+ renameSync: vi.fn(),
44
+ }));
45
+
46
+ import { AccountPool } from "../account-pool.js";
47
+
48
+ describe("account-pool sticky strategy", () => {
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ profileForToken = {};
52
+ mockStrategy = "sticky";
53
+ });
54
+
55
+ it("selects account with most recent last_used", () => {
56
+ profileForToken = {
57
+ "tok-a": { chatgpt_plan_type: "free", email: "a@test.com" },
58
+ "tok-b": { chatgpt_plan_type: "free", email: "b@test.com" },
59
+ "tok-c": { chatgpt_plan_type: "free", email: "c@test.com" },
60
+ };
61
+
62
+ const pool = new AccountPool();
63
+ const idA = pool.addAccount("tok-a");
64
+ const idB = pool.addAccount("tok-b");
65
+ const idC = pool.addAccount("tok-c");
66
+
67
+ // Simulate: B was used most recently, then A, then C
68
+ const entryC = pool.getEntry(idC)!;
69
+ entryC.usage.last_used = new Date(Date.now() - 30_000).toISOString();
70
+ entryC.usage.request_count = 1;
71
+
72
+ const entryA = pool.getEntry(idA)!;
73
+ entryA.usage.last_used = new Date(Date.now() - 10_000).toISOString();
74
+ entryA.usage.request_count = 2;
75
+
76
+ const entryB = pool.getEntry(idB)!;
77
+ entryB.usage.last_used = new Date(Date.now() - 1_000).toISOString();
78
+ entryB.usage.request_count = 5;
79
+
80
+ // Sticky should pick B (most recent last_used) despite having most requests
81
+ const acquired = pool.acquire();
82
+ expect(acquired).not.toBeNull();
83
+ expect(acquired!.entryId).toBe(idB);
84
+ pool.release(acquired!.entryId);
85
+ });
86
+
87
+ it("sticks to same account across multiple acquire/release cycles", () => {
88
+ profileForToken = {
89
+ "tok-a": { chatgpt_plan_type: "free", email: "a@test.com" },
90
+ "tok-b": { chatgpt_plan_type: "free", email: "b@test.com" },
91
+ };
92
+
93
+ const pool = new AccountPool();
94
+ pool.addAccount("tok-a");
95
+ pool.addAccount("tok-b");
96
+
97
+ // First acquire picks one (arbitrary from fresh pool)
98
+ const first = pool.acquire()!;
99
+ pool.release(first.entryId);
100
+
101
+ // Subsequent acquires should stick to the same account
102
+ for (let i = 0; i < 5; i++) {
103
+ const next = pool.acquire()!;
104
+ expect(next.entryId).toBe(first.entryId);
105
+ pool.release(next.entryId);
106
+ }
107
+ });
108
+
109
+ it("falls back when current account is rate-limited", () => {
110
+ profileForToken = {
111
+ "tok-a": { chatgpt_plan_type: "free", email: "a@test.com" },
112
+ "tok-b": { chatgpt_plan_type: "free", email: "b@test.com" },
113
+ };
114
+
115
+ const pool = new AccountPool();
116
+ const idA = pool.addAccount("tok-a");
117
+ const idB = pool.addAccount("tok-b");
118
+
119
+ // Make A the sticky choice
120
+ const entryA = pool.getEntry(idA)!;
121
+ entryA.usage.last_used = new Date().toISOString();
122
+ entryA.usage.request_count = 5;
123
+
124
+ // Rate-limit A
125
+ pool.markRateLimited(idA, { retryAfterSec: 300 });
126
+
127
+ // Should fall back to B
128
+ const acquired = pool.acquire();
129
+ expect(acquired).not.toBeNull();
130
+ expect(acquired!.entryId).toBe(idB);
131
+ pool.release(acquired!.entryId);
132
+ });
133
+
134
+ it("picks first available when no account has been used yet", () => {
135
+ profileForToken = {
136
+ "tok-a": { chatgpt_plan_type: "free", email: "a@test.com" },
137
+ "tok-b": { chatgpt_plan_type: "free", email: "b@test.com" },
138
+ "tok-c": { chatgpt_plan_type: "free", email: "c@test.com" },
139
+ };
140
+
141
+ const pool = new AccountPool();
142
+ pool.addAccount("tok-a");
143
+ pool.addAccount("tok-b");
144
+ pool.addAccount("tok-c");
145
+
146
+ // All accounts have null last_used — should pick one
147
+ const first = pool.acquire();
148
+ expect(first).not.toBeNull();
149
+ pool.release(first!.entryId);
150
+
151
+ // After releasing, the same one should be sticky
152
+ const second = pool.acquire();
153
+ expect(second).not.toBeNull();
154
+ expect(second!.entryId).toBe(first!.entryId);
155
+ pool.release(second!.entryId);
156
+ });
157
+
158
+ it("respects model filtering", () => {
159
+ profileForToken = {
160
+ "tok-free": { chatgpt_plan_type: "free", email: "free@test.com" },
161
+ "tok-team": { chatgpt_plan_type: "team", email: "team@test.com" },
162
+ };
163
+ mockGetModelPlanTypes.mockReturnValue(["team"]);
164
+
165
+ const pool = new AccountPool();
166
+ const idFree = pool.addAccount("tok-free");
167
+ const idTeam = pool.addAccount("tok-team");
168
+
169
+ // Use the free account more recently
170
+ const entryFree = pool.getEntry(idFree)!;
171
+ entryFree.usage.last_used = new Date().toISOString();
172
+ entryFree.usage.request_count = 10;
173
+
174
+ // Model requires team plan — sticky should pick team account despite free being more recent
175
+ const acquired = pool.acquire({ model: "gpt-5.4" });
176
+ expect(acquired).not.toBeNull();
177
+ expect(acquired!.entryId).toBe(idTeam);
178
+ pool.release(acquired!.entryId);
179
+ });
180
+
181
+ it("least_used still works (regression guard)", () => {
182
+ mockStrategy = "least_used";
183
+ profileForToken = {
184
+ "tok-a": { chatgpt_plan_type: "free", email: "a@test.com" },
185
+ "tok-b": { chatgpt_plan_type: "free", email: "b@test.com" },
186
+ };
187
+
188
+ const pool = new AccountPool();
189
+ const idA = pool.addAccount("tok-a");
190
+ const idB = pool.addAccount("tok-b");
191
+
192
+ // A has more requests — least_used should prefer B
193
+ const entryA = pool.getEntry(idA)!;
194
+ entryA.usage.request_count = 10;
195
+ entryA.usage.last_used = new Date().toISOString();
196
+
197
+ const entryB = pool.getEntry(idB)!;
198
+ entryB.usage.request_count = 2;
199
+
200
+ const acquired = pool.acquire();
201
+ expect(acquired).not.toBeNull();
202
+ expect(acquired!.entryId).toBe(idB);
203
+ pool.release(acquired!.entryId);
204
+ });
205
+ });
src/auth/account-pool.ts CHANGED
@@ -133,6 +133,15 @@ export class AccountPool {
133
  this.roundRobinIndex++;
134
  return selected;
135
  }
 
 
 
 
 
 
 
 
 
136
  // least_used: sort by request_count asc → window_reset_at asc → last_used asc (LRU)
137
  candidates.sort((a, b) => {
138
  const diff = a.usage.request_count - b.usage.request_count;
 
133
  this.roundRobinIndex++;
134
  return selected;
135
  }
136
+ if (config.auth.rotation_strategy === "sticky") {
137
+ // Sticky: prefer most recently used account (keep reusing until unavailable)
138
+ candidates.sort((a, b) => {
139
+ const aTime = a.usage.last_used ? new Date(a.usage.last_used).getTime() : 0;
140
+ const bTime = b.usage.last_used ? new Date(b.usage.last_used).getTime() : 0;
141
+ return bTime - aTime; // desc — most recent first
142
+ });
143
+ return candidates[0];
144
+ }
145
  // least_used: sort by request_count asc → window_reset_at asc → last_used asc (LRU)
146
  candidates.sort((a, b) => {
147
  const diff = a.usage.request_count - b.usage.request_count;
src/config.ts CHANGED
@@ -6,6 +6,8 @@ import { loadStaticModels } from "./models/model-store.js";
6
  import { triggerImmediateRefresh } from "./models/model-fetcher.js";
7
  import { getConfigDir } from "./paths.js";
8
 
 
 
9
  const ConfigSchema = z.object({
10
  api: z.object({
11
  base_url: z.string().default("https://chatgpt.com/backend-api"),
@@ -30,7 +32,7 @@ const ConfigSchema = z.object({
30
  jwt_token: z.string().nullable().default(null),
31
  chatgpt_oauth: z.boolean().default(true),
32
  refresh_margin_seconds: z.number().min(0).default(300),
33
- rotation_strategy: z.enum(["least_used", "round_robin"]).default("least_used"),
34
  rate_limit_backoff_seconds: z.number().min(1).default(60),
35
  oauth_client_id: z.string().default("app_EMoamEEZ73f0CkXaXp7hrann"),
36
  oauth_auth_endpoint: z.string().default("https://auth.openai.com/oauth/authorize"),
 
6
  import { triggerImmediateRefresh } from "./models/model-fetcher.js";
7
  import { getConfigDir } from "./paths.js";
8
 
9
+ export const ROTATION_STRATEGIES = ["least_used", "round_robin", "sticky"] as const;
10
+
11
  const ConfigSchema = z.object({
12
  api: z.object({
13
  base_url: z.string().default("https://chatgpt.com/backend-api"),
 
32
  jwt_token: z.string().nullable().default(null),
33
  chatgpt_oauth: z.boolean().default(true),
34
  refresh_margin_seconds: z.number().min(0).default(300),
35
+ rotation_strategy: z.enum(ROTATION_STRATEGIES).default("least_used"),
36
  rate_limit_backoff_seconds: z.number().min(1).default(60),
37
  oauth_client_id: z.string().default("app_EMoamEEZ73f0CkXaXp7hrann"),
38
  oauth_auth_endpoint: z.string().default("https://auth.openai.com/oauth/authorize"),
src/routes/__tests__/rotation-settings.test.ts ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for rotation settings endpoints.
3
+ * GET /admin/rotation-settings — read current rotation strategy
4
+ * POST /admin/rotation-settings — update rotation strategy
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from "vitest";
8
+
9
+ // --- Mocks (before any imports) ---
10
+
11
+ const mockConfig = {
12
+ server: { proxy_api_key: null as string | null },
13
+ auth: { rotation_strategy: "least_used" as string },
14
+ quota: {
15
+ refresh_interval_minutes: 5,
16
+ warning_thresholds: { primary: [80, 90], secondary: [80, 90] },
17
+ skip_exhausted: true,
18
+ },
19
+ };
20
+
21
+ vi.mock("../../config.js", () => ({
22
+ getConfig: vi.fn(() => mockConfig),
23
+ reloadAllConfigs: vi.fn(),
24
+ ROTATION_STRATEGIES: ["least_used", "round_robin", "sticky"],
25
+ }));
26
+
27
+ vi.mock("../../paths.js", () => ({
28
+ getConfigDir: vi.fn(() => "/tmp/test-config"),
29
+ getPublicDir: vi.fn(() => "/tmp/test-public"),
30
+ getDesktopPublicDir: vi.fn(() => "/tmp/test-desktop"),
31
+ getDataDir: vi.fn(() => "/tmp/test-data"),
32
+ getBinDir: vi.fn(() => "/tmp/test-bin"),
33
+ isEmbedded: vi.fn(() => false),
34
+ }));
35
+
36
+ vi.mock("../../utils/yaml-mutate.js", () => ({
37
+ mutateYaml: vi.fn(),
38
+ }));
39
+
40
+ vi.mock("../../tls/transport.js", () => ({
41
+ getTransport: vi.fn(),
42
+ getTransportInfo: vi.fn(() => ({})),
43
+ }));
44
+
45
+ vi.mock("../../tls/curl-binary.js", () => ({
46
+ getCurlDiagnostics: vi.fn(() => ({})),
47
+ }));
48
+
49
+ vi.mock("../../fingerprint/manager.js", () => ({
50
+ buildHeaders: vi.fn(() => ({})),
51
+ }));
52
+
53
+ vi.mock("../../update-checker.js", () => ({
54
+ getUpdateState: vi.fn(() => ({})),
55
+ checkForUpdate: vi.fn(),
56
+ isUpdateInProgress: vi.fn(() => false),
57
+ }));
58
+
59
+ vi.mock("../../self-update.js", () => ({
60
+ getProxyInfo: vi.fn(() => ({})),
61
+ canSelfUpdate: vi.fn(() => false),
62
+ checkProxySelfUpdate: vi.fn(),
63
+ applyProxySelfUpdate: vi.fn(),
64
+ isProxyUpdateInProgress: vi.fn(() => false),
65
+ getCachedProxyUpdateResult: vi.fn(() => null),
66
+ getDeployMode: vi.fn(() => "git"),
67
+ }));
68
+
69
+ vi.mock("@hono/node-server/serve-static", () => ({
70
+ serveStatic: vi.fn(() => vi.fn()),
71
+ }));
72
+
73
+ vi.mock("@hono/node-server/conninfo", () => ({
74
+ getConnInfo: vi.fn(() => ({ remote: { address: "127.0.0.1" } })),
75
+ }));
76
+
77
+ import { createWebRoutes } from "../web.js";
78
+ import { mutateYaml } from "../../utils/yaml-mutate.js";
79
+
80
+ const mockPool = {
81
+ getAll: vi.fn(() => []),
82
+ acquire: vi.fn(),
83
+ release: vi.fn(),
84
+ } as unknown as Parameters<typeof createWebRoutes>[0];
85
+
86
+ describe("GET /admin/rotation-settings", () => {
87
+ beforeEach(() => {
88
+ vi.clearAllMocks();
89
+ mockConfig.auth.rotation_strategy = "least_used";
90
+ });
91
+
92
+ it("returns current rotation strategy", async () => {
93
+ const app = createWebRoutes(mockPool);
94
+ const res = await app.request("/admin/rotation-settings");
95
+ expect(res.status).toBe(200);
96
+ const data = await res.json();
97
+ expect(data).toEqual({ rotation_strategy: "least_used" });
98
+ });
99
+
100
+ it("reflects config value", async () => {
101
+ mockConfig.auth.rotation_strategy = "sticky";
102
+ const app = createWebRoutes(mockPool);
103
+ const res = await app.request("/admin/rotation-settings");
104
+ const data = await res.json();
105
+ expect(data.rotation_strategy).toBe("sticky");
106
+ });
107
+ });
108
+
109
+ describe("POST /admin/rotation-settings", () => {
110
+ beforeEach(() => {
111
+ vi.clearAllMocks();
112
+ mockConfig.server.proxy_api_key = null;
113
+ mockConfig.auth.rotation_strategy = "least_used";
114
+ });
115
+
116
+ it("updates strategy to sticky", async () => {
117
+ const app = createWebRoutes(mockPool);
118
+ const res = await app.request("/admin/rotation-settings", {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({ rotation_strategy: "sticky" }),
122
+ });
123
+ expect(res.status).toBe(200);
124
+ const data = await res.json();
125
+ expect(data.success).toBe(true);
126
+ expect(mutateYaml).toHaveBeenCalledOnce();
127
+ });
128
+
129
+ it("accepts all three valid strategies", async () => {
130
+ const app = createWebRoutes(mockPool);
131
+ for (const strategy of ["least_used", "round_robin", "sticky"]) {
132
+ vi.mocked(mutateYaml).mockClear();
133
+ const res = await app.request("/admin/rotation-settings", {
134
+ method: "POST",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify({ rotation_strategy: strategy }),
137
+ });
138
+ expect(res.status).toBe(200);
139
+ }
140
+ });
141
+
142
+ it("rejects invalid strategy with 400", async () => {
143
+ const app = createWebRoutes(mockPool);
144
+ const res = await app.request("/admin/rotation-settings", {
145
+ method: "POST",
146
+ headers: { "Content-Type": "application/json" },
147
+ body: JSON.stringify({ rotation_strategy: "random" }),
148
+ });
149
+ expect(res.status).toBe(400);
150
+ const data = await res.json();
151
+ expect(data.error).toBeDefined();
152
+ });
153
+
154
+ it("rejects missing strategy with 400", async () => {
155
+ const app = createWebRoutes(mockPool);
156
+ const res = await app.request("/admin/rotation-settings", {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/json" },
159
+ body: JSON.stringify({}),
160
+ });
161
+ expect(res.status).toBe(400);
162
+ });
163
+
164
+ it("requires auth when proxy_api_key is set", async () => {
165
+ mockConfig.server.proxy_api_key = "my-secret";
166
+ const app = createWebRoutes(mockPool);
167
+
168
+ // No auth → 401
169
+ const res1 = await app.request("/admin/rotation-settings", {
170
+ method: "POST",
171
+ headers: { "Content-Type": "application/json" },
172
+ body: JSON.stringify({ rotation_strategy: "sticky" }),
173
+ });
174
+ expect(res1.status).toBe(401);
175
+
176
+ // With auth → 200
177
+ const res2 = await app.request("/admin/rotation-settings", {
178
+ method: "POST",
179
+ headers: {
180
+ "Content-Type": "application/json",
181
+ Authorization: "Bearer my-secret",
182
+ },
183
+ body: JSON.stringify({ rotation_strategy: "sticky" }),
184
+ });
185
+ expect(res2.status).toBe(200);
186
+ });
187
+ });
src/routes/web.ts CHANGED
@@ -5,7 +5,7 @@ import { getConnInfo } from "@hono/node-server/conninfo";
5
  import { readFileSync, existsSync } from "fs";
6
  import { resolve } from "path";
7
  import type { AccountPool } from "../auth/account-pool.js";
8
- import { getConfig, getFingerprint, reloadAllConfigs } from "../config.js";
9
  import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, getBinDir, isEmbedded } from "../paths.js";
10
  import { getTransport, getTransportInfo } from "../tls/transport.js";
11
  import { getCurlDiagnostics } from "../tls/curl-binary.js";
@@ -446,6 +446,49 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
446
 
447
  // --- Settings endpoints ---
448
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  app.get("/admin/settings", (c) => {
450
  const config = getConfig();
451
  return c.json({ proxy_api_key: config.server.proxy_api_key });
 
5
  import { readFileSync, existsSync } from "fs";
6
  import { resolve } from "path";
7
  import type { AccountPool } from "../auth/account-pool.js";
8
+ import { getConfig, getFingerprint, reloadAllConfigs, ROTATION_STRATEGIES } from "../config.js";
9
  import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, getBinDir, isEmbedded } from "../paths.js";
10
  import { getTransport, getTransportInfo } from "../tls/transport.js";
11
  import { getCurlDiagnostics } from "../tls/curl-binary.js";
 
446
 
447
  // --- Settings endpoints ---
448
 
449
+ // --- Rotation settings endpoints ---
450
+
451
+ app.get("/admin/rotation-settings", (c) => {
452
+ const config = getConfig();
453
+ return c.json({
454
+ rotation_strategy: config.auth.rotation_strategy,
455
+ });
456
+ });
457
+
458
+ app.post("/admin/rotation-settings", async (c) => {
459
+ const config = getConfig();
460
+ const currentKey = config.server.proxy_api_key;
461
+
462
+ if (currentKey) {
463
+ const authHeader = c.req.header("Authorization") ?? "";
464
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
465
+ if (token !== currentKey) {
466
+ c.status(401);
467
+ return c.json({ error: "Invalid current API key" });
468
+ }
469
+ }
470
+
471
+ const body = await c.req.json() as { rotation_strategy?: string };
472
+ const valid: readonly string[] = ROTATION_STRATEGIES;
473
+ if (!body.rotation_strategy || !valid.includes(body.rotation_strategy)) {
474
+ c.status(400);
475
+ return c.json({ error: `rotation_strategy must be one of: ${ROTATION_STRATEGIES.join(", ")}` });
476
+ }
477
+
478
+ const configPath = resolve(getConfigDir(), "default.yaml");
479
+ mutateYaml(configPath, (data) => {
480
+ if (!data.auth) data.auth = {};
481
+ (data.auth as Record<string, unknown>).rotation_strategy = body.rotation_strategy;
482
+ });
483
+ reloadAllConfigs();
484
+
485
+ const updated = getConfig();
486
+ return c.json({
487
+ success: true,
488
+ rotation_strategy: updated.auth.rotation_strategy,
489
+ });
490
+ });
491
+
492
  app.get("/admin/settings", (c) => {
493
  const config = getConfig();
494
  return c.json({ proxy_api_key: config.server.proxy_api_key });
web/src/App.tsx CHANGED
@@ -11,6 +11,7 @@ import { AnthropicSetup } from "./components/AnthropicSetup";
11
  import { CodeExamples } from "./components/CodeExamples";
12
  import { SettingsPanel } from "./components/SettingsPanel";
13
  import { QuotaSettings } from "./components/QuotaSettings";
 
14
  import { TestConnection } from "./components/TestConnection";
15
  import { Footer } from "./components/Footer";
16
  import { ProxySettings } from "./pages/ProxySettings";
@@ -150,6 +151,7 @@ function Dashboard() {
150
  />
151
  <SettingsPanel />
152
  <QuotaSettings />
 
153
  <TestConnection />
154
  </div>
155
  </main>
 
11
  import { CodeExamples } from "./components/CodeExamples";
12
  import { SettingsPanel } from "./components/SettingsPanel";
13
  import { QuotaSettings } from "./components/QuotaSettings";
14
+ import { RotationSettings } from "./components/RotationSettings";
15
  import { TestConnection } from "./components/TestConnection";
16
  import { Footer } from "./components/Footer";
17
  import { ProxySettings } from "./pages/ProxySettings";
 
151
  />
152
  <SettingsPanel />
153
  <QuotaSettings />
154
+ <RotationSettings />
155
  <TestConnection />
156
  </div>
157
  </main>
web/src/components/RotationSettings.tsx ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import { useRotationSettings, type RotationStrategy } from "../../../shared/hooks/use-rotation-settings";
4
+ import { useSettings } from "../../../shared/hooks/use-settings";
5
+
6
+ type Mode = "sticky" | "rotation";
7
+ type RotationSub = "least_used" | "round_robin";
8
+
9
+ function toMode(strategy: RotationStrategy): Mode {
10
+ return strategy === "sticky" ? "sticky" : "rotation";
11
+ }
12
+
13
+ function toStrategy(mode: Mode, sub: RotationSub): RotationStrategy {
14
+ return mode === "sticky" ? "sticky" : sub;
15
+ }
16
+
17
+ export function RotationSettings() {
18
+ const t = useT();
19
+ const settings = useSettings();
20
+ const rs = useRotationSettings(settings.apiKey);
21
+
22
+ const current = rs.data?.rotation_strategy ?? "least_used";
23
+ const currentMode = toMode(current);
24
+ const currentSub: RotationSub = current === "sticky" ? "least_used" : (current as RotationSub);
25
+
26
+ const [draftMode, setDraftMode] = useState<Mode | null>(null);
27
+ const [draftSub, setDraftSub] = useState<RotationSub | null>(null);
28
+ const [collapsed, setCollapsed] = useState(true);
29
+
30
+ const displayMode = draftMode ?? currentMode;
31
+ const displaySub = draftSub ?? currentSub;
32
+ const displayStrategy = toStrategy(displayMode, displaySub);
33
+ const isDirty = displayStrategy !== current;
34
+
35
+ const handleSave = useCallback(async () => {
36
+ if (!isDirty) return;
37
+ await rs.save({ rotation_strategy: displayStrategy });
38
+ setDraftMode(null);
39
+ setDraftSub(null);
40
+ }, [isDirty, displayStrategy, rs]);
41
+
42
+ const radioCls = "w-4 h-4 text-primary focus:ring-primary cursor-pointer";
43
+ const labelCls = "text-[0.8rem] font-medium text-slate-700 dark:text-text-main cursor-pointer";
44
+
45
+ return (
46
+ <section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl shadow-sm transition-colors">
47
+ <button
48
+ onClick={() => setCollapsed(!collapsed)}
49
+ class="w-full flex items-center justify-between p-5 cursor-pointer select-none"
50
+ >
51
+ <div class="flex items-center gap-2">
52
+ <svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
53
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
54
+ </svg>
55
+ <h2 class="text-[0.95rem] font-bold">{t("rotationSettings")}</h2>
56
+ </div>
57
+ <svg class={`size-5 text-slate-400 dark:text-text-dim transition-transform ${collapsed ? "" : "rotate-180"}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
58
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
59
+ </svg>
60
+ </button>
61
+
62
+ {!collapsed && (
63
+ <div class="px-5 pb-5 border-t border-slate-100 dark:border-border-dark pt-4 space-y-4">
64
+ <p class="text-xs text-slate-400 dark:text-text-dim">{t("rotationStrategyHint")}</p>
65
+
66
+ {/* Mode: Sticky vs Rotation */}
67
+ <div class="space-y-3">
68
+ {/* Sticky */}
69
+ <label class="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-border-dark cursor-pointer hover:bg-slate-50 dark:hover:bg-bg-dark transition-colors">
70
+ <input
71
+ type="radio"
72
+ name="rotation-mode"
73
+ checked={displayMode === "sticky"}
74
+ onChange={() => setDraftMode("sticky")}
75
+ class={radioCls + " mt-0.5"}
76
+ />
77
+ <div>
78
+ <span class={labelCls}>{t("rotationSticky")}</span>
79
+ <p class="text-xs text-slate-400 dark:text-text-dim mt-0.5">{t("rotationStickyDesc")}</p>
80
+ </div>
81
+ </label>
82
+
83
+ {/* Rotation */}
84
+ <label class="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-border-dark cursor-pointer hover:bg-slate-50 dark:hover:bg-bg-dark transition-colors">
85
+ <input
86
+ type="radio"
87
+ name="rotation-mode"
88
+ checked={displayMode === "rotation"}
89
+ onChange={() => setDraftMode("rotation")}
90
+ class={radioCls + " mt-0.5"}
91
+ />
92
+ <div class="flex-1">
93
+ <span class={labelCls}>{t("rotationRotate")}</span>
94
+ <p class="text-xs text-slate-400 dark:text-text-dim mt-0.5">{t("rotationRotateDesc")}</p>
95
+ </div>
96
+ </label>
97
+
98
+ {/* Sub-strategy (only when rotation mode) */}
99
+ {displayMode === "rotation" && (
100
+ <div class="ml-10 space-y-2">
101
+ <label class="flex items-center gap-2 cursor-pointer">
102
+ <input
103
+ type="radio"
104
+ name="rotation-sub"
105
+ checked={displaySub === "least_used"}
106
+ onChange={() => setDraftSub("least_used")}
107
+ class={radioCls}
108
+ />
109
+ <div>
110
+ <span class="text-xs font-medium text-slate-600 dark:text-text-main">{t("rotationLeastUsed")}</span>
111
+ <span class="text-xs text-slate-400 dark:text-text-dim ml-1.5">{t("rotationLeastUsedDesc")}</span>
112
+ </div>
113
+ </label>
114
+ <label class="flex items-center gap-2 cursor-pointer">
115
+ <input
116
+ type="radio"
117
+ name="rotation-sub"
118
+ checked={displaySub === "round_robin"}
119
+ onChange={() => setDraftSub("round_robin")}
120
+ class={radioCls}
121
+ />
122
+ <div>
123
+ <span class="text-xs font-medium text-slate-600 dark:text-text-main">{t("rotationRoundRobin")}</span>
124
+ <span class="text-xs text-slate-400 dark:text-text-dim ml-1.5">{t("rotationRoundRobinDesc")}</span>
125
+ </div>
126
+ </label>
127
+ </div>
128
+ )}
129
+ </div>
130
+
131
+ {/* Save button + status */}
132
+ <div class="flex items-center gap-3">
133
+ <button
134
+ onClick={handleSave}
135
+ disabled={rs.saving || !isDirty}
136
+ class={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
137
+ isDirty && !rs.saving
138
+ ? "bg-primary text-white hover:bg-primary/90 cursor-pointer"
139
+ : "bg-slate-100 dark:bg-[#21262d] text-slate-400 dark:text-text-dim cursor-not-allowed"
140
+ }`}
141
+ >
142
+ {rs.saving ? "..." : t("submit")}
143
+ </button>
144
+ {rs.saved && (
145
+ <span class="text-xs font-medium text-green-600 dark:text-green-400">{t("rotationSaved")}</span>
146
+ )}
147
+ {rs.error && (
148
+ <span class="text-xs font-medium text-red-500">{rs.error}</span>
149
+ )}
150
+ </div>
151
+ </div>
152
+ )}
153
+ </section>
154
+ );
155
+ }