Spaces:
Paused
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 +7 -0
- shared/hooks/use-rotation-settings.ts +59 -0
- shared/i18n/translations.ts +24 -0
- src/auth/__tests__/account-pool-sticky.test.ts +205 -0
- src/auth/account-pool.ts +9 -0
- src/config.ts +3 -1
- src/routes/__tests__/rotation-settings.test.ts +187 -0
- src/routes/web.ts +44 -1
- web/src/App.tsx +2 -0
- web/src/components/RotationSettings.tsx +155 -0
|
@@ -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 |
|
|
@@ -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 |
+
}
|
|
@@ -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 |
|
|
@@ -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 |
+
});
|
|
@@ -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;
|
|
@@ -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(
|
| 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"),
|
|
@@ -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 |
+
});
|
|
@@ -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 });
|
|
@@ -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>
|
|
@@ -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 |
+
}
|