@pikoloo/codex-proxy 1.1.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +28 -11
- package/bin/cli.js +15 -15
- package/docs/ACCOUNT.md +104 -0
- package/docs/API.md +26 -19
- package/docs/ARCHITECTURE.md +9 -9
- package/docs/CLAUDE_INTEGRATION.md +3 -3
- package/docs/OAUTH.md +13 -13
- package/docs/OPENCLAW.md +1 -1
- package/docs/legal.md +6 -0
- package/package.json +10 -8
- package/public/css/style.css +4 -34
- package/public/index.html +105 -166
- package/public/js/app.js +23 -58
- package/src/account-manager.js +210 -292
- package/src/cli/account.js +236 -0
- package/src/direct-api.js +7 -9
- package/src/index.js +7 -7
- package/src/middleware/credentials.js +6 -47
- package/src/oauth.js +2 -1
- package/src/routes/{accounts-route.js → account-route.js} +25 -109
- package/src/routes/api-routes.js +18 -26
- package/src/routes/chat-route.js +2 -2
- package/src/routes/messages-route.js +29 -189
- package/src/routes/models-route.js +11 -21
- package/src/security.js +1 -1
- package/src/server-settings.js +1 -8
- package/docs/ACCOUNTS.md +0 -202
- package/src/account-rotation/index.js +0 -130
- package/src/account-rotation/rate-limits.js +0 -293
- package/src/cli/accounts.js +0 -557
package/src/routes/api-routes.js
CHANGED
|
@@ -9,7 +9,7 @@ import { join, dirname } from 'path';
|
|
|
9
9
|
import { fileURLToPath } from 'url';
|
|
10
10
|
import { createRequire } from 'module';
|
|
11
11
|
|
|
12
|
-
import { getStatus,
|
|
12
|
+
import { getStatus, ACCOUNT_FILE } from '../account-manager.js';
|
|
13
13
|
|
|
14
14
|
// Route handlers
|
|
15
15
|
import { handleMessages } from './messages-route.js';
|
|
@@ -28,20 +28,16 @@ import { handleGetLogs, handleStreamLogs } from './logs-route.js';
|
|
|
28
28
|
import { handleGetMetricsRecent, handleGetMetricsStorage, handleGetMetricsSummary } from './metrics-route.js';
|
|
29
29
|
import { handleGetClaudeConfig, handleSetProxyMode, handleSetDirectMode, handleSetClaudeApiEndpoint } from './claude-config-route.js';
|
|
30
30
|
import {
|
|
31
|
-
|
|
31
|
+
handleGetAccount,
|
|
32
32
|
handleAccountStatus,
|
|
33
33
|
handleOAuthCleanup,
|
|
34
34
|
handleAddAccount,
|
|
35
35
|
handleAddAccountManual,
|
|
36
|
-
handleSwitchAccount,
|
|
37
36
|
handleRefreshAccount,
|
|
38
|
-
handleRefreshAllAccounts,
|
|
39
|
-
handleRefreshActiveAccount,
|
|
40
37
|
handleRemoveAccount,
|
|
41
38
|
handleImportAccount,
|
|
42
|
-
handleGetQuota
|
|
43
|
-
|
|
44
|
-
} from './accounts-route.js';
|
|
39
|
+
handleGetQuota
|
|
40
|
+
} from './account-route.js';
|
|
45
41
|
|
|
46
42
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
47
43
|
const require = createRequire(import.meta.url);
|
|
@@ -54,7 +50,7 @@ export function registerApiRoutes(app, { port }) {
|
|
|
54
50
|
|
|
55
51
|
// ─── Health ────────────────────────────────────────────────────────────────
|
|
56
52
|
app.get('/health', (req, res) => {
|
|
57
|
-
res.json({ status: 'ok', ...getStatus(), configPath:
|
|
53
|
+
res.json({ status: 'ok', ...getStatus(), configPath: ACCOUNT_FILE });
|
|
58
54
|
});
|
|
59
55
|
|
|
60
56
|
// ─── Anthropic Messages API ────────────────────────────────────────────────
|
|
@@ -66,8 +62,8 @@ export function registerApiRoutes(app, { port }) {
|
|
|
66
62
|
|
|
67
63
|
// ─── Models ────────────────────────────────────────────────────────────────
|
|
68
64
|
app.get('/v1/models', handleListModels);
|
|
69
|
-
app.get('/
|
|
70
|
-
app.get('/
|
|
65
|
+
app.get('/account/models', handleAccountModels);
|
|
66
|
+
app.get('/account/usage', handleAccountUsage);
|
|
71
67
|
|
|
72
68
|
// ─── Settings ──────────────────────────────────────────────────────────────
|
|
73
69
|
app.get('/settings/haiku-model', handleGetHaikuModel);
|
|
@@ -79,21 +75,17 @@ export function registerApiRoutes(app, { port }) {
|
|
|
79
75
|
app.post('/settings/claude-proxy', handleSetClaudeProxySetting);
|
|
80
76
|
|
|
81
77
|
// ─── Account Management ───────────────────────────────────────────────────
|
|
82
|
-
app.get('/
|
|
83
|
-
app.get('/
|
|
84
|
-
app.get('/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
app.post('/
|
|
88
|
-
app.post('/
|
|
89
|
-
app.post('/
|
|
90
|
-
app.post('/
|
|
91
|
-
|
|
92
|
-
app.
|
|
93
|
-
app.post('/accounts/oauth/cleanup', handleOAuthCleanup);
|
|
94
|
-
app.post('/accounts/:email/refresh', handleRefreshAccount);
|
|
95
|
-
|
|
96
|
-
app.delete('/accounts/:email', handleRemoveAccount);
|
|
78
|
+
app.get('/account', handleGetAccount);
|
|
79
|
+
app.get('/account/status', handleAccountStatus);
|
|
80
|
+
app.get('/account/quota', handleGetQuota);
|
|
81
|
+
|
|
82
|
+
app.post('/account/add', handleAddAccount);
|
|
83
|
+
app.post('/account/add/manual', handleAddAccountManual);
|
|
84
|
+
app.post('/account/import', handleImportAccount);
|
|
85
|
+
app.post('/account/refresh', handleRefreshAccount);
|
|
86
|
+
app.post('/account/oauth/cleanup', handleOAuthCleanup);
|
|
87
|
+
|
|
88
|
+
app.delete('/account', handleRemoveAccount);
|
|
97
89
|
|
|
98
90
|
// ─── Claude CLI Configuration ──────────────────────────────────────────────
|
|
99
91
|
app.get('/claude/config', handleGetClaudeConfig);
|
package/src/routes/chat-route.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function handleChatCompletion(req, res) {
|
|
|
48
48
|
if (!isKilo) {
|
|
49
49
|
creds = await getCredentialsOrError();
|
|
50
50
|
if (!creds) {
|
|
51
|
-
logger.response(401, { error: 'No
|
|
51
|
+
logger.response(401, { error: 'No configured account' });
|
|
52
52
|
recordChatMetric({
|
|
53
53
|
body,
|
|
54
54
|
requestedModel,
|
|
@@ -58,7 +58,7 @@ export async function handleChatCompletion(req, res) {
|
|
|
58
58
|
status: 401,
|
|
59
59
|
errorType: 'auth_error'
|
|
60
60
|
});
|
|
61
|
-
return sendAuthError(res, 'No
|
|
61
|
+
return sendAuthError(res, 'No configured account. Add an account via /account/add');
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -1,31 +1,11 @@
|
|
|
1
1
|
import { sendMessageStream, sendMessage } from '../direct-api.js';
|
|
2
2
|
import { sendKiloMessageStream, sendKiloMessage } from '../kilo-api.js';
|
|
3
3
|
import { DEFAULT_OPENAI_MODEL, isKiloEnabled, resolveModelRouting } from '../model-mapper.js';
|
|
4
|
-
import { sendAuthError, getCredentialsOrError
|
|
4
|
+
import { sendAuthError, getCredentialsOrError } from '../middleware/credentials.js';
|
|
5
5
|
import { initSSEResponse, pipeSSEStream, handleStreamError } from '../middleware/sse.js';
|
|
6
6
|
import { logger } from '../utils/logger.js';
|
|
7
|
-
import { AccountRotator } from '../account-rotation/index.js';
|
|
8
|
-
import { listAccounts, save } from '../account-manager.js';
|
|
9
|
-
import { isMultiAccountRotationEnabled } from '../server-settings.js';
|
|
10
7
|
import { recordUsageEventSafe, tapUsageEventStream } from '../usage-metrics.js';
|
|
11
8
|
|
|
12
|
-
const MAX_RETRIES = 5;
|
|
13
|
-
const MAX_WAIT_BEFORE_ERROR_MS = 120000;
|
|
14
|
-
const SHORT_RATE_LIMIT_THRESHOLD_MS = 5000;
|
|
15
|
-
|
|
16
|
-
let accountRotator = null;
|
|
17
|
-
|
|
18
|
-
function getAccountRotator() {
|
|
19
|
-
if (!accountRotator) {
|
|
20
|
-
accountRotator = new AccountRotator({
|
|
21
|
-
listAccounts,
|
|
22
|
-
save
|
|
23
|
-
});
|
|
24
|
-
logger.info('[Messages] Account rotation enabled');
|
|
25
|
-
}
|
|
26
|
-
return accountRotator;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
9
|
export async function handleMessages(req, res) {
|
|
30
10
|
const startTime = Date.now();
|
|
31
11
|
const body = req.body;
|
|
@@ -62,50 +42,8 @@ export async function handleMessages(req, res) {
|
|
|
62
42
|
: _sendKilo(res, { ...body, model: upstreamModel }, kiloTarget, requestedModel, startTime);
|
|
63
43
|
}
|
|
64
44
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (!creds) {
|
|
68
|
-
recordMessageMetric({
|
|
69
|
-
body,
|
|
70
|
-
endpoint: '/v1/messages',
|
|
71
|
-
requestedModel,
|
|
72
|
-
upstreamModel,
|
|
73
|
-
provider: 'openai',
|
|
74
|
-
startTime,
|
|
75
|
-
status: 401,
|
|
76
|
-
errorType: 'auth_error'
|
|
77
|
-
});
|
|
78
|
-
return sendAuthError(res);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const anthropicRequest = { ...body, model: upstreamModel, ...(reasoningLevel ? { reasoningLevel } : {}) };
|
|
82
|
-
try {
|
|
83
|
-
if (isStreaming) {
|
|
84
|
-
await _streamDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, null);
|
|
85
|
-
} else {
|
|
86
|
-
await _sendDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, null);
|
|
87
|
-
}
|
|
88
|
-
return;
|
|
89
|
-
} catch (error) {
|
|
90
|
-
recordMessageMetric({
|
|
91
|
-
body,
|
|
92
|
-
endpoint: '/v1/messages',
|
|
93
|
-
requestedModel,
|
|
94
|
-
upstreamModel,
|
|
95
|
-
provider: 'openai',
|
|
96
|
-
accountLabel: creds.email,
|
|
97
|
-
startTime,
|
|
98
|
-
status: error.status || 500,
|
|
99
|
-
errorType: classifyMetricError(error)
|
|
100
|
-
});
|
|
101
|
-
return handleStreamError(res, error, requestedModel, startTime);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const rotator = getAccountRotator();
|
|
106
|
-
const accountSnapshot = listAccounts();
|
|
107
|
-
|
|
108
|
-
if (accountSnapshot.total === 0) {
|
|
45
|
+
const creds = await getCredentialsOrError();
|
|
46
|
+
if (!creds) {
|
|
109
47
|
recordMessageMetric({
|
|
110
48
|
body,
|
|
111
49
|
endpoint: '/v1/messages',
|
|
@@ -116,130 +54,36 @@ export async function handleMessages(req, res) {
|
|
|
116
54
|
status: 401,
|
|
117
55
|
errorType: 'auth_error'
|
|
118
56
|
});
|
|
119
|
-
return sendAuthError(res
|
|
57
|
+
return sendAuthError(res);
|
|
120
58
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const minWait = rotator.getMinWaitTimeMs(upstreamModel);
|
|
129
|
-
|
|
130
|
-
if (minWait > MAX_WAIT_BEFORE_ERROR_MS) {
|
|
131
|
-
recordMessageMetric({
|
|
132
|
-
body,
|
|
133
|
-
endpoint: '/v1/messages',
|
|
134
|
-
requestedModel,
|
|
135
|
-
upstreamModel,
|
|
136
|
-
provider: 'openai',
|
|
137
|
-
startTime,
|
|
138
|
-
status: 429,
|
|
139
|
-
errorType: 'rate_limited'
|
|
140
|
-
});
|
|
141
|
-
return handleStreamError(res, new Error(`RESOURCE_EXHAUSTED: All accounts rate-limited. Wait ${Math.round(minWait/1000)}s`), requestedModel, startTime);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
logger.info(`[Messages] All accounts rate-limited, waiting ${Math.round(minWait/1000)}s...`);
|
|
145
|
-
await sleep(minWait + 500);
|
|
146
|
-
rotator.clearExpiredLimits();
|
|
147
|
-
attempt--;
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const { account, waitMs } = rotator.selectAccount(upstreamModel);
|
|
152
|
-
|
|
153
|
-
if (!account) {
|
|
154
|
-
if (waitMs > 0) {
|
|
155
|
-
await sleep(waitMs);
|
|
156
|
-
attempt--;
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
recordMessageMetric({
|
|
160
|
-
body,
|
|
161
|
-
endpoint: '/v1/messages',
|
|
162
|
-
requestedModel,
|
|
163
|
-
upstreamModel,
|
|
164
|
-
provider: 'openai',
|
|
165
|
-
startTime,
|
|
166
|
-
status: 401,
|
|
167
|
-
errorType: 'auth_error'
|
|
168
|
-
});
|
|
169
|
-
return sendAuthError(res, 'No available accounts');
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const creds = await getCredentialsForAccount(account.email);
|
|
173
|
-
if (!creds) {
|
|
174
|
-
rotator.markInvalid(account.email, 'Failed to get credentials');
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const anthropicRequest = { ...body, model: upstreamModel, ...(reasoningLevel ? { reasoningLevel } : {}) };
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
if (isStreaming) {
|
|
182
|
-
await _streamDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, rotator);
|
|
183
|
-
} else {
|
|
184
|
-
await _sendDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, rotator);
|
|
185
|
-
}
|
|
186
|
-
rotator.notifySuccess(account, upstreamModel);
|
|
187
|
-
return;
|
|
188
|
-
} catch (error) {
|
|
189
|
-
if (error.message.startsWith('RATE_LIMITED:')) {
|
|
190
|
-
const parts = error.message.split(':');
|
|
191
|
-
const resetMs = parseInt(parts[1], 10);
|
|
192
|
-
const errorText = parts.slice(2).join(':');
|
|
193
|
-
|
|
194
|
-
rotator.notifyRateLimit(account, upstreamModel);
|
|
195
|
-
|
|
196
|
-
if (resetMs <= SHORT_RATE_LIMIT_THRESHOLD_MS) {
|
|
197
|
-
logger.info(`[Messages] Short rate limit on ${account.email}, waiting ${resetMs}ms...`);
|
|
198
|
-
await sleep(resetMs);
|
|
199
|
-
attempt--;
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
logger.info(`[Messages] Rate limit on ${account.email}, switching account...`);
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (error.message.includes('AUTH_EXPIRED')) {
|
|
208
|
-
rotator.markInvalid(account.email, 'Auth expired');
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
recordMessageMetric({
|
|
213
|
-
body,
|
|
214
|
-
endpoint: '/v1/messages',
|
|
215
|
-
requestedModel,
|
|
216
|
-
upstreamModel,
|
|
217
|
-
provider: 'openai',
|
|
218
|
-
accountLabel: account.email,
|
|
219
|
-
startTime,
|
|
220
|
-
status: error.status || 500,
|
|
221
|
-
errorType: classifyMetricError(error)
|
|
222
|
-
});
|
|
223
|
-
return handleStreamError(res, error, requestedModel, startTime);
|
|
59
|
+
|
|
60
|
+
const anthropicRequest = { ...body, model: upstreamModel, ...(reasoningLevel ? { reasoningLevel } : {}) };
|
|
61
|
+
try {
|
|
62
|
+
if (isStreaming) {
|
|
63
|
+
await _streamDirect(res, anthropicRequest, creds, requestedModel, startTime);
|
|
64
|
+
} else {
|
|
65
|
+
await _sendDirect(res, anthropicRequest, creds, requestedModel, startTime);
|
|
224
66
|
}
|
|
67
|
+
return;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
recordMessageMetric({
|
|
70
|
+
body,
|
|
71
|
+
endpoint: '/v1/messages',
|
|
72
|
+
requestedModel,
|
|
73
|
+
upstreamModel,
|
|
74
|
+
provider: 'openai',
|
|
75
|
+
accountLabel: creds.email,
|
|
76
|
+
startTime,
|
|
77
|
+
status: error.message?.startsWith('RATE_LIMITED:') ? 429 : error.status || 500,
|
|
78
|
+
errorType: classifyMetricError(error)
|
|
79
|
+
});
|
|
80
|
+
return handleStreamError(res, error, requestedModel, startTime);
|
|
225
81
|
}
|
|
226
|
-
|
|
227
|
-
recordMessageMetric({
|
|
228
|
-
body,
|
|
229
|
-
endpoint: '/v1/messages',
|
|
230
|
-
requestedModel,
|
|
231
|
-
upstreamModel,
|
|
232
|
-
provider: 'openai',
|
|
233
|
-
startTime,
|
|
234
|
-
status: 500,
|
|
235
|
-
errorType: 'max_retries'
|
|
236
|
-
});
|
|
237
|
-
return handleStreamError(res, new Error('Max retries exceeded'), requestedModel, startTime);
|
|
238
82
|
}
|
|
239
83
|
|
|
240
|
-
async function
|
|
84
|
+
async function _streamDirect(res, anthropicRequest, creds, responseModel, startTime) {
|
|
241
85
|
initSSEResponse(res);
|
|
242
|
-
const sourceStream = sendMessageStream(anthropicRequest, creds.accessToken, creds.accountId
|
|
86
|
+
const sourceStream = sendMessageStream(anthropicRequest, creds.accessToken, creds.accountId);
|
|
243
87
|
let finalUsage = null;
|
|
244
88
|
const stream = tapUsageEventStream(sourceStream, (usage) => {
|
|
245
89
|
finalUsage = usage;
|
|
@@ -260,7 +104,7 @@ async function _streamDirectWithRotation(res, anthropicRequest, creds, responseM
|
|
|
260
104
|
logger.response(200, { model: anthropicRequest.model, usage: finalUsage, duration: Date.now() - startTime });
|
|
261
105
|
}
|
|
262
106
|
|
|
263
|
-
async function
|
|
107
|
+
async function _sendDirect(res, anthropicRequest, creds, responseModel, startTime) {
|
|
264
108
|
const response = await sendMessage(anthropicRequest, creds.accessToken, creds.accountId);
|
|
265
109
|
const duration = Date.now() - startTime;
|
|
266
110
|
logger.response(200, { model: anthropicRequest.model, usage: response.usage, duration });
|
|
@@ -332,10 +176,6 @@ async function _sendKilo(res, anthropicRequest, kiloTarget, responseModel, start
|
|
|
332
176
|
});
|
|
333
177
|
}
|
|
334
178
|
|
|
335
|
-
function sleep(ms) {
|
|
336
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
337
|
-
}
|
|
338
|
-
|
|
339
179
|
function recordMessageMetric(options) {
|
|
340
180
|
const body = options.body || {};
|
|
341
181
|
recordUsageEventSafe({
|
|
@@ -358,7 +198,7 @@ function recordMessageMetric(options) {
|
|
|
358
198
|
|
|
359
199
|
function classifyMetricError(error) {
|
|
360
200
|
const message = error?.message || '';
|
|
361
|
-
if (message.startsWith('RATE_LIMITED:')
|
|
201
|
+
if (message.startsWith('RATE_LIMITED:')) return 'rate_limited';
|
|
362
202
|
if (message.includes('AUTH_EXPIRED')) return 'auth_expired';
|
|
363
203
|
if (message.startsWith('CLOUDFLARE_BLOCKED:')) return 'cloudflare_blocked';
|
|
364
204
|
if (message.startsWith('FORBIDDEN:')) return 'forbidden';
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Models Route
|
|
3
3
|
* Handles:
|
|
4
4
|
* GET /v1/models — OpenAI-compatible model list
|
|
5
|
-
* GET /
|
|
6
|
-
* GET /
|
|
5
|
+
* GET /account/models — Raw model list for the configured account
|
|
6
|
+
* GET /account/usage — Usage stats for the configured account
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { fetchModels, fetchUsage } from '../model-api.js';
|
|
10
|
-
import { getActiveAccount
|
|
10
|
+
import { getActiveAccount } from '../account-manager.js';
|
|
11
11
|
import { logger } from '../utils/logger.js';
|
|
12
12
|
import { getCredentialsOrError } from '../middleware/credentials.js';
|
|
13
13
|
import { DEFAULT_OPENAI_MODEL, DEFAULT_SMALL_OPENAI_MODEL, LATEST_CODEX_MODEL } from '../model-mapper.js';
|
|
@@ -61,16 +61,16 @@ export async function handleListModels(req, res) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* GET /
|
|
65
|
-
* Returns the raw model list for the
|
|
64
|
+
* GET /account/models
|
|
65
|
+
* Returns the raw model list for the configured account.
|
|
66
66
|
*/
|
|
67
67
|
export async function handleAccountModels(req, res) {
|
|
68
|
-
const account =
|
|
68
|
+
const account = getActiveAccount();
|
|
69
69
|
|
|
70
70
|
if (!account) {
|
|
71
71
|
return res.status(404).json({
|
|
72
72
|
success: false,
|
|
73
|
-
error:
|
|
73
|
+
error: 'No account configured'
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -84,16 +84,16 @@ export async function handleAccountModels(req, res) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
* GET /
|
|
88
|
-
* Returns usage stats for the
|
|
87
|
+
* GET /account/usage
|
|
88
|
+
* Returns usage stats for the configured account.
|
|
89
89
|
*/
|
|
90
90
|
export async function handleAccountUsage(req, res) {
|
|
91
|
-
const account =
|
|
91
|
+
const account = getActiveAccount();
|
|
92
92
|
|
|
93
93
|
if (!account) {
|
|
94
94
|
return res.status(404).json({
|
|
95
95
|
success: false,
|
|
96
|
-
error:
|
|
96
|
+
error: 'No account configured'
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -106,14 +106,4 @@ export async function handleAccountUsage(req, res) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
function _resolveAccount(email) {
|
|
112
|
-
if (email) {
|
|
113
|
-
const data = loadAccounts();
|
|
114
|
-
return data.accounts.find(a => a.email === email) || null;
|
|
115
|
-
}
|
|
116
|
-
return getActiveAccount();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
109
|
export default { handleListModels, handleAccountModels, handleAccountUsage };
|
package/src/security.js
CHANGED
package/src/server-settings.js
CHANGED
|
@@ -3,7 +3,6 @@ import { join } from 'path';
|
|
|
3
3
|
import { CONFIG_DIR } from './account-manager.js';
|
|
4
4
|
|
|
5
5
|
const SETTINGS_FILE = join(CONFIG_DIR, 'settings.json');
|
|
6
|
-
const MULTI_ACCOUNT_ROTATION_ENV = 'CODEX_CLAUDE_PROXY_ENABLE_MULTI_ACCOUNT_ROTATION';
|
|
7
6
|
|
|
8
7
|
const DEFAULT_SETTINGS = {
|
|
9
8
|
haikuKiloModel: 'minimax/minimax-m2.5:free',
|
|
@@ -75,17 +74,11 @@ export function setServerSettings(patch = {}) {
|
|
|
75
74
|
return next;
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
export
|
|
79
|
-
return env[MULTI_ACCOUNT_ROTATION_ENV] === 'true';
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export { SETTINGS_FILE, MULTI_ACCOUNT_ROTATION_ENV };
|
|
77
|
+
export { SETTINGS_FILE };
|
|
83
78
|
|
|
84
79
|
export default {
|
|
85
80
|
getServerSettings,
|
|
86
81
|
setServerSettings,
|
|
87
82
|
normalizeSettings,
|
|
88
|
-
isMultiAccountRotationEnabled,
|
|
89
|
-
MULTI_ACCOUNT_ROTATION_ENV,
|
|
90
83
|
SETTINGS_FILE
|
|
91
84
|
};
|
package/docs/ACCOUNTS.md
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
# Account Management
|
|
2
|
-
|
|
3
|
-
## Storage Structure
|
|
4
|
-
|
|
5
|
-
### Main Registry
|
|
6
|
-
|
|
7
|
-
**Location:** `~/.codex-claude-proxy/accounts.json`
|
|
8
|
-
|
|
9
|
-
```json
|
|
10
|
-
{
|
|
11
|
-
"accounts": [
|
|
12
|
-
{
|
|
13
|
-
"email": "user@gmail.com",
|
|
14
|
-
"accountId": "d41e9636-16d8-42be-91da-7ea8773bfb7e",
|
|
15
|
-
"planType": "plus",
|
|
16
|
-
"accessToken": "eyJhbGciOiJSUzI1NiIs...",
|
|
17
|
-
"refreshToken": "rt_WpTMn1...",
|
|
18
|
-
"idToken": "eyJhbGciOiJSUzI1NiIs...",
|
|
19
|
-
"expiresAt": 1770886178000,
|
|
20
|
-
"addedAt": "2026-02-13T04:00:00.000Z",
|
|
21
|
-
"lastUsed": "2026-02-13T04:30:00.000Z",
|
|
22
|
-
"quota": {
|
|
23
|
-
"usage": {...},
|
|
24
|
-
"account": {...},
|
|
25
|
-
"lastChecked": "2026-02-14T10:00:00.000Z"
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
],
|
|
29
|
-
"activeAccount": "user@gmail.com",
|
|
30
|
-
"version": 1
|
|
31
|
-
}
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
### Per-Account Tokens
|
|
35
|
-
|
|
36
|
-
**Location:** `~/.codex-claude-proxy/accounts/<email>/auth.json`
|
|
37
|
-
|
|
38
|
-
```json
|
|
39
|
-
{
|
|
40
|
-
"auth_mode": "chatgpt",
|
|
41
|
-
"OPENAI_API_KEY": null,
|
|
42
|
-
"tokens": {
|
|
43
|
-
"id_token": "...",
|
|
44
|
-
"access_token": "...",
|
|
45
|
-
"refresh_token": "...",
|
|
46
|
-
"account_id": "..."
|
|
47
|
-
},
|
|
48
|
-
"last_refresh": "2026-02-14T10:00:00.000Z"
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## Operations
|
|
53
|
-
|
|
54
|
-
### Add Account (OAuth)
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
curl -X POST http://localhost:8081/accounts/add
|
|
58
|
-
|
|
59
|
-
# Returns OAuth URL to open in browser
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### Import from Codex App
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
curl -X POST http://localhost:8081/accounts/import
|
|
66
|
-
|
|
67
|
-
# Imports from ~/.codex/auth.json
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
### List Accounts
|
|
71
|
-
|
|
72
|
-
```bash
|
|
73
|
-
curl http://localhost:8081/accounts
|
|
74
|
-
|
|
75
|
-
# Response
|
|
76
|
-
{
|
|
77
|
-
"accounts": [
|
|
78
|
-
{
|
|
79
|
-
"email": "user@gmail.com",
|
|
80
|
-
"accountId": "...",
|
|
81
|
-
"planType": "plus",
|
|
82
|
-
"addedAt": "...",
|
|
83
|
-
"lastUsed": "...",
|
|
84
|
-
"isActive": true,
|
|
85
|
-
"tokenExpired": false,
|
|
86
|
-
"quota": {...}
|
|
87
|
-
}
|
|
88
|
-
],
|
|
89
|
-
"activeAccount": "user@gmail.com",
|
|
90
|
-
"total": 1
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
### Switch Active Account
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
curl -X POST http://localhost:8081/accounts/switch \
|
|
98
|
-
-H "Content-Type: application/json" \
|
|
99
|
-
-d '{"email":"other@gmail.com"}'
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
Switching:
|
|
103
|
-
1. Updates `activeAccount` in `accounts.json`
|
|
104
|
-
2. Updates auth file for the account
|
|
105
|
-
3. Next API calls use new account's credentials
|
|
106
|
-
|
|
107
|
-
### Remove Account
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
curl -X DELETE http://localhost:8081/accounts/user@gmail.com
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Removes:
|
|
114
|
-
- Account from registry
|
|
115
|
-
- Per-account token directory
|
|
116
|
-
|
|
117
|
-
### Refresh Tokens
|
|
118
|
-
|
|
119
|
-
```bash
|
|
120
|
-
# Active account
|
|
121
|
-
curl -X POST http://localhost:8081/accounts/refresh
|
|
122
|
-
|
|
123
|
-
# Specific account
|
|
124
|
-
curl -X POST http://localhost:8081/accounts/user@gmail.com/refresh
|
|
125
|
-
|
|
126
|
-
# All accounts
|
|
127
|
-
curl -X POST http://localhost:8081/accounts/refresh/all
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
## Token Lifecycle
|
|
131
|
-
|
|
132
|
-
### Expiration
|
|
133
|
-
|
|
134
|
-
- Access tokens expire in ~1 hour (3600 seconds)
|
|
135
|
-
- Refresh tokens are long-lived (weeks/months)
|
|
136
|
-
|
|
137
|
-
### Auto-Refresh
|
|
138
|
-
|
|
139
|
-
- Background refresh every **55 minutes**
|
|
140
|
-
- Startup refresh 2 seconds after server start
|
|
141
|
-
- Proactive refresh 5 minutes before expiry
|
|
142
|
-
|
|
143
|
-
### Token Validation
|
|
144
|
-
|
|
145
|
-
Before each API call:
|
|
146
|
-
1. Check if token is expired or expiring within 5 minutes
|
|
147
|
-
2. If yes, refresh using refresh token
|
|
148
|
-
3. Use new access token for the call
|
|
149
|
-
|
|
150
|
-
## Quota Tracking
|
|
151
|
-
|
|
152
|
-
### Fetch Quota
|
|
153
|
-
|
|
154
|
-
```bash
|
|
155
|
-
curl http://localhost:8081/accounts/quota
|
|
156
|
-
|
|
157
|
-
# Response
|
|
158
|
-
{
|
|
159
|
-
"success": true,
|
|
160
|
-
"email": "user@gmail.com",
|
|
161
|
-
"quota": {
|
|
162
|
-
"usage": {
|
|
163
|
-
"totalTokenUsage": 15,
|
|
164
|
-
"limit": 100,
|
|
165
|
-
"remaining": 85,
|
|
166
|
-
"percentage": 15,
|
|
167
|
-
"resetAt": "..."
|
|
168
|
-
},
|
|
169
|
-
"account": {...}
|
|
170
|
-
},
|
|
171
|
-
"cached": false
|
|
172
|
-
}
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
### Web UI Quota Display Rules
|
|
176
|
-
|
|
177
|
-
- The Accounts table displays **remaining quota** as a percentage.
|
|
178
|
-
- Remaining percentage is normalized to `0-100` to avoid broken UI values.
|
|
179
|
-
- If `limitReached=true` or `allowed=false`, UI shows quota as exhausted even when percentage data is missing.
|
|
180
|
-
- If usage data is unavailable, UI shows `-` instead of rendering a broken bar.
|
|
181
|
-
- Reset window is shown using `usage.resetAt` (with fallback to `usage.raw.rate_limit.primary_window.reset_at`).
|
|
182
|
-
- UI also shows a relative countdown (e.g. `Resets in 6d 13h`) when reset data is available.
|
|
183
|
-
|
|
184
|
-
### Refresh All Quotas
|
|
185
|
-
|
|
186
|
-
```bash
|
|
187
|
-
curl http://localhost:8081/accounts/quota/all
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
## Account Persistence
|
|
191
|
-
|
|
192
|
-
On server startup:
|
|
193
|
-
1. `ensureAccountsPersist()` loads accounts
|
|
194
|
-
2. Restores active account's auth
|
|
195
|
-
3. Starts auto-refresh timer
|
|
196
|
-
|
|
197
|
-
## Security
|
|
198
|
-
|
|
199
|
-
- Tokens stored locally in `~/.codex-claude-proxy/`
|
|
200
|
-
- Directory permissions: user read/write only
|
|
201
|
-
- Never logged or exposed in API responses
|
|
202
|
-
- Per-account isolation via separate directories
|