icebear0828 Claude Opus 4.6 commited on
Commit
4ebb914
·
1 Parent(s): 0e1248b

feat: add proxy pool for per-account proxy routing

Browse files

Add proxy pool system allowing different accounts to route through
different upstream proxies for IP diversity and risk isolation.

Backend:
- ProxyPool manager with CRUD, assignment, round-robin, health checks
- TlsTransport interface accepts per-request proxyUrl parameter
- Both curl-cli and libcurl-ffi transports support per-request proxy
- CodexApi forwards proxy through the request chain
- REST API at /api/proxies for proxy + assignment management
- Periodic health checks (default 5min) via api.ipify.org

Frontend:
- ProxyPool management section in dashboard
- Per-account proxy selector dropdown in AccountCard
- Four assignment modes: Global/Direct/Auto(Round-Robin)/Specific

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

CHANGELOG.md CHANGED
@@ -8,6 +8,16 @@
8
 
9
  ### Added
10
 
 
 
 
 
 
 
 
 
 
 
11
  - Dashboard GitHub Star 徽章:Header 新增醒目的 ⭐ Star 按钮(amber 药丸样式),点击跳转 GitHub 仓库页面,方便用户收藏和获取更新
12
  - Dashboard 检查更新功能:Footer 显示 Proxy 版本+commit 和 Codex Desktop 指纹版本,提供"检查更新"按钮同时检查两种更新
13
  - Proxy 自更新(CLI 模式):通过 `git fetch` 检查新提交,自动执行 `git pull + npm install + npm run build`,完成后提示重启
 
8
 
9
  ### Added
10
 
11
+ - 代理池功能:支持为不同账号配置不同的上游代理,实现 IP 多样化和风险隔离
12
+ - 代理 CRUD:添加、删除、启用、禁用代理(HTTP/HTTPS/SOCKS5)
13
+ - 四种分配模式:Global Default(全局代理)、Direct(直连)、Auto(Round-Robin 轮转)、指定代理
14
+ - 健康检查:定时(默认 5 分钟)+ 手动,通过 ipify API 获取出口 IP 和延迟
15
+ - 不可达代理自动标记为 unreachable,不参与自动轮转
16
+ - Dashboard 代理池管理面板:添加/删除/检查/启用/禁用代理
17
+ - AccountCard 代理选择器:每个账号可选择代理或模式
18
+ - 全套 REST API:`/api/proxies` CRUD + `/api/proxies/assign` 分配管理
19
+ - 持久化:`data/proxies.json`(原子写入,与 cookies.json 同模式)
20
+ - Transport 层支持 per-request 代理:`TlsTransport` 接口新增可选 `proxyUrl` 参数
21
  - Dashboard GitHub Star 徽章:Header 新增醒目的 ⭐ Star 按钮(amber 药丸样式),点击跳转 GitHub 仓库页面,方便用户收藏和获取更新
22
  - Dashboard 检查更新功能:Footer 显示 Proxy 版本+commit 和 Codex Desktop 指纹版本,提供"检查更新"按钮同时检查两种更新
23
  - Proxy 自更新(CLI 模式):通过 `git fetch` 检查新提交,自动执行 `git pull + npm install + npm run build`,完成后提示重启
shared/hooks/use-proxies.ts ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "preact/hooks";
2
+ import type { ProxyEntry, ProxyAssignment } from "../types";
3
+
4
+ export interface ProxiesState {
5
+ proxies: ProxyEntry[];
6
+ assignments: ProxyAssignment[];
7
+ healthCheckIntervalMinutes: number;
8
+ loading: boolean;
9
+ refresh: () => Promise<void>;
10
+ addProxy: (name: string, url: string) => Promise<string | null>;
11
+ removeProxy: (id: string) => Promise<string | null>;
12
+ checkProxy: (id: string) => Promise<void>;
13
+ checkAll: () => Promise<void>;
14
+ enableProxy: (id: string) => Promise<void>;
15
+ disableProxy: (id: string) => Promise<void>;
16
+ assignProxy: (accountId: string, proxyId: string) => Promise<void>;
17
+ unassignProxy: (accountId: string) => Promise<void>;
18
+ setInterval: (minutes: number) => Promise<void>;
19
+ }
20
+
21
+ export function useProxies(): ProxiesState {
22
+ const [proxies, setProxies] = useState<ProxyEntry[]>([]);
23
+ const [assignments, setAssignments] = useState<ProxyAssignment[]>([]);
24
+ const [healthCheckIntervalMinutes, setHealthInterval] = useState(5);
25
+ const [loading, setLoading] = useState(true);
26
+
27
+ const refresh = useCallback(async () => {
28
+ try {
29
+ const resp = await fetch("/api/proxies");
30
+ const data = await resp.json();
31
+ setProxies(data.proxies || []);
32
+ setAssignments(data.assignments || []);
33
+ setHealthInterval(data.healthCheckIntervalMinutes || 5);
34
+ } catch {
35
+ // ignore
36
+ } finally {
37
+ setLoading(false);
38
+ }
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ refresh();
43
+ }, [refresh]);
44
+
45
+ const addProxy = useCallback(
46
+ async (name: string, url: string): Promise<string | null> => {
47
+ try {
48
+ const resp = await fetch("/api/proxies", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ name, url }),
52
+ });
53
+ const data = await resp.json();
54
+ if (!resp.ok) return data.error || "Failed to add proxy";
55
+ await refresh();
56
+ return null;
57
+ } catch (err) {
58
+ return err instanceof Error ? err.message : "Network error";
59
+ }
60
+ },
61
+ [refresh],
62
+ );
63
+
64
+ const removeProxy = useCallback(
65
+ async (id: string): Promise<string | null> => {
66
+ try {
67
+ const resp = await fetch(`/api/proxies/${encodeURIComponent(id)}`, {
68
+ method: "DELETE",
69
+ });
70
+ if (!resp.ok) {
71
+ const data = await resp.json();
72
+ return data.error || "Failed to remove proxy";
73
+ }
74
+ await refresh();
75
+ return null;
76
+ } catch (err) {
77
+ return err instanceof Error ? err.message : "Network error";
78
+ }
79
+ },
80
+ [refresh],
81
+ );
82
+
83
+ const checkProxy = useCallback(
84
+ async (id: string) => {
85
+ await fetch(`/api/proxies/${encodeURIComponent(id)}/check`, {
86
+ method: "POST",
87
+ });
88
+ await refresh();
89
+ },
90
+ [refresh],
91
+ );
92
+
93
+ const checkAll = useCallback(async () => {
94
+ await fetch("/api/proxies/check-all", { method: "POST" });
95
+ await refresh();
96
+ }, [refresh]);
97
+
98
+ const enableProxy = useCallback(
99
+ async (id: string) => {
100
+ await fetch(`/api/proxies/${encodeURIComponent(id)}/enable`, {
101
+ method: "POST",
102
+ });
103
+ await refresh();
104
+ },
105
+ [refresh],
106
+ );
107
+
108
+ const disableProxy = useCallback(
109
+ async (id: string) => {
110
+ await fetch(`/api/proxies/${encodeURIComponent(id)}/disable`, {
111
+ method: "POST",
112
+ });
113
+ await refresh();
114
+ },
115
+ [refresh],
116
+ );
117
+
118
+ const assignProxy = useCallback(
119
+ async (accountId: string, proxyId: string) => {
120
+ await fetch("/api/proxies/assign", {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({ accountId, proxyId }),
124
+ });
125
+ await refresh();
126
+ },
127
+ [refresh],
128
+ );
129
+
130
+ const unassignProxy = useCallback(
131
+ async (accountId: string) => {
132
+ await fetch(`/api/proxies/assign/${encodeURIComponent(accountId)}`, {
133
+ method: "DELETE",
134
+ });
135
+ await refresh();
136
+ },
137
+ [refresh],
138
+ );
139
+
140
+ const setIntervalMinutes = useCallback(
141
+ async (minutes: number) => {
142
+ await fetch("/api/proxies/settings", {
143
+ method: "PUT",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ healthCheckIntervalMinutes: minutes }),
146
+ });
147
+ await refresh();
148
+ },
149
+ [refresh],
150
+ );
151
+
152
+ return {
153
+ proxies,
154
+ assignments,
155
+ healthCheckIntervalMinutes,
156
+ loading,
157
+ refresh,
158
+ addProxy,
159
+ removeProxy,
160
+ checkProxy,
161
+ checkAll,
162
+ enableProxy,
163
+ disableProxy,
164
+ assignProxy,
165
+ unassignProxy,
166
+ setInterval: setIntervalMinutes,
167
+ };
168
+ }
shared/i18n/translations.ts CHANGED
@@ -72,6 +72,29 @@ export const translations = {
72
  lastChecked: "Last checked",
73
  never: "never",
74
  commits: "commits",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  },
76
  zh: {
77
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -149,6 +172,29 @@ export const translations = {
149
  lastChecked: "\u4e0a\u6b21\u68c0\u67e5",
150
  never: "\u4ece\u672a",
151
  commits: "\u4e2a\u63d0\u4ea4",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  },
153
  } as const;
154
 
 
72
  lastChecked: "Last checked",
73
  never: "never",
74
  commits: "commits",
75
+ proxyPool: "Proxy Pool",
76
+ proxyPoolDesc: "Manage upstream proxies for different accounts.",
77
+ addProxy: "Add Proxy",
78
+ proxyName: "Name",
79
+ proxyUrl: "Proxy URL",
80
+ noProxies: "No proxies configured. Add one to route accounts through different IPs.",
81
+ proxyActive: "Active",
82
+ proxyUnreachable: "Unreachable",
83
+ proxyDisabled: "Disabled",
84
+ exitIp: "Exit IP",
85
+ latency: "Latency",
86
+ checkHealth: "Check",
87
+ checkAllHealth: "Check All",
88
+ enableProxy: "Enable",
89
+ disableProxy: "Disable",
90
+ deleteProxy: "Delete proxy",
91
+ removeProxyConfirm: "Remove this proxy?",
92
+ proxyAssignment: "Proxy",
93
+ globalDefault: "Global Default",
94
+ directNoProxy: "Direct (No Proxy)",
95
+ autoRoundRobin: "Auto (Round-Robin)",
96
+ healthInterval: "Health check interval",
97
+ minutes: "min",
98
  },
99
  zh: {
100
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
172
  lastChecked: "\u4e0a\u6b21\u68c0\u67e5",
173
  never: "\u4ece\u672a",
174
  commits: "\u4e2a\u63d0\u4ea4",
175
+ proxyPool: "\u4ee3\u7406\u6c60",
176
+ proxyPoolDesc: "\u7ba1\u7406\u4e0d\u540c\u8d26\u53f7\u7684\u4e0a\u6e38\u4ee3\u7406\u3002",
177
+ addProxy: "\u6dfb\u52a0\u4ee3\u7406",
178
+ proxyName: "\u540d\u79f0",
179
+ proxyUrl: "\u4ee3\u7406 URL",
180
+ noProxies: "\u672a\u914d\u7f6e\u4ee3\u7406\u3002\u6dfb\u52a0\u4e00\u4e2a\u4ee5\u901a\u8fc7\u4e0d\u540c IP \u8def\u7531\u8d26\u53f7\u3002",
181
+ proxyActive: "\u6d3b\u8dc3",
182
+ proxyUnreachable: "\u4e0d\u53ef\u8fbe",
183
+ proxyDisabled: "\u5df2\u7981\u7528",
184
+ exitIp: "\u51fa\u53e3 IP",
185
+ latency: "\u5ef6\u8fdf",
186
+ checkHealth: "\u68c0\u67e5",
187
+ checkAllHealth: "\u68c0\u67e5\u5168\u90e8",
188
+ enableProxy: "\u542f\u7528",
189
+ disableProxy: "\u7981\u7528",
190
+ deleteProxy: "\u5220\u9664\u4ee3\u7406",
191
+ removeProxyConfirm: "\u786e\u5b9a\u8981\u79fb\u9664\u6b64\u4ee3\u7406\u5417\uff1f",
192
+ proxyAssignment: "\u4ee3\u7406",
193
+ globalDefault: "\u5168\u5c40\u9ed8\u8ba4",
194
+ directNoProxy: "\u76f4\u8fde\uff08\u65e0\u4ee3\u7406\uff09",
195
+ autoRoundRobin: "\u81ea\u52a8\u8f6e\u8f6c",
196
+ healthInterval: "\u5065\u5eb7\u68c0\u67e5\u95f4\u9694",
197
+ minutes: "\u5206\u949f",
198
  },
199
  } as const;
200
 
shared/types.ts CHANGED
@@ -21,4 +21,27 @@ export interface Account {
21
  window_output_tokens?: number;
22
  };
23
  quota?: AccountQuota;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
 
21
  window_output_tokens?: number;
22
  };
23
  quota?: AccountQuota;
24
+ proxyId?: string;
25
+ proxyName?: string;
26
+ }
27
+
28
+ export interface ProxyHealthInfo {
29
+ exitIp: string | null;
30
+ latencyMs: number;
31
+ lastChecked: string;
32
+ error: string | null;
33
+ }
34
+
35
+ export interface ProxyEntry {
36
+ id: string;
37
+ name: string;
38
+ url: string;
39
+ status: "active" | "unreachable" | "disabled";
40
+ health: ProxyHealthInfo | null;
41
+ addedAt: string;
42
+ }
43
+
44
+ export interface ProxyAssignment {
45
+ accountId: string;
46
+ proxyId: string;
47
  }
src/index.ts CHANGED
@@ -15,6 +15,8 @@ import { createGeminiRoutes } from "./routes/gemini.js";
15
  import { createModelRoutes } from "./routes/models.js";
16
  import { createWebRoutes } from "./routes/web.js";
17
  import { CookieJar } from "./proxy/cookie-jar.js";
 
 
18
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
19
  import { initProxy } from "./tls/curl-binary.js";
20
  import { initTransport } from "./tls/transport.js";
@@ -54,6 +56,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
54
  const accountPool = new AccountPool();
55
  const refreshScheduler = new RefreshScheduler(accountPool);
56
  const cookieJar = new CookieJar();
 
57
 
58
  // Create Hono app
59
  const app = new Hono();
@@ -65,10 +68,11 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
65
 
66
  // Mount routes
67
  const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
68
- const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar);
69
- const chatRoutes = createChatRoutes(accountPool, cookieJar);
70
- const messagesRoutes = createMessagesRoutes(accountPool, cookieJar);
71
- const geminiRoutes = createGeminiRoutes(accountPool, cookieJar);
 
72
  const webRoutes = createWebRoutes(accountPool);
73
 
74
  app.route("/", authRoutes);
@@ -76,6 +80,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
76
  app.route("/", chatRoutes);
77
  app.route("/", messagesRoutes);
78
  app.route("/", geminiRoutes);
 
79
  app.route("/", createModelRoutes());
80
  app.route("/", webRoutes);
81
 
@@ -111,7 +116,10 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
111
  startUpdateChecker();
112
 
113
  // Start background model refresh (requires auth to be ready)
114
- startModelRefresh(accountPool, cookieJar);
 
 
 
115
 
116
  const server = serve({
117
  fetch: app.fetch,
@@ -125,6 +133,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
125
  stopUpdateChecker();
126
  stopModelRefresh();
127
  refreshScheduler.destroy();
 
128
  cookieJar.destroy();
129
  accountPool.destroy();
130
  resolve();
 
15
  import { createModelRoutes } from "./routes/models.js";
16
  import { createWebRoutes } from "./routes/web.js";
17
  import { CookieJar } from "./proxy/cookie-jar.js";
18
+ import { ProxyPool } from "./proxy/proxy-pool.js";
19
+ import { createProxyRoutes } from "./routes/proxies.js";
20
  import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
21
  import { initProxy } from "./tls/curl-binary.js";
22
  import { initTransport } from "./tls/transport.js";
 
56
  const accountPool = new AccountPool();
57
  const refreshScheduler = new RefreshScheduler(accountPool);
58
  const cookieJar = new CookieJar();
59
+ const proxyPool = new ProxyPool();
60
 
61
  // Create Hono app
62
  const app = new Hono();
 
68
 
69
  // Mount routes
70
  const authRoutes = createAuthRoutes(accountPool, refreshScheduler);
71
+ const accountRoutes = createAccountRoutes(accountPool, refreshScheduler, cookieJar, proxyPool);
72
+ const chatRoutes = createChatRoutes(accountPool, cookieJar, proxyPool);
73
+ const messagesRoutes = createMessagesRoutes(accountPool, cookieJar, proxyPool);
74
+ const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool);
75
+ const proxyRoutes = createProxyRoutes(proxyPool);
76
  const webRoutes = createWebRoutes(accountPool);
77
 
78
  app.route("/", authRoutes);
 
80
  app.route("/", chatRoutes);
81
  app.route("/", messagesRoutes);
82
  app.route("/", geminiRoutes);
83
+ app.route("/", proxyRoutes);
84
  app.route("/", createModelRoutes());
85
  app.route("/", webRoutes);
86
 
 
116
  startUpdateChecker();
117
 
118
  // Start background model refresh (requires auth to be ready)
119
+ startModelRefresh(accountPool, cookieJar, proxyPool);
120
+
121
+ // Start proxy health check timer (if proxies exist)
122
+ proxyPool.startHealthCheckTimer();
123
 
124
  const server = serve({
125
  fetch: app.fetch,
 
133
  stopUpdateChecker();
134
  stopModelRefresh();
135
  refreshScheduler.destroy();
136
+ proxyPool.destroy();
137
  cookieJar.destroy();
138
  accountPool.destroy();
139
  resolve();
src/models/model-fetcher.ts CHANGED
@@ -10,6 +10,7 @@ import { CodexApi } from "../proxy/codex-api.js";
10
  import { applyBackendModels, type BackendModelEntry } from "./model-store.js";
11
  import type { AccountPool } from "../auth/account-pool.js";
12
  import type { CookieJar } from "../proxy/cookie-jar.js";
 
13
  import { jitter } from "../utils/jitter.js";
14
 
15
  const REFRESH_INTERVAL_HOURS = 1;
@@ -18,6 +19,7 @@ const INITIAL_DELAY_MS = 5_000; // 5s after startup
18
  let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
19
  let _accountPool: AccountPool | null = null;
20
  let _cookieJar: CookieJar | null = null;
 
21
 
22
  /**
23
  * Fetch models from the Codex backend using an available account.
@@ -25,6 +27,7 @@ let _cookieJar: CookieJar | null = null;
25
  async function fetchModelsFromBackend(
26
  accountPool: AccountPool,
27
  cookieJar: CookieJar,
 
28
  ): Promise<void> {
29
  if (!accountPool.isAuthenticated()) return; // silently skip when no accounts
30
 
@@ -35,11 +38,13 @@ async function fetchModelsFromBackend(
35
  }
36
 
37
  try {
 
38
  const api = new CodexApi(
39
  acquired.token,
40
  acquired.accountId,
41
  cookieJar,
42
  acquired.entryId,
 
43
  );
44
 
45
  const models = await api.getModels();
@@ -65,14 +70,16 @@ async function fetchModelsFromBackend(
65
  export function startModelRefresh(
66
  accountPool: AccountPool,
67
  cookieJar: CookieJar,
 
68
  ): void {
69
  _accountPool = accountPool;
70
  _cookieJar = cookieJar;
 
71
 
72
  // Initial fetch after short delay
73
  _refreshTimer = setTimeout(async () => {
74
  try {
75
- await fetchModelsFromBackend(accountPool, cookieJar);
76
  } finally {
77
  scheduleNext(accountPool, cookieJar);
78
  }
@@ -88,7 +95,7 @@ function scheduleNext(
88
  const intervalMs = jitter(REFRESH_INTERVAL_HOURS * 3600 * 1000, 0.15);
89
  _refreshTimer = setTimeout(async () => {
90
  try {
91
- await fetchModelsFromBackend(accountPool, cookieJar);
92
  } finally {
93
  scheduleNext(accountPool, cookieJar);
94
  }
@@ -101,7 +108,7 @@ function scheduleNext(
101
  */
102
  export function triggerImmediateRefresh(): void {
103
  if (_accountPool && _cookieJar) {
104
- fetchModelsFromBackend(_accountPool, _cookieJar).catch((err) => {
105
  const msg = err instanceof Error ? err.message : String(err);
106
  console.warn(`[ModelFetcher] Immediate refresh failed: ${msg}`);
107
  });
 
10
  import { applyBackendModels, type BackendModelEntry } from "./model-store.js";
11
  import type { AccountPool } from "../auth/account-pool.js";
12
  import type { CookieJar } from "../proxy/cookie-jar.js";
13
+ import type { ProxyPool } from "../proxy/proxy-pool.js";
14
  import { jitter } from "../utils/jitter.js";
15
 
16
  const REFRESH_INTERVAL_HOURS = 1;
 
19
  let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
20
  let _accountPool: AccountPool | null = null;
21
  let _cookieJar: CookieJar | null = null;
22
+ let _proxyPool: ProxyPool | null = null;
23
 
24
  /**
25
  * Fetch models from the Codex backend using an available account.
 
27
  async function fetchModelsFromBackend(
28
  accountPool: AccountPool,
29
  cookieJar: CookieJar,
30
+ proxyPool: ProxyPool | null,
31
  ): Promise<void> {
32
  if (!accountPool.isAuthenticated()) return; // silently skip when no accounts
33
 
 
38
  }
39
 
40
  try {
41
+ const proxyUrl = proxyPool?.resolveProxyUrl(acquired.entryId);
42
  const api = new CodexApi(
43
  acquired.token,
44
  acquired.accountId,
45
  cookieJar,
46
  acquired.entryId,
47
+ proxyUrl,
48
  );
49
 
50
  const models = await api.getModels();
 
70
  export function startModelRefresh(
71
  accountPool: AccountPool,
72
  cookieJar: CookieJar,
73
+ proxyPool?: ProxyPool,
74
  ): void {
75
  _accountPool = accountPool;
76
  _cookieJar = cookieJar;
77
+ _proxyPool = proxyPool ?? null;
78
 
79
  // Initial fetch after short delay
80
  _refreshTimer = setTimeout(async () => {
81
  try {
82
+ await fetchModelsFromBackend(accountPool, cookieJar, _proxyPool);
83
  } finally {
84
  scheduleNext(accountPool, cookieJar);
85
  }
 
95
  const intervalMs = jitter(REFRESH_INTERVAL_HOURS * 3600 * 1000, 0.15);
96
  _refreshTimer = setTimeout(async () => {
97
  try {
98
+ await fetchModelsFromBackend(accountPool, cookieJar, _proxyPool);
99
  } finally {
100
  scheduleNext(accountPool, cookieJar);
101
  }
 
108
  */
109
  export function triggerImmediateRefresh(): void {
110
  if (_accountPool && _cookieJar) {
111
+ fetchModelsFromBackend(_accountPool, _cookieJar, _proxyPool).catch((err) => {
112
  const msg = err instanceof Error ? err.message : String(err);
113
  console.warn(`[ModelFetcher] Immediate refresh failed: ${msg}`);
114
  });
src/proxy/codex-api.ts CHANGED
@@ -57,17 +57,20 @@ export class CodexApi {
57
  private accountId: string | null;
58
  private cookieJar: CookieJar | null;
59
  private entryId: string | null;
 
60
 
61
  constructor(
62
  token: string,
63
  accountId: string | null,
64
  cookieJar?: CookieJar | null,
65
  entryId?: string | null,
 
66
  ) {
67
  this.token = token;
68
  this.accountId = accountId;
69
  this.cookieJar = cookieJar ?? null;
70
  this.entryId = entryId ?? null;
 
71
  }
72
 
73
  setToken(token: string): void {
@@ -111,7 +114,7 @@ export class CodexApi {
111
 
112
  let body: string;
113
  try {
114
- const result = await transport.get(url, headers, 15);
115
  body = result.body;
116
  } catch (err) {
117
  const msg = err instanceof Error ? err.message : String(err);
@@ -157,7 +160,7 @@ export class CodexApi {
157
 
158
  for (const url of endpoints) {
159
  try {
160
- const result = await transport.get(url, headers, 15);
161
  const parsed = JSON.parse(result.body) as Record<string, unknown>;
162
 
163
  // sentinel/chat-requirements returns { chat_models: { models: [...], ... } }
@@ -219,7 +222,7 @@ export class CodexApi {
219
  }
220
 
221
  try {
222
- const result = await transport.get(url, headers, 15);
223
  return JSON.parse(result.body) as Record<string, unknown>;
224
  } catch {
225
  return null;
@@ -248,7 +251,7 @@ export class CodexApi {
248
 
249
  let transportRes;
250
  try {
251
- transportRes = await transport.post(url, headers, JSON.stringify(request), signal, timeout);
252
  } catch (err) {
253
  const msg = err instanceof Error ? err.message : String(err);
254
  throw new CodexApiError(0, msg);
 
57
  private accountId: string | null;
58
  private cookieJar: CookieJar | null;
59
  private entryId: string | null;
60
+ private proxyUrl: string | null | undefined;
61
 
62
  constructor(
63
  token: string,
64
  accountId: string | null,
65
  cookieJar?: CookieJar | null,
66
  entryId?: string | null,
67
+ proxyUrl?: string | null,
68
  ) {
69
  this.token = token;
70
  this.accountId = accountId;
71
  this.cookieJar = cookieJar ?? null;
72
  this.entryId = entryId ?? null;
73
+ this.proxyUrl = proxyUrl;
74
  }
75
 
76
  setToken(token: string): void {
 
114
 
115
  let body: string;
116
  try {
117
+ const result = await transport.get(url, headers, 15, this.proxyUrl);
118
  body = result.body;
119
  } catch (err) {
120
  const msg = err instanceof Error ? err.message : String(err);
 
160
 
161
  for (const url of endpoints) {
162
  try {
163
+ const result = await transport.get(url, headers, 15, this.proxyUrl);
164
  const parsed = JSON.parse(result.body) as Record<string, unknown>;
165
 
166
  // sentinel/chat-requirements returns { chat_models: { models: [...], ... } }
 
222
  }
223
 
224
  try {
225
+ const result = await transport.get(url, headers, 15, this.proxyUrl);
226
  return JSON.parse(result.body) as Record<string, unknown>;
227
  } catch {
228
  return null;
 
251
 
252
  let transportRes;
253
  try {
254
+ transportRes = await transport.post(url, headers, JSON.stringify(request), signal, timeout, this.proxyUrl);
255
  } catch (err) {
256
  const msg = err instanceof Error ? err.message : String(err);
257
  throw new CodexApiError(0, msg);
src/proxy/proxy-pool.ts ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ProxyPool — per-account proxy management with health checks.
3
+ *
4
+ * Stores proxy entries and account→proxy assignments.
5
+ * Supports manual assignment, "auto" round-robin, "direct" (no proxy),
6
+ * and "global" (use the globally detected proxy).
7
+ *
8
+ * Persistence: data/proxies.json (atomic write via tmp + rename).
9
+ * Health checks: periodic + on-demand, using api.ipify.org for exit IP.
10
+ */
11
+
12
+ import {
13
+ readFileSync,
14
+ writeFileSync,
15
+ renameSync,
16
+ existsSync,
17
+ mkdirSync,
18
+ } from "fs";
19
+ import { resolve, dirname } from "path";
20
+ import { getDataDir } from "../paths.js";
21
+ import { getTransport } from "../tls/transport.js";
22
+
23
+ function getProxiesFile(): string {
24
+ return resolve(getDataDir(), "proxies.json");
25
+ }
26
+
27
+ // ── Types ─────────────────────────────────────────────────────────────
28
+
29
+ export interface ProxyHealthInfo {
30
+ exitIp: string | null;
31
+ latencyMs: number;
32
+ lastChecked: string;
33
+ error: string | null;
34
+ }
35
+
36
+ export type ProxyStatus = "active" | "unreachable" | "disabled";
37
+
38
+ export interface ProxyEntry {
39
+ id: string;
40
+ name: string;
41
+ url: string;
42
+ status: ProxyStatus;
43
+ health: ProxyHealthInfo | null;
44
+ addedAt: string;
45
+ }
46
+
47
+ /** Special assignment values (not a proxy ID). */
48
+ export type SpecialAssignment = "global" | "direct" | "auto";
49
+
50
+ export interface ProxyAssignment {
51
+ accountId: string;
52
+ proxyId: string; // ProxyEntry.id | SpecialAssignment
53
+ }
54
+
55
+ interface ProxiesFile {
56
+ proxies: ProxyEntry[];
57
+ assignments: ProxyAssignment[];
58
+ healthCheckIntervalMinutes: number;
59
+ }
60
+
61
+ const HEALTH_CHECK_URL = "https://api.ipify.org?format=json";
62
+ const DEFAULT_HEALTH_INTERVAL_MIN = 5;
63
+
64
+ // ── ProxyPool ─────────────────────────────────────────────────────────
65
+
66
+ export class ProxyPool {
67
+ private proxies: Map<string, ProxyEntry> = new Map();
68
+ private assignments: Map<string, string> = new Map(); // accountId → proxyId
69
+ private healthIntervalMin = DEFAULT_HEALTH_INTERVAL_MIN;
70
+ private persistTimer: ReturnType<typeof setTimeout> | null = null;
71
+ private healthTimer: ReturnType<typeof setInterval> | null = null;
72
+ private _roundRobinIndex = 0;
73
+
74
+ constructor() {
75
+ this.load();
76
+ }
77
+
78
+ // ── CRUD ──────────────────────────────────────────────────────────
79
+
80
+ add(name: string, url: string): string {
81
+ const id = randomHex(8);
82
+ const entry: ProxyEntry = {
83
+ id,
84
+ name: name.trim(),
85
+ url: url.trim(),
86
+ status: "active",
87
+ health: null,
88
+ addedAt: new Date().toISOString(),
89
+ };
90
+ this.proxies.set(id, entry);
91
+ this.persistNow();
92
+ return id;
93
+ }
94
+
95
+ remove(id: string): boolean {
96
+ if (!this.proxies.delete(id)) return false;
97
+ // Clean up assignments pointing to this proxy
98
+ for (const [accountId, proxyId] of this.assignments) {
99
+ if (proxyId === id) {
100
+ this.assignments.delete(accountId);
101
+ }
102
+ }
103
+ this.persistNow();
104
+ return true;
105
+ }
106
+
107
+ update(id: string, fields: { name?: string; url?: string }): boolean {
108
+ const entry = this.proxies.get(id);
109
+ if (!entry) return false;
110
+ if (fields.name !== undefined) entry.name = fields.name.trim();
111
+ if (fields.url !== undefined) {
112
+ entry.url = fields.url.trim();
113
+ entry.health = null; // reset health on URL change
114
+ entry.status = "active";
115
+ }
116
+ this.schedulePersist();
117
+ return true;
118
+ }
119
+
120
+ getAll(): ProxyEntry[] {
121
+ return Array.from(this.proxies.values());
122
+ }
123
+
124
+ getById(id: string): ProxyEntry | undefined {
125
+ return this.proxies.get(id);
126
+ }
127
+
128
+ enable(id: string): boolean {
129
+ const entry = this.proxies.get(id);
130
+ if (!entry) return false;
131
+ entry.status = "active";
132
+ this.schedulePersist();
133
+ return true;
134
+ }
135
+
136
+ disable(id: string): boolean {
137
+ const entry = this.proxies.get(id);
138
+ if (!entry) return false;
139
+ entry.status = "disabled";
140
+ this.schedulePersist();
141
+ return true;
142
+ }
143
+
144
+ // ── Assignment ────────────────────────────────────────────────────
145
+
146
+ assign(accountId: string, proxyId: string): void {
147
+ this.assignments.set(accountId, proxyId);
148
+ this.persistNow();
149
+ }
150
+
151
+ unassign(accountId: string): void {
152
+ if (this.assignments.delete(accountId)) {
153
+ this.persistNow();
154
+ }
155
+ }
156
+
157
+ getAssignment(accountId: string): string {
158
+ return this.assignments.get(accountId) ?? "global";
159
+ }
160
+
161
+ getAllAssignments(): ProxyAssignment[] {
162
+ const result: ProxyAssignment[] = [];
163
+ for (const [accountId, proxyId] of this.assignments) {
164
+ result.push({ accountId, proxyId });
165
+ }
166
+ return result;
167
+ }
168
+
169
+ /**
170
+ * Get display name for an assignment.
171
+ */
172
+ getAssignmentDisplayName(accountId: string): string {
173
+ const assignment = this.getAssignment(accountId);
174
+ if (assignment === "global") return "Global Default";
175
+ if (assignment === "direct") return "Direct (No Proxy)";
176
+ if (assignment === "auto") return "Auto (Round-Robin)";
177
+ const proxy = this.proxies.get(assignment);
178
+ return proxy ? proxy.name : "Unknown Proxy";
179
+ }
180
+
181
+ // ── Resolution ────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Resolve the proxy URL for an account.
185
+ * Returns:
186
+ * undefined — use global proxy (default behavior)
187
+ * null — direct connection (no proxy)
188
+ * string — specific proxy URL
189
+ */
190
+ resolveProxyUrl(accountId: string): string | null | undefined {
191
+ const assignment = this.getAssignment(accountId);
192
+
193
+ if (assignment === "global") return undefined;
194
+ if (assignment === "direct") return null;
195
+
196
+ if (assignment === "auto") {
197
+ return this.pickRoundRobin();
198
+ }
199
+
200
+ // Specific proxy ID
201
+ const proxy = this.proxies.get(assignment);
202
+ if (!proxy || proxy.status !== "active") {
203
+ // Proxy deleted or unreachable/disabled — fall back to global
204
+ return undefined;
205
+ }
206
+ return proxy.url;
207
+ }
208
+
209
+ /**
210
+ * Round-robin pick from active proxies.
211
+ * Returns undefined (global) if no active proxies exist.
212
+ */
213
+ private pickRoundRobin(): string | undefined {
214
+ const active = Array.from(this.proxies.values()).filter(
215
+ (p) => p.status === "active",
216
+ );
217
+ if (active.length === 0) return undefined;
218
+
219
+ this._roundRobinIndex = this._roundRobinIndex % active.length;
220
+ const picked = active[this._roundRobinIndex];
221
+ this._roundRobinIndex = (this._roundRobinIndex + 1) % active.length;
222
+ return picked.url;
223
+ }
224
+
225
+ // ── Health Check ──────────────────────────────────────────────────
226
+
227
+ async healthCheck(id: string): Promise<ProxyHealthInfo> {
228
+ const proxy = this.proxies.get(id);
229
+ if (!proxy) {
230
+ throw new Error(`Proxy ${id} not found`);
231
+ }
232
+
233
+ const transport = getTransport();
234
+ const start = Date.now();
235
+
236
+ try {
237
+ const result = await transport.get(
238
+ HEALTH_CHECK_URL,
239
+ { Accept: "application/json" },
240
+ 10,
241
+ proxy.url,
242
+ );
243
+ const latencyMs = Date.now() - start;
244
+
245
+ let exitIp: string | null = null;
246
+ try {
247
+ const parsed = JSON.parse(result.body) as { ip?: string };
248
+ exitIp = parsed.ip ?? null;
249
+ } catch {
250
+ // Could not parse IP
251
+ }
252
+
253
+ const info: ProxyHealthInfo = {
254
+ exitIp,
255
+ latencyMs,
256
+ lastChecked: new Date().toISOString(),
257
+ error: null,
258
+ };
259
+
260
+ proxy.health = info;
261
+ // Only change status if not manually disabled
262
+ if (proxy.status !== "disabled") {
263
+ proxy.status = "active";
264
+ }
265
+ this.schedulePersist();
266
+ return info;
267
+ } catch (err) {
268
+ const latencyMs = Date.now() - start;
269
+ const error = err instanceof Error ? err.message : String(err);
270
+
271
+ const info: ProxyHealthInfo = {
272
+ exitIp: null,
273
+ latencyMs,
274
+ lastChecked: new Date().toISOString(),
275
+ error,
276
+ };
277
+
278
+ proxy.health = info;
279
+ if (proxy.status !== "disabled") {
280
+ proxy.status = "unreachable";
281
+ }
282
+ this.schedulePersist();
283
+ return info;
284
+ }
285
+ }
286
+
287
+ async healthCheckAll(): Promise<void> {
288
+ const ids = Array.from(this.proxies.keys());
289
+ if (ids.length === 0) return;
290
+
291
+ console.log(`[ProxyPool] Health checking ${ids.length} proxies...`);
292
+ await Promise.allSettled(ids.map((id) => this.healthCheck(id)));
293
+
294
+ const active = Array.from(this.proxies.values()).filter(
295
+ (p) => p.status === "active",
296
+ ).length;
297
+ console.log(
298
+ `[ProxyPool] Health check complete: ${active}/${ids.length} active`,
299
+ );
300
+ }
301
+
302
+ startHealthCheckTimer(): void {
303
+ this.stopHealthCheckTimer();
304
+ if (this.proxies.size === 0) return;
305
+
306
+ const intervalMs = this.healthIntervalMin * 60 * 1000;
307
+ this.healthTimer = setInterval(() => {
308
+ this.healthCheckAll().catch((err) => {
309
+ const msg = err instanceof Error ? err.message : String(err);
310
+ console.warn(`[ProxyPool] Periodic health check error: ${msg}`);
311
+ });
312
+ }, intervalMs);
313
+ if (this.healthTimer.unref) this.healthTimer.unref();
314
+
315
+ console.log(
316
+ `[ProxyPool] Health check timer started (every ${this.healthIntervalMin}min)`,
317
+ );
318
+ }
319
+
320
+ stopHealthCheckTimer(): void {
321
+ if (this.healthTimer) {
322
+ clearInterval(this.healthTimer);
323
+ this.healthTimer = null;
324
+ }
325
+ }
326
+
327
+ getHealthIntervalMinutes(): number {
328
+ return this.healthIntervalMin;
329
+ }
330
+
331
+ setHealthIntervalMinutes(minutes: number): void {
332
+ this.healthIntervalMin = Math.max(1, minutes);
333
+ this.schedulePersist();
334
+ // Restart timer with new interval
335
+ if (this.healthTimer) {
336
+ this.startHealthCheckTimer();
337
+ }
338
+ }
339
+
340
+ // ── Persistence ───────────────────────────────────────────────────
341
+
342
+ private schedulePersist(): void {
343
+ if (this.persistTimer) return;
344
+ this.persistTimer = setTimeout(() => {
345
+ this.persistTimer = null;
346
+ this.persistNow();
347
+ }, 1000);
348
+ }
349
+
350
+ persistNow(): void {
351
+ if (this.persistTimer) {
352
+ clearTimeout(this.persistTimer);
353
+ this.persistTimer = null;
354
+ }
355
+ try {
356
+ const filePath = getProxiesFile();
357
+ const dir = dirname(filePath);
358
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
359
+
360
+ const data: ProxiesFile = {
361
+ proxies: Array.from(this.proxies.values()),
362
+ assignments: this.getAllAssignments(),
363
+ healthCheckIntervalMinutes: this.healthIntervalMin,
364
+ };
365
+
366
+ const tmpFile = filePath + ".tmp";
367
+ writeFileSync(tmpFile, JSON.stringify(data, null, 2), "utf-8");
368
+ renameSync(tmpFile, filePath);
369
+ } catch (err) {
370
+ console.warn(
371
+ "[ProxyPool] Failed to persist:",
372
+ err instanceof Error ? err.message : err,
373
+ );
374
+ }
375
+ }
376
+
377
+ private load(): void {
378
+ try {
379
+ const filePath = getProxiesFile();
380
+ if (!existsSync(filePath)) return;
381
+
382
+ const raw = readFileSync(filePath, "utf-8");
383
+ const data = JSON.parse(raw) as Partial<ProxiesFile>;
384
+
385
+ if (Array.isArray(data.proxies)) {
386
+ for (const p of data.proxies) {
387
+ if (p && typeof p.id === "string" && typeof p.url === "string") {
388
+ this.proxies.set(p.id, {
389
+ id: p.id,
390
+ name: p.name ?? "",
391
+ url: p.url,
392
+ status: p.status ?? "active",
393
+ health: p.health ?? null,
394
+ addedAt: p.addedAt ?? new Date().toISOString(),
395
+ });
396
+ }
397
+ }
398
+ }
399
+
400
+ if (Array.isArray(data.assignments)) {
401
+ for (const a of data.assignments) {
402
+ if (
403
+ a &&
404
+ typeof a.accountId === "string" &&
405
+ typeof a.proxyId === "string"
406
+ ) {
407
+ this.assignments.set(a.accountId, a.proxyId);
408
+ }
409
+ }
410
+ }
411
+
412
+ if (typeof data.healthCheckIntervalMinutes === "number") {
413
+ this.healthIntervalMin = Math.max(1, data.healthCheckIntervalMinutes);
414
+ }
415
+
416
+ if (this.proxies.size > 0) {
417
+ console.log(
418
+ `[ProxyPool] Loaded ${this.proxies.size} proxies, ${this.assignments.size} assignments`,
419
+ );
420
+ }
421
+ } catch (err) {
422
+ console.warn(
423
+ "[ProxyPool] Failed to load:",
424
+ err instanceof Error ? err.message : err,
425
+ );
426
+ }
427
+ }
428
+
429
+ destroy(): void {
430
+ this.stopHealthCheckTimer();
431
+ if (this.persistTimer) {
432
+ clearTimeout(this.persistTimer);
433
+ this.persistTimer = null;
434
+ }
435
+ this.persistNow();
436
+ }
437
+ }
438
+
439
+ // ── Helpers ─────────────────────────────────────────────────────────
440
+
441
+ function randomHex(bytes: number): string {
442
+ const arr = new Uint8Array(bytes);
443
+ crypto.getRandomValues(arr);
444
+ return Array.from(arr)
445
+ .map((b) => b.toString(16).padStart(2, "0"))
446
+ .join("");
447
+ }
src/routes/accounts.ts CHANGED
@@ -23,6 +23,7 @@ import { CodexApi } from "../proxy/codex-api.js";
23
  import type { CodexUsageResponse } from "../proxy/codex-api.js";
24
  import type { CodexQuota, AccountInfo } from "../auth/types.js";
25
  import type { CookieJar } from "../proxy/cookie-jar.js";
 
26
 
27
  function toQuota(usage: CodexUsageResponse): CodexQuota {
28
  return {
@@ -51,12 +52,14 @@ export function createAccountRoutes(
51
  pool: AccountPool,
52
  scheduler: RefreshScheduler,
53
  cookieJar?: CookieJar,
 
54
  ): Hono {
55
  const app = new Hono();
56
 
57
- /** Helper: build a CodexApi with cookie support. */
58
  function makeApi(entryId: string, token: string, accountId: string | null): CodexApi {
59
- return new CodexApi(token, accountId, cookieJar, entryId);
 
60
  }
61
 
62
  // Start OAuth flow to add a new account — 302 redirect to Auth0
@@ -73,7 +76,12 @@ export function createAccountRoutes(
73
  const wantQuota = c.req.query("quota") === "true";
74
 
75
  if (!wantQuota) {
76
- return c.json({ accounts });
 
 
 
 
 
77
  }
78
 
79
  // Fetch quota for every active account in parallel
@@ -93,9 +101,18 @@ export function createAccountRoutes(
93
  pool.syncRateLimitWindow(acct.id, resetAt, windowSec);
94
  // Re-read usage after potential reset
95
  const freshAcct = pool.getAccounts().find((a) => a.id === acct.id) ?? acct;
96
- return { ...freshAcct, quota: toQuota(usage) };
 
 
 
 
 
97
  } catch {
98
- return acct; // skip on error — no quota field
 
 
 
 
99
  }
100
  }),
101
  );
 
23
  import type { CodexUsageResponse } from "../proxy/codex-api.js";
24
  import type { CodexQuota, AccountInfo } from "../auth/types.js";
25
  import type { CookieJar } from "../proxy/cookie-jar.js";
26
+ import type { ProxyPool } from "../proxy/proxy-pool.js";
27
 
28
  function toQuota(usage: CodexUsageResponse): CodexQuota {
29
  return {
 
52
  pool: AccountPool,
53
  scheduler: RefreshScheduler,
54
  cookieJar?: CookieJar,
55
+ proxyPool?: ProxyPool,
56
  ): Hono {
57
  const app = new Hono();
58
 
59
+ /** Helper: build a CodexApi with cookie + proxy support. */
60
  function makeApi(entryId: string, token: string, accountId: string | null): CodexApi {
61
+ const proxyUrl = proxyPool?.resolveProxyUrl(entryId);
62
+ return new CodexApi(token, accountId, cookieJar, entryId, proxyUrl);
63
  }
64
 
65
  // Start OAuth flow to add a new account — 302 redirect to Auth0
 
76
  const wantQuota = c.req.query("quota") === "true";
77
 
78
  if (!wantQuota) {
79
+ const enrichedBasic = accounts.map((acct) => ({
80
+ ...acct,
81
+ proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
82
+ proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
83
+ }));
84
+ return c.json({ accounts: enrichedBasic });
85
  }
86
 
87
  // Fetch quota for every active account in parallel
 
101
  pool.syncRateLimitWindow(acct.id, resetAt, windowSec);
102
  // Re-read usage after potential reset
103
  const freshAcct = pool.getAccounts().find((a) => a.id === acct.id) ?? acct;
104
+ return {
105
+ ...freshAcct,
106
+ quota: toQuota(usage),
107
+ proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
108
+ proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
109
+ };
110
  } catch {
111
+ return {
112
+ ...acct,
113
+ proxyId: proxyPool?.getAssignment(acct.id) ?? "global",
114
+ proxyName: proxyPool?.getAssignmentDisplayName(acct.id) ?? "Global Default",
115
+ };
116
  }
117
  }),
118
  );
src/routes/chat.ts CHANGED
@@ -2,6 +2,7 @@ import { Hono } from "hono";
2
  import { ChatCompletionRequestSchema } from "../types/openai.js";
3
  import type { AccountPool } from "../auth/account-pool.js";
4
  import type { CookieJar } from "../proxy/cookie-jar.js";
 
5
  import { translateToCodexRequest } from "../translation/openai-to-codex.js";
6
  import {
7
  streamCodexToOpenAI,
@@ -52,6 +53,7 @@ function makeOpenAIFormat(wantReasoning: boolean): FormatAdapter {
52
  export function createChatRoutes(
53
  accountPool: AccountPool,
54
  cookieJar?: CookieJar,
 
55
  ): Hono {
56
  const app = new Hono();
57
 
@@ -132,6 +134,7 @@ export function createChatRoutes(
132
  isStreaming: req.stream,
133
  },
134
  makeOpenAIFormat(wantReasoning),
 
135
  );
136
  });
137
 
 
2
  import { ChatCompletionRequestSchema } from "../types/openai.js";
3
  import type { AccountPool } from "../auth/account-pool.js";
4
  import type { CookieJar } from "../proxy/cookie-jar.js";
5
+ import type { ProxyPool } from "../proxy/proxy-pool.js";
6
  import { translateToCodexRequest } from "../translation/openai-to-codex.js";
7
  import {
8
  streamCodexToOpenAI,
 
53
  export function createChatRoutes(
54
  accountPool: AccountPool,
55
  cookieJar?: CookieJar,
56
+ proxyPool?: ProxyPool,
57
  ): Hono {
58
  const app = new Hono();
59
 
 
134
  isStreaming: req.stream,
135
  },
136
  makeOpenAIFormat(wantReasoning),
137
+ proxyPool,
138
  );
139
  });
140
 
src/routes/gemini.ts CHANGED
@@ -11,6 +11,7 @@ import { GEMINI_STATUS_MAP } from "../types/gemini.js";
11
  import { GeminiGenerateContentRequestSchema } from "../types/gemini.js";
12
  import type { AccountPool } from "../auth/account-pool.js";
13
  import type { CookieJar } from "../proxy/cookie-jar.js";
 
14
  import {
15
  translateGeminiToCodexRequest,
16
  } from "../translation/gemini-to-codex.js";
@@ -73,6 +74,7 @@ const GEMINI_FORMAT: FormatAdapter = {
73
  export function createGeminiRoutes(
74
  accountPool: AccountPool,
75
  cookieJar?: CookieJar,
 
76
  ): Hono {
77
  const app = new Hono();
78
 
@@ -159,6 +161,7 @@ export function createGeminiRoutes(
159
  isStreaming,
160
  },
161
  GEMINI_FORMAT,
 
162
  );
163
  });
164
 
 
11
  import { GeminiGenerateContentRequestSchema } from "../types/gemini.js";
12
  import type { AccountPool } from "../auth/account-pool.js";
13
  import type { CookieJar } from "../proxy/cookie-jar.js";
14
+ import type { ProxyPool } from "../proxy/proxy-pool.js";
15
  import {
16
  translateGeminiToCodexRequest,
17
  } from "../translation/gemini-to-codex.js";
 
74
  export function createGeminiRoutes(
75
  accountPool: AccountPool,
76
  cookieJar?: CookieJar,
77
+ proxyPool?: ProxyPool,
78
  ): Hono {
79
  const app = new Hono();
80
 
 
161
  isStreaming,
162
  },
163
  GEMINI_FORMAT,
164
+ proxyPool,
165
  );
166
  });
167
 
src/routes/messages.ts CHANGED
@@ -9,6 +9,7 @@ import { AnthropicMessagesRequestSchema } from "../types/anthropic.js";
9
  import type { AnthropicErrorBody, AnthropicErrorType } from "../types/anthropic.js";
10
  import type { AccountPool } from "../auth/account-pool.js";
11
  import type { CookieJar } from "../proxy/cookie-jar.js";
 
12
  import { translateAnthropicToCodexRequest } from "../translation/anthropic-to-codex.js";
13
  import {
14
  streamCodexToAnthropic,
@@ -48,6 +49,7 @@ function makeAnthropicFormat(wantThinking: boolean): FormatAdapter {
48
  export function createMessagesRoutes(
49
  accountPool: AccountPool,
50
  cookieJar?: CookieJar,
 
51
  ): Hono {
52
  const app = new Hono();
53
 
@@ -106,6 +108,7 @@ export function createMessagesRoutes(
106
  isStreaming: req.stream,
107
  },
108
  makeAnthropicFormat(wantThinking),
 
109
  );
110
  });
111
 
 
9
  import type { AnthropicErrorBody, AnthropicErrorType } from "../types/anthropic.js";
10
  import type { AccountPool } from "../auth/account-pool.js";
11
  import type { CookieJar } from "../proxy/cookie-jar.js";
12
+ import type { ProxyPool } from "../proxy/proxy-pool.js";
13
  import { translateAnthropicToCodexRequest } from "../translation/anthropic-to-codex.js";
14
  import {
15
  streamCodexToAnthropic,
 
49
  export function createMessagesRoutes(
50
  accountPool: AccountPool,
51
  cookieJar?: CookieJar,
52
+ proxyPool?: ProxyPool,
53
  ): Hono {
54
  const app = new Hono();
55
 
 
108
  isStreaming: req.stream,
109
  },
110
  makeAnthropicFormat(wantThinking),
111
+ proxyPool,
112
  );
113
  });
114
 
src/routes/proxies.ts ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Proxy pool management API routes.
3
+ *
4
+ * GET /api/proxies — list all proxies + assignments
5
+ * POST /api/proxies — add proxy { name, url }
6
+ * PUT /api/proxies/:id — update proxy { name?, url? }
7
+ * DELETE /api/proxies/:id — remove proxy
8
+ * POST /api/proxies/:id/check — health check single proxy
9
+ * POST /api/proxies/:id/enable — enable proxy
10
+ * POST /api/proxies/:id/disable — disable proxy
11
+ * POST /api/proxies/check-all — health check all proxies
12
+ * POST /api/proxies/assign — assign proxy to account { accountId, proxyId }
13
+ * DELETE /api/proxies/assign/:accountId — unassign proxy from account
14
+ * PUT /api/proxies/settings — update settings { healthCheckIntervalMinutes }
15
+ */
16
+
17
+ import { Hono } from "hono";
18
+ import type { ProxyPool } from "../proxy/proxy-pool.js";
19
+
20
+ export function createProxyRoutes(proxyPool: ProxyPool): Hono {
21
+ const app = new Hono();
22
+
23
+ // List all proxies + assignments
24
+ app.get("/api/proxies", (c) => {
25
+ return c.json({
26
+ proxies: proxyPool.getAll(),
27
+ assignments: proxyPool.getAllAssignments(),
28
+ healthCheckIntervalMinutes: proxyPool.getHealthIntervalMinutes(),
29
+ });
30
+ });
31
+
32
+ // Add proxy
33
+ app.post("/api/proxies", async (c) => {
34
+ const body = await c.req.json<{ name?: string; url?: string }>();
35
+ const url = body.url?.trim();
36
+
37
+ if (!url) {
38
+ c.status(400);
39
+ return c.json({ error: "url is required" });
40
+ }
41
+
42
+ // Basic URL validation
43
+ try {
44
+ new URL(url);
45
+ } catch {
46
+ c.status(400);
47
+ return c.json({ error: "Invalid proxy URL format" });
48
+ }
49
+
50
+ const name = body.name?.trim() || url;
51
+ const id = proxyPool.add(name, url);
52
+ const proxy = proxyPool.getById(id);
53
+
54
+ // Restart health check timer if this is the first proxy
55
+ proxyPool.startHealthCheckTimer();
56
+
57
+ return c.json({ success: true, proxy });
58
+ });
59
+
60
+ // Update proxy
61
+ app.put("/api/proxies/:id", async (c) => {
62
+ const id = c.req.param("id");
63
+ const body = await c.req.json<{ name?: string; url?: string }>();
64
+
65
+ if (!proxyPool.update(id, body)) {
66
+ c.status(404);
67
+ return c.json({ error: "Proxy not found" });
68
+ }
69
+
70
+ return c.json({ success: true, proxy: proxyPool.getById(id) });
71
+ });
72
+
73
+ // Remove proxy
74
+ app.delete("/api/proxies/:id", (c) => {
75
+ const id = c.req.param("id");
76
+ if (!proxyPool.remove(id)) {
77
+ c.status(404);
78
+ return c.json({ error: "Proxy not found" });
79
+ }
80
+ return c.json({ success: true });
81
+ });
82
+
83
+ // Health check single proxy
84
+ app.post("/api/proxies/:id/check", async (c) => {
85
+ const id = c.req.param("id");
86
+ try {
87
+ const health = await proxyPool.healthCheck(id);
88
+ const proxy = proxyPool.getById(id);
89
+ return c.json({ success: true, proxy, health });
90
+ } catch (err) {
91
+ const msg = err instanceof Error ? err.message : String(err);
92
+ c.status(404);
93
+ return c.json({ error: msg });
94
+ }
95
+ });
96
+
97
+ // Enable proxy
98
+ app.post("/api/proxies/:id/enable", (c) => {
99
+ const id = c.req.param("id");
100
+ if (!proxyPool.enable(id)) {
101
+ c.status(404);
102
+ return c.json({ error: "Proxy not found" });
103
+ }
104
+ return c.json({ success: true, proxy: proxyPool.getById(id) });
105
+ });
106
+
107
+ // Disable proxy
108
+ app.post("/api/proxies/:id/disable", (c) => {
109
+ const id = c.req.param("id");
110
+ if (!proxyPool.disable(id)) {
111
+ c.status(404);
112
+ return c.json({ error: "Proxy not found" });
113
+ }
114
+ return c.json({ success: true, proxy: proxyPool.getById(id) });
115
+ });
116
+
117
+ // Health check all — must be before /:id routes
118
+ app.post("/api/proxies/check-all", async (c) => {
119
+ await proxyPool.healthCheckAll();
120
+ return c.json({
121
+ success: true,
122
+ proxies: proxyPool.getAll(),
123
+ });
124
+ });
125
+
126
+ // Assign proxy to account
127
+ app.post("/api/proxies/assign", async (c) => {
128
+ const body = await c.req.json<{ accountId?: string; proxyId?: string }>();
129
+ const { accountId, proxyId } = body;
130
+
131
+ if (!accountId || !proxyId) {
132
+ c.status(400);
133
+ return c.json({ error: "accountId and proxyId are required" });
134
+ }
135
+
136
+ // Validate proxyId is a known value
137
+ const validSpecial = ["global", "direct", "auto"];
138
+ if (!validSpecial.includes(proxyId) && !proxyPool.getById(proxyId)) {
139
+ c.status(400);
140
+ return c.json({ error: "Invalid proxyId. Use 'global', 'direct', 'auto', or a valid proxy ID." });
141
+ }
142
+
143
+ proxyPool.assign(accountId, proxyId);
144
+ return c.json({
145
+ success: true,
146
+ assignment: { accountId, proxyId },
147
+ displayName: proxyPool.getAssignmentDisplayName(accountId),
148
+ });
149
+ });
150
+
151
+ // Unassign proxy from account
152
+ app.delete("/api/proxies/assign/:accountId", (c) => {
153
+ const accountId = c.req.param("accountId");
154
+ proxyPool.unassign(accountId);
155
+ return c.json({ success: true });
156
+ });
157
+
158
+ // Update settings
159
+ app.put("/api/proxies/settings", async (c) => {
160
+ const body = await c.req.json<{ healthCheckIntervalMinutes?: number }>();
161
+ if (typeof body.healthCheckIntervalMinutes === "number") {
162
+ proxyPool.setHealthIntervalMinutes(body.healthCheckIntervalMinutes);
163
+ }
164
+ return c.json({
165
+ success: true,
166
+ healthCheckIntervalMinutes: proxyPool.getHealthIntervalMinutes(),
167
+ });
168
+ });
169
+
170
+ return app;
171
+ }
src/routes/shared/proxy-handler.ts CHANGED
@@ -14,6 +14,7 @@ import type { CodexResponsesRequest } from "../../proxy/codex-api.js";
14
  import { EmptyResponseError } from "../../translation/codex-event-extractor.js";
15
  import type { AccountPool } from "../../auth/account-pool.js";
16
  import type { CookieJar } from "../../proxy/cookie-jar.js";
 
17
  import { withRetry } from "../../utils/retry.js";
18
 
19
  /** Data prepared by each route after parsing and translating the request. */
@@ -59,6 +60,7 @@ export async function handleProxyRequest(
59
  cookieJar: CookieJar | undefined,
60
  req: ProxyRequest,
61
  fmt: FormatAdapter,
 
62
  ): Promise<Response> {
63
  // 1. Acquire account
64
  const acquired = accountPool.acquire();
@@ -68,7 +70,8 @@ export async function handleProxyRequest(
68
  }
69
 
70
  const { entryId, token, accountId } = acquired;
71
- const codexApi = new CodexApi(token, accountId, cookieJar, entryId);
 
72
  // Tracks which account the outer catch should release (updated by retry loop)
73
  let activeEntryId = entryId;
74
 
@@ -158,7 +161,8 @@ export async function handleProxyRequest(
158
 
159
  currentEntryId = newAcquired.entryId;
160
  activeEntryId = currentEntryId;
161
- currentCodexApi = new CodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId);
 
162
  try {
163
  currentRawResponse = await withRetry(
164
  () => currentCodexApi.createResponse(req.codexRequest, abortController.signal),
 
14
  import { EmptyResponseError } from "../../translation/codex-event-extractor.js";
15
  import type { AccountPool } from "../../auth/account-pool.js";
16
  import type { CookieJar } from "../../proxy/cookie-jar.js";
17
+ import type { ProxyPool } from "../../proxy/proxy-pool.js";
18
  import { withRetry } from "../../utils/retry.js";
19
 
20
  /** Data prepared by each route after parsing and translating the request. */
 
60
  cookieJar: CookieJar | undefined,
61
  req: ProxyRequest,
62
  fmt: FormatAdapter,
63
+ proxyPool?: ProxyPool,
64
  ): Promise<Response> {
65
  // 1. Acquire account
66
  const acquired = accountPool.acquire();
 
70
  }
71
 
72
  const { entryId, token, accountId } = acquired;
73
+ const proxyUrl = proxyPool?.resolveProxyUrl(entryId);
74
+ const codexApi = new CodexApi(token, accountId, cookieJar, entryId, proxyUrl);
75
  // Tracks which account the outer catch should release (updated by retry loop)
76
  let activeEntryId = entryId;
77
 
 
161
 
162
  currentEntryId = newAcquired.entryId;
163
  activeEntryId = currentEntryId;
164
+ const retryProxyUrl = proxyPool?.resolveProxyUrl(newAcquired.entryId);
165
+ currentCodexApi = new CodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId, retryProxyUrl);
166
  try {
167
  currentRawResponse = await withRetry(
168
  () => currentCodexApi.createResponse(req.codexRequest, abortController.signal),
src/tls/curl-cli-transport.ts CHANGED
@@ -25,11 +25,12 @@ export class CurlCliTransport implements TlsTransport {
25
  body: string,
26
  signal?: AbortSignal,
27
  timeoutSec?: number,
 
28
  ): Promise<TlsTransportResponse> {
29
  return new Promise((resolve, reject) => {
30
  const args = [
31
  ...getChromeTlsArgs(),
32
- ...getProxyArgs(),
33
  "-s", "-S",
34
  "--compressed",
35
  "-N", // no output buffering (SSE)
@@ -159,10 +160,11 @@ export class CurlCliTransport implements TlsTransport {
159
  url: string,
160
  headers: Record<string, string>,
161
  timeoutSec = 30,
 
162
  ): Promise<{ status: number; body: string }> {
163
  const args = [
164
  ...getChromeTlsArgs(),
165
- ...getProxyArgs(),
166
  "-s", "-S",
167
  "--compressed",
168
  "--max-time", String(timeoutSec),
@@ -187,10 +189,11 @@ export class CurlCliTransport implements TlsTransport {
187
  headers: Record<string, string>,
188
  body: string,
189
  timeoutSec = 30,
 
190
  ): Promise<{ status: number; body: string }> {
191
  const args = [
192
  ...getChromeTlsArgs(),
193
- ...getProxyArgs(),
194
  "-s", "-S",
195
  "--compressed",
196
  "--max-time", String(timeoutSec),
@@ -241,6 +244,16 @@ function execCurl(args: string[]): Promise<{ status: number; body: string }> {
241
  });
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
244
  /** Parse HTTP response header block from curl -i output. */
245
  function parseHeaderDump(headerBlock: string): {
246
  status: number;
 
25
  body: string,
26
  signal?: AbortSignal,
27
  timeoutSec?: number,
28
+ proxyUrl?: string | null,
29
  ): Promise<TlsTransportResponse> {
30
  return new Promise((resolve, reject) => {
31
  const args = [
32
  ...getChromeTlsArgs(),
33
+ ...resolveProxyArgs(proxyUrl),
34
  "-s", "-S",
35
  "--compressed",
36
  "-N", // no output buffering (SSE)
 
160
  url: string,
161
  headers: Record<string, string>,
162
  timeoutSec = 30,
163
+ proxyUrl?: string | null,
164
  ): Promise<{ status: number; body: string }> {
165
  const args = [
166
  ...getChromeTlsArgs(),
167
+ ...resolveProxyArgs(proxyUrl),
168
  "-s", "-S",
169
  "--compressed",
170
  "--max-time", String(timeoutSec),
 
189
  headers: Record<string, string>,
190
  body: string,
191
  timeoutSec = 30,
192
+ proxyUrl?: string | null,
193
  ): Promise<{ status: number; body: string }> {
194
  const args = [
195
  ...getChromeTlsArgs(),
196
+ ...resolveProxyArgs(proxyUrl),
197
  "-s", "-S",
198
  "--compressed",
199
  "--max-time", String(timeoutSec),
 
244
  });
245
  }
246
 
247
+ /**
248
+ * Resolve proxy args for curl CLI.
249
+ * undefined → global default | null → no proxy | string → specific proxy
250
+ */
251
+ function resolveProxyArgs(proxyUrl: string | null | undefined): string[] {
252
+ if (proxyUrl === null) return [];
253
+ if (proxyUrl !== undefined) return ["-x", proxyUrl];
254
+ return getProxyArgs();
255
+ }
256
+
257
  /** Parse HTTP response header block from curl -i output. */
258
  function parseHeaderDump(headerBlock: string): {
259
  status: number;
src/tls/libcurl-ffi-transport.ts CHANGED
@@ -215,6 +215,7 @@ export class LibcurlFfiTransport implements TlsTransport {
215
  body: string,
216
  signal?: AbortSignal,
217
  timeoutSec?: number,
 
218
  ): Promise<TlsTransportResponse> {
219
  return new Promise((resolve, reject) => {
220
  if (signal?.aborted) {
@@ -226,6 +227,7 @@ export class LibcurlFfiTransport implements TlsTransport {
226
  method: "POST",
227
  body,
228
  timeoutSec,
 
229
  });
230
 
231
  const b = this.b;
@@ -372,8 +374,9 @@ export class LibcurlFfiTransport implements TlsTransport {
372
  url: string,
373
  headers: Record<string, string>,
374
  timeoutSec = 30,
 
375
  ): Promise<{ status: number; body: string }> {
376
- return this.simpleRequest(url, headers, undefined, timeoutSec);
377
  }
378
 
379
  async simplePost(
@@ -381,8 +384,9 @@ export class LibcurlFfiTransport implements TlsTransport {
381
  headers: Record<string, string>,
382
  body: string,
383
  timeoutSec = 30,
 
384
  ): Promise<{ status: number; body: string }> {
385
- return this.simpleRequest(url, headers, body, timeoutSec);
386
  }
387
 
388
  isImpersonate(): boolean {
@@ -394,12 +398,14 @@ export class LibcurlFfiTransport implements TlsTransport {
394
  headers: Record<string, string>,
395
  body: string | undefined,
396
  timeoutSec: number,
 
397
  ): Promise<{ status: number; body: string }> {
398
  const b = this.b;
399
  const { easy, slist } = this.setupEasyHandle(url, headers, {
400
  method: body !== undefined ? "POST" : "GET",
401
  body,
402
  timeoutSec,
 
403
  });
404
 
405
  const chunks: Buffer[] = [];
@@ -444,6 +450,7 @@ export class LibcurlFfiTransport implements TlsTransport {
444
  method?: "GET" | "POST";
445
  body?: string;
446
  timeoutSec?: number;
 
447
  } = {},
448
  ): { easy: CurlHandle; slist: SlistHandle } {
449
  const b = this.b;
@@ -469,10 +476,11 @@ export class LibcurlFfiTransport implements TlsTransport {
469
  b.curl_easy_setopt_long(easy, CURLOPT_TIMEOUT, opts.timeoutSec);
470
  }
471
 
472
- // Proxy
473
- const proxyUrl = getProxyUrl();
474
- if (proxyUrl) {
475
- b.curl_easy_setopt_str(easy, CURLOPT_PROXY, proxyUrl);
 
476
  }
477
 
478
  // Headers — build slist
 
215
  body: string,
216
  signal?: AbortSignal,
217
  timeoutSec?: number,
218
+ proxyUrl?: string | null,
219
  ): Promise<TlsTransportResponse> {
220
  return new Promise((resolve, reject) => {
221
  if (signal?.aborted) {
 
227
  method: "POST",
228
  body,
229
  timeoutSec,
230
+ proxyUrl,
231
  });
232
 
233
  const b = this.b;
 
374
  url: string,
375
  headers: Record<string, string>,
376
  timeoutSec = 30,
377
+ proxyUrl?: string | null,
378
  ): Promise<{ status: number; body: string }> {
379
+ return this.simpleRequest(url, headers, undefined, timeoutSec, proxyUrl);
380
  }
381
 
382
  async simplePost(
 
384
  headers: Record<string, string>,
385
  body: string,
386
  timeoutSec = 30,
387
+ proxyUrl?: string | null,
388
  ): Promise<{ status: number; body: string }> {
389
+ return this.simpleRequest(url, headers, body, timeoutSec, proxyUrl);
390
  }
391
 
392
  isImpersonate(): boolean {
 
398
  headers: Record<string, string>,
399
  body: string | undefined,
400
  timeoutSec: number,
401
+ proxyUrl?: string | null,
402
  ): Promise<{ status: number; body: string }> {
403
  const b = this.b;
404
  const { easy, slist } = this.setupEasyHandle(url, headers, {
405
  method: body !== undefined ? "POST" : "GET",
406
  body,
407
  timeoutSec,
408
+ proxyUrl,
409
  });
410
 
411
  const chunks: Buffer[] = [];
 
450
  method?: "GET" | "POST";
451
  body?: string;
452
  timeoutSec?: number;
453
+ proxyUrl?: string | null;
454
  } = {},
455
  ): { easy: CurlHandle; slist: SlistHandle } {
456
  const b = this.b;
 
476
  b.curl_easy_setopt_long(easy, CURLOPT_TIMEOUT, opts.timeoutSec);
477
  }
478
 
479
+ // Proxy: per-request override > global default
480
+ // null = direct (no proxy), undefined = use global, string = specific proxy
481
+ const effectiveProxy = opts.proxyUrl === null ? null : (opts.proxyUrl ?? getProxyUrl());
482
+ if (effectiveProxy) {
483
+ b.curl_easy_setopt_str(easy, CURLOPT_PROXY, effectiveProxy);
484
  }
485
 
486
  // Headers — build slist
src/tls/transport.ts CHANGED
@@ -17,28 +17,40 @@ export interface TlsTransportResponse {
17
  }
18
 
19
  export interface TlsTransport {
20
- /** Streaming POST (for SSE). Returns headers + streaming body. */
 
 
 
21
  post(
22
  url: string,
23
  headers: Record<string, string>,
24
  body: string,
25
  signal?: AbortSignal,
26
  timeoutSec?: number,
 
27
  ): Promise<TlsTransportResponse>;
28
 
29
- /** Simple GET — returns full body as string. */
 
 
 
30
  get(
31
  url: string,
32
  headers: Record<string, string>,
33
  timeoutSec?: number,
 
34
  ): Promise<{ status: number; body: string }>;
35
 
36
- /** Simple (non-streaming) POST — returns full body as string. */
 
 
 
37
  simplePost(
38
  url: string,
39
  headers: Record<string, string>,
40
  body: string,
41
  timeoutSec?: number,
 
42
  ): Promise<{ status: number; body: string }>;
43
 
44
  /** Whether this transport provides a Chrome TLS fingerprint. */
 
17
  }
18
 
19
  export interface TlsTransport {
20
+ /**
21
+ * Streaming POST (for SSE). Returns headers + streaming body.
22
+ * @param proxyUrl undefined = global default, null = direct (no proxy), string = specific proxy
23
+ */
24
  post(
25
  url: string,
26
  headers: Record<string, string>,
27
  body: string,
28
  signal?: AbortSignal,
29
  timeoutSec?: number,
30
+ proxyUrl?: string | null,
31
  ): Promise<TlsTransportResponse>;
32
 
33
+ /**
34
+ * Simple GET — returns full body as string.
35
+ * @param proxyUrl undefined = global default, null = direct (no proxy), string = specific proxy
36
+ */
37
  get(
38
  url: string,
39
  headers: Record<string, string>,
40
  timeoutSec?: number,
41
+ proxyUrl?: string | null,
42
  ): Promise<{ status: number; body: string }>;
43
 
44
+ /**
45
+ * Simple (non-streaming) POST — returns full body as string.
46
+ * @param proxyUrl undefined = global default, null = direct (no proxy), string = specific proxy
47
+ */
48
  simplePost(
49
  url: string,
50
  headers: Record<string, string>,
51
  body: string,
52
  timeoutSec?: number,
53
+ proxyUrl?: string | null,
54
  ): Promise<{ status: number; body: string }>;
55
 
56
  /** Whether this transport provides a Chrome TLS fingerprint. */
web/src/App.tsx CHANGED
@@ -3,11 +3,13 @@ import { ThemeProvider } from "../../shared/theme/context";
3
  import { Header } from "./components/Header";
4
  import { AccountList } from "./components/AccountList";
5
  import { AddAccount } from "./components/AddAccount";
 
6
  import { ApiConfig } from "./components/ApiConfig";
7
  import { AnthropicSetup } from "./components/AnthropicSetup";
8
  import { CodeExamples } from "./components/CodeExamples";
9
  import { Footer } from "./components/Footer";
10
  import { useAccounts } from "../../shared/hooks/use-accounts";
 
11
  import { useStatus } from "../../shared/hooks/use-status";
12
  import { useUpdateStatus } from "../../shared/hooks/use-update-status";
13
  import { useI18n } from "../../shared/i18n/context";
@@ -52,9 +54,15 @@ function useUpdateMessage() {
52
 
53
  function Dashboard() {
54
  const accounts = useAccounts();
 
55
  const status = useStatus(accounts.list.length);
56
  const update = useUpdateMessage();
57
 
 
 
 
 
 
58
  return (
59
  <>
60
  <Header
@@ -79,7 +87,10 @@ function Dashboard() {
79
  onRefresh={accounts.refresh}
80
  refreshing={accounts.refreshing}
81
  lastUpdated={accounts.lastUpdated}
 
 
82
  />
 
83
  <ApiConfig
84
  baseUrl={status.baseUrl}
85
  apiKey={status.apiKey}
 
3
  import { Header } from "./components/Header";
4
  import { AccountList } from "./components/AccountList";
5
  import { AddAccount } from "./components/AddAccount";
6
+ import { ProxyPool } from "./components/ProxyPool";
7
  import { ApiConfig } from "./components/ApiConfig";
8
  import { AnthropicSetup } from "./components/AnthropicSetup";
9
  import { CodeExamples } from "./components/CodeExamples";
10
  import { Footer } from "./components/Footer";
11
  import { useAccounts } from "../../shared/hooks/use-accounts";
12
+ import { useProxies } from "../../shared/hooks/use-proxies";
13
  import { useStatus } from "../../shared/hooks/use-status";
14
  import { useUpdateStatus } from "../../shared/hooks/use-update-status";
15
  import { useI18n } from "../../shared/i18n/context";
 
54
 
55
  function Dashboard() {
56
  const accounts = useAccounts();
57
+ const proxies = useProxies();
58
  const status = useStatus(accounts.list.length);
59
  const update = useUpdateMessage();
60
 
61
+ const handleProxyChange = async (accountId: string, proxyId: string) => {
62
+ await proxies.assignProxy(accountId, proxyId);
63
+ accounts.refresh();
64
+ };
65
+
66
  return (
67
  <>
68
  <Header
 
87
  onRefresh={accounts.refresh}
88
  refreshing={accounts.refreshing}
89
  lastUpdated={accounts.lastUpdated}
90
+ proxies={proxies.proxies}
91
+ onProxyChange={handleProxyChange}
92
  />
93
+ <ProxyPool proxies={proxies} />
94
  <ApiConfig
95
  baseUrl={status.baseUrl}
96
  apiKey={status.apiKey}
web/src/components/AccountCard.tsx CHANGED
@@ -2,7 +2,7 @@ import { useCallback } from "preact/hooks";
2
  import { useT, useI18n } from "../../../shared/i18n/context";
3
  import type { TranslationKey } from "../../../shared/i18n/translations";
4
  import { formatNumber, formatResetTime, formatWindowDuration } from "../../../shared/utils/format";
5
- import type { Account } from "../../../shared/types";
6
 
7
  const avatarColors = [
8
  ["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
@@ -39,9 +39,11 @@ interface AccountCardProps {
39
  account: Account;
40
  index: number;
41
  onDelete: (id: string) => Promise<string | null>;
 
 
42
  }
43
 
44
- export function AccountCard({ account, index, onDelete }: AccountCardProps) {
45
  const t = useT();
46
  const { lang } = useI18n();
47
  const email = account.email || "Unknown";
@@ -132,6 +134,30 @@ export function AccountCard({ account, index, onDelete }: AccountCardProps) {
132
  </div>
133
  </div>
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  {/* Quota bar */}
136
  {rl && (
137
  <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
 
2
  import { useT, useI18n } from "../../../shared/i18n/context";
3
  import type { TranslationKey } from "../../../shared/i18n/translations";
4
  import { formatNumber, formatResetTime, formatWindowDuration } from "../../../shared/utils/format";
5
+ import type { Account, ProxyEntry } from "../../../shared/types";
6
 
7
  const avatarColors = [
8
  ["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
 
39
  account: Account;
40
  index: number;
41
  onDelete: (id: string) => Promise<string | null>;
42
+ proxies?: ProxyEntry[];
43
+ onProxyChange?: (accountId: string, proxyId: string) => void;
44
  }
45
 
46
+ export function AccountCard({ account, index, onDelete, proxies, onProxyChange }: AccountCardProps) {
47
  const t = useT();
48
  const { lang } = useI18n();
49
  const email = account.email || "Unknown";
 
134
  </div>
135
  </div>
136
 
137
+ {/* Proxy selector */}
138
+ {proxies && onProxyChange && (
139
+ <div class="flex items-center justify-between text-[0.78rem] mt-2 pt-2 border-t border-slate-100 dark:border-border-dark">
140
+ <span class="text-slate-500 dark:text-text-dim">{t("proxyAssignment")}</span>
141
+ <select
142
+ value={account.proxyId || "global"}
143
+ onChange={(e) =>
144
+ onProxyChange(account.id, (e.target as HTMLSelectElement).value)
145
+ }
146
+ class="text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-transparent focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
147
+ >
148
+ <option value="global">{t("globalDefault")}</option>
149
+ <option value="direct">{t("directNoProxy")}</option>
150
+ <option value="auto">{t("autoRoundRobin")}</option>
151
+ {proxies.map((p) => (
152
+ <option key={p.id} value={p.id}>
153
+ {p.name}
154
+ {p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
155
+ </option>
156
+ ))}
157
+ </select>
158
+ </div>
159
+ )}
160
+
161
  {/* Quota bar */}
162
  {rl && (
163
  <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
web/src/components/AccountList.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useI18n, useT } from "../../../shared/i18n/context";
2
  import { AccountCard } from "./AccountCard";
3
- import type { Account } from "../../../shared/types";
4
 
5
  interface AccountListProps {
6
  accounts: Account[];
@@ -9,9 +9,11 @@ interface AccountListProps {
9
  onRefresh: () => void;
10
  refreshing: boolean;
11
  lastUpdated: Date | null;
 
 
12
  }
13
 
14
- export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing, lastUpdated }: AccountListProps) {
15
  const t = useT();
16
  const { lang } = useI18n();
17
 
@@ -61,7 +63,7 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
61
  </div>
62
  ) : (
63
  accounts.map((acct, i) => (
64
- <AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} />
65
  ))
66
  )}
67
  </div>
 
1
  import { useI18n, useT } from "../../../shared/i18n/context";
2
  import { AccountCard } from "./AccountCard";
3
+ import type { Account, ProxyEntry } from "../../../shared/types";
4
 
5
  interface AccountListProps {
6
  accounts: Account[];
 
9
  onRefresh: () => void;
10
  refreshing: boolean;
11
  lastUpdated: Date | null;
12
+ proxies?: ProxyEntry[];
13
+ onProxyChange?: (accountId: string, proxyId: string) => void;
14
  }
15
 
16
+ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing, lastUpdated, proxies, onProxyChange }: AccountListProps) {
17
  const t = useT();
18
  const { lang } = useI18n();
19
 
 
63
  </div>
64
  ) : (
65
  accounts.map((acct, i) => (
66
+ <AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} proxies={proxies} onProxyChange={onProxyChange} />
67
  ))
68
  )}
69
  </div>
web/src/components/ProxyPool.tsx ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import type { TranslationKey } from "../../../shared/i18n/translations";
4
+ import type { ProxiesState } from "../../../shared/hooks/use-proxies";
5
+
6
+ const statusStyles: Record<string, [string, TranslationKey]> = {
7
+ active: [
8
+ "bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
9
+ "proxyActive",
10
+ ],
11
+ unreachable: [
12
+ "bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
13
+ "proxyUnreachable",
14
+ ],
15
+ disabled: [
16
+ "bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
17
+ "proxyDisabled",
18
+ ],
19
+ };
20
+
21
+ function maskUrl(url: string): string {
22
+ try {
23
+ const u = new URL(url);
24
+ if (u.password) {
25
+ u.password = "***";
26
+ }
27
+ return u.toString();
28
+ } catch {
29
+ return url;
30
+ }
31
+ }
32
+
33
+ interface ProxyPoolProps {
34
+ proxies: ProxiesState;
35
+ }
36
+
37
+ export function ProxyPool({ proxies }: ProxyPoolProps) {
38
+ const t = useT();
39
+ const [showAdd, setShowAdd] = useState(false);
40
+ const [newName, setNewName] = useState("");
41
+ const [newUrl, setNewUrl] = useState("");
42
+ const [addError, setAddError] = useState("");
43
+ const [checking, setChecking] = useState<string | null>(null);
44
+ const [checkingAll, setCheckingAll] = useState(false);
45
+
46
+ const handleAdd = useCallback(async () => {
47
+ setAddError("");
48
+ if (!newUrl.trim()) {
49
+ setAddError("URL is required");
50
+ return;
51
+ }
52
+ const err = await proxies.addProxy(newName || newUrl, newUrl);
53
+ if (err) {
54
+ setAddError(err);
55
+ } else {
56
+ setNewName("");
57
+ setNewUrl("");
58
+ setShowAdd(false);
59
+ }
60
+ }, [newName, newUrl, proxies]);
61
+
62
+ const handleCheck = useCallback(
63
+ async (id: string) => {
64
+ setChecking(id);
65
+ await proxies.checkProxy(id);
66
+ setChecking(null);
67
+ },
68
+ [proxies],
69
+ );
70
+
71
+ const handleCheckAll = useCallback(async () => {
72
+ setCheckingAll(true);
73
+ await proxies.checkAll();
74
+ setCheckingAll(false);
75
+ }, [proxies]);
76
+
77
+ const handleDelete = useCallback(
78
+ async (id: string) => {
79
+ if (!confirm(t("removeProxyConfirm"))) return;
80
+ await proxies.removeProxy(id);
81
+ },
82
+ [proxies, t],
83
+ );
84
+
85
+ return (
86
+ <section>
87
+ {/* Header */}
88
+ <div class="flex items-center justify-between mb-2">
89
+ <div>
90
+ <h2 class="text-lg font-semibold">{t("proxyPool")}</h2>
91
+ <p class="text-xs text-slate-500 dark:text-text-dim mt-0.5">
92
+ {t("proxyPoolDesc")}
93
+ </p>
94
+ </div>
95
+ <div class="flex items-center gap-2">
96
+ {proxies.proxies.length > 0 && (
97
+ <button
98
+ onClick={handleCheckAll}
99
+ disabled={checkingAll}
100
+ class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors disabled:opacity-50"
101
+ >
102
+ {checkingAll ? "..." : t("checkAllHealth")}
103
+ </button>
104
+ )}
105
+ <button
106
+ onClick={() => setShowAdd(!showAdd)}
107
+ class="px-3 py-1.5 text-xs font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
108
+ >
109
+ {t("addProxy")}
110
+ </button>
111
+ </div>
112
+ </div>
113
+
114
+ {/* Add form */}
115
+ {showAdd && (
116
+ <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 mb-4">
117
+ <div class="flex flex-col sm:flex-row gap-3">
118
+ <input
119
+ type="text"
120
+ placeholder={t("proxyName")}
121
+ value={newName}
122
+ onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
123
+ class="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-transparent focus:outline-none focus:ring-1 focus:ring-primary"
124
+ />
125
+ <input
126
+ type="text"
127
+ placeholder="http://host:port or socks5://host:port"
128
+ value={newUrl}
129
+ onInput={(e) => setNewUrl((e.target as HTMLInputElement).value)}
130
+ class="flex-[2] px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-transparent focus:outline-none focus:ring-1 focus:ring-primary font-mono"
131
+ />
132
+ <button
133
+ onClick={handleAdd}
134
+ class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors whitespace-nowrap"
135
+ >
136
+ {t("addProxy")}
137
+ </button>
138
+ </div>
139
+ {addError && (
140
+ <p class="text-xs text-red-500 mt-2">{addError}</p>
141
+ )}
142
+ </div>
143
+ )}
144
+
145
+ {/* Empty state */}
146
+ {proxies.proxies.length === 0 && !showAdd && (
147
+ <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-6 text-center text-sm text-slate-500 dark:text-text-dim">
148
+ {t("noProxies")}
149
+ </div>
150
+ )}
151
+
152
+ {/* Proxy list */}
153
+ {proxies.proxies.length > 0 && (
154
+ <div class="space-y-2">
155
+ {proxies.proxies.map((proxy) => {
156
+ const [statusCls, statusKey] =
157
+ statusStyles[proxy.status] || statusStyles.disabled;
158
+ const isChecking = checking === proxy.id;
159
+
160
+ return (
161
+ <div
162
+ key={proxy.id}
163
+ class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-3 hover:shadow-sm transition-all"
164
+ >
165
+ <div class="flex items-center justify-between">
166
+ {/* Left: name + url + status */}
167
+ <div class="flex items-center gap-3 min-w-0 flex-1">
168
+ <div class="min-w-0">
169
+ <div class="flex items-center gap-2">
170
+ <span class="font-medium text-sm truncate">
171
+ {proxy.name}
172
+ </span>
173
+ <span
174
+ class={`px-2 py-0.5 rounded-full text-[0.65rem] font-medium border ${statusCls}`}
175
+ >
176
+ {t(statusKey)}
177
+ </span>
178
+ </div>
179
+ <p class="text-xs text-slate-400 dark:text-text-dim font-mono truncate mt-0.5">
180
+ {maskUrl(proxy.url)}
181
+ </p>
182
+ </div>
183
+ </div>
184
+
185
+ {/* Center: health info */}
186
+ {proxy.health && (
187
+ <div class="hidden sm:flex items-center gap-4 px-4 text-xs text-slate-500 dark:text-text-dim">
188
+ {proxy.health.exitIp && (
189
+ <span>
190
+ {t("exitIp")}: <span class="font-mono font-medium text-slate-700 dark:text-text-main">{proxy.health.exitIp}</span>
191
+ </span>
192
+ )}
193
+ <span>
194
+ {proxy.health.latencyMs}ms
195
+ </span>
196
+ {proxy.health.error && (
197
+ <span class="text-red-500 truncate max-w-[200px]" title={proxy.health.error}>
198
+ {proxy.health.error}
199
+ </span>
200
+ )}
201
+ </div>
202
+ )}
203
+
204
+ {/* Right: actions */}
205
+ <div class="flex items-center gap-1 ml-2">
206
+ <button
207
+ onClick={() => handleCheck(proxy.id)}
208
+ disabled={isChecking}
209
+ class="px-2 py-1 text-xs rounded-md hover:bg-slate-100 dark:hover:bg-border-dark transition-colors disabled:opacity-50"
210
+ title={t("checkHealth")}
211
+ >
212
+ {isChecking ? "..." : t("checkHealth")}
213
+ </button>
214
+ {proxy.status === "disabled" ? (
215
+ <button
216
+ onClick={() => proxies.enableProxy(proxy.id)}
217
+ class="px-2 py-1 text-xs rounded-md hover:bg-green-50 dark:hover:bg-green-900/20 text-green-600 transition-colors"
218
+ >
219
+ {t("enableProxy")}
220
+ </button>
221
+ ) : (
222
+ <button
223
+ onClick={() => proxies.disableProxy(proxy.id)}
224
+ class="px-2 py-1 text-xs rounded-md hover:bg-slate-100 dark:hover:bg-border-dark text-slate-500 transition-colors"
225
+ >
226
+ {t("disableProxy")}
227
+ </button>
228
+ )}
229
+ <button
230
+ onClick={() => handleDelete(proxy.id)}
231
+ class="p-1 text-slate-400 dark:text-text-dim hover:text-red-500 transition-colors rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
232
+ title={t("deleteProxy")}
233
+ >
234
+ <svg
235
+ class="size-4"
236
+ viewBox="0 0 24 24"
237
+ fill="none"
238
+ stroke="currentColor"
239
+ stroke-width="1.5"
240
+ >
241
+ <path
242
+ stroke-linecap="round"
243
+ stroke-linejoin="round"
244
+ d="M6 18L18 6M6 6l12 12"
245
+ />
246
+ </svg>
247
+ </button>
248
+ </div>
249
+ </div>
250
+
251
+ {/* Mobile health info */}
252
+ {proxy.health && (
253
+ <div class="sm:hidden flex items-center gap-3 mt-2 text-xs text-slate-500 dark:text-text-dim">
254
+ {proxy.health.exitIp && (
255
+ <span>IP: <span class="font-mono">{proxy.health.exitIp}</span></span>
256
+ )}
257
+ <span>{proxy.health.latencyMs}ms</span>
258
+ </div>
259
+ )}
260
+ </div>
261
+ );
262
+ })}
263
+ </div>
264
+ )}
265
+ </section>
266
+ );
267
+ }