@pikoloo/codex-proxy 1.0.6 → 1.1.0
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/README.md +12 -5
- package/docs/API.md +0 -15
- package/images/dashboard-screenshot.png +0 -0
- package/images/readme-cover.png +0 -0
- package/images/settings-screenshot.png +0 -0
- package/package.json +11 -3
- package/public/css/style.css +1097 -40
- package/public/index.html +439 -184
- package/public/js/app.js +384 -66
- package/src/account-rotation/index.js +64 -27
- package/src/format-converter.js +5 -1
- package/src/index.js +1 -1
- package/src/model-mapper.js +145 -22
- package/src/routes/api-routes.js +19 -3
- package/src/routes/chat-route.js +77 -4
- package/src/routes/messages-route.js +189 -21
- package/src/routes/metrics-route.js +43 -0
- package/src/routes/settings-route.js +127 -21
- package/src/security.js +2 -1
- package/src/server-settings.js +40 -5
- package/src/server.js +27 -2
- package/src/usage-metrics.js +472 -0
- package/src/utils/logger.js +14 -1
- package/images/demo-screenshot.png +0 -0
- package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
- package/src/account-rotation/strategies/base-strategy.js +0 -48
- package/src/account-rotation/strategies/index.js +0 -31
- package/src/account-rotation/strategies/round-robin-strategy.js +0 -42
- package/src/account-rotation/strategies/sticky-strategy.js +0 -97
|
@@ -5,28 +5,23 @@ import { sendAuthError, getCredentialsOrError, getCredentialsForAccount } from '
|
|
|
5
5
|
import { initSSEResponse, pipeSSEStream, handleStreamError } from '../middleware/sse.js';
|
|
6
6
|
import { logger } from '../utils/logger.js';
|
|
7
7
|
import { AccountRotator } from '../account-rotation/index.js';
|
|
8
|
-
import { listAccounts,
|
|
9
|
-
import {
|
|
8
|
+
import { listAccounts, save } from '../account-manager.js';
|
|
9
|
+
import { isMultiAccountRotationEnabled } from '../server-settings.js';
|
|
10
|
+
import { recordUsageEventSafe, tapUsageEventStream } from '../usage-metrics.js';
|
|
10
11
|
|
|
11
12
|
const MAX_RETRIES = 5;
|
|
12
13
|
const MAX_WAIT_BEFORE_ERROR_MS = 120000;
|
|
13
14
|
const SHORT_RATE_LIMIT_THRESHOLD_MS = 5000;
|
|
14
15
|
|
|
15
16
|
let accountRotator = null;
|
|
16
|
-
let currentStrategy = null;
|
|
17
17
|
|
|
18
18
|
function getAccountRotator() {
|
|
19
|
-
|
|
20
|
-
const strategy = settings.accountStrategy || 'sticky';
|
|
21
|
-
|
|
22
|
-
if (!accountRotator || currentStrategy !== strategy) {
|
|
19
|
+
if (!accountRotator) {
|
|
23
20
|
accountRotator = new AccountRotator({
|
|
24
21
|
listAccounts,
|
|
25
|
-
save
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
currentStrategy = strategy;
|
|
29
|
-
logger.info(`[Messages] Account strategy: ${strategy}`);
|
|
22
|
+
save
|
|
23
|
+
});
|
|
24
|
+
logger.info('[Messages] Account rotation enabled');
|
|
30
25
|
}
|
|
31
26
|
return accountRotator;
|
|
32
27
|
}
|
|
@@ -37,10 +32,21 @@ export async function handleMessages(req, res) {
|
|
|
37
32
|
const requestedModel = body.model || DEFAULT_OPENAI_MODEL;
|
|
38
33
|
const isStreaming = body.stream !== false;
|
|
39
34
|
|
|
40
|
-
const { isKilo, kiloTarget, upstreamModel } = resolveModelRouting(requestedModel);
|
|
35
|
+
const { isKilo, kiloTarget, upstreamModel, reasoningLevel } = resolveModelRouting(requestedModel);
|
|
41
36
|
|
|
42
37
|
if (isKilo) {
|
|
43
38
|
if (!isKiloEnabled()) {
|
|
39
|
+
recordMessageMetric({
|
|
40
|
+
body,
|
|
41
|
+
endpoint: '/v1/messages',
|
|
42
|
+
requestedModel,
|
|
43
|
+
upstreamModel,
|
|
44
|
+
provider: 'kilo',
|
|
45
|
+
accountLabel: 'kilo',
|
|
46
|
+
startTime,
|
|
47
|
+
status: 403,
|
|
48
|
+
errorType: 'kilo_disabled'
|
|
49
|
+
});
|
|
44
50
|
return res.status(403).json({
|
|
45
51
|
type: 'error',
|
|
46
52
|
error: {
|
|
@@ -59,10 +65,20 @@ export async function handleMessages(req, res) {
|
|
|
59
65
|
if (!isMultiAccountRotationEnabled()) {
|
|
60
66
|
const creds = await getCredentialsOrError();
|
|
61
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
|
+
});
|
|
62
78
|
return sendAuthError(res);
|
|
63
79
|
}
|
|
64
80
|
|
|
65
|
-
const anthropicRequest = { ...body, model: upstreamModel };
|
|
81
|
+
const anthropicRequest = { ...body, model: upstreamModel, ...(reasoningLevel ? { reasoningLevel } : {}) };
|
|
66
82
|
try {
|
|
67
83
|
if (isStreaming) {
|
|
68
84
|
await _streamDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, null);
|
|
@@ -71,6 +87,17 @@ export async function handleMessages(req, res) {
|
|
|
71
87
|
}
|
|
72
88
|
return;
|
|
73
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
|
+
});
|
|
74
101
|
return handleStreamError(res, error, requestedModel, startTime);
|
|
75
102
|
}
|
|
76
103
|
}
|
|
@@ -79,6 +106,16 @@ export async function handleMessages(req, res) {
|
|
|
79
106
|
const accountSnapshot = listAccounts();
|
|
80
107
|
|
|
81
108
|
if (accountSnapshot.total === 0) {
|
|
109
|
+
recordMessageMetric({
|
|
110
|
+
body,
|
|
111
|
+
endpoint: '/v1/messages',
|
|
112
|
+
requestedModel,
|
|
113
|
+
upstreamModel,
|
|
114
|
+
provider: 'openai',
|
|
115
|
+
startTime,
|
|
116
|
+
status: 401,
|
|
117
|
+
errorType: 'auth_error'
|
|
118
|
+
});
|
|
82
119
|
return sendAuthError(res, 'No active account with valid credentials. Add an account via /accounts/add');
|
|
83
120
|
}
|
|
84
121
|
|
|
@@ -91,6 +128,16 @@ export async function handleMessages(req, res) {
|
|
|
91
128
|
const minWait = rotator.getMinWaitTimeMs(upstreamModel);
|
|
92
129
|
|
|
93
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
|
+
});
|
|
94
141
|
return handleStreamError(res, new Error(`RESOURCE_EXHAUSTED: All accounts rate-limited. Wait ${Math.round(minWait/1000)}s`), requestedModel, startTime);
|
|
95
142
|
}
|
|
96
143
|
|
|
@@ -109,6 +156,16 @@ export async function handleMessages(req, res) {
|
|
|
109
156
|
attempt--;
|
|
110
157
|
continue;
|
|
111
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
|
+
});
|
|
112
169
|
return sendAuthError(res, 'No available accounts');
|
|
113
170
|
}
|
|
114
171
|
|
|
@@ -118,7 +175,7 @@ export async function handleMessages(req, res) {
|
|
|
118
175
|
continue;
|
|
119
176
|
}
|
|
120
177
|
|
|
121
|
-
const anthropicRequest = { ...body, model: upstreamModel };
|
|
178
|
+
const anthropicRequest = { ...body, model: upstreamModel, ...(reasoningLevel ? { reasoningLevel } : {}) };
|
|
122
179
|
|
|
123
180
|
try {
|
|
124
181
|
if (isStreaming) {
|
|
@@ -152,38 +209,117 @@ export async function handleMessages(req, res) {
|
|
|
152
209
|
continue;
|
|
153
210
|
}
|
|
154
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
|
+
});
|
|
155
223
|
return handleStreamError(res, error, requestedModel, startTime);
|
|
156
224
|
}
|
|
157
225
|
}
|
|
158
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
|
+
});
|
|
159
237
|
return handleStreamError(res, new Error('Max retries exceeded'), requestedModel, startTime);
|
|
160
238
|
}
|
|
161
239
|
|
|
162
240
|
async function _streamDirectWithRotation(res, anthropicRequest, creds, responseModel, startTime, rotator) {
|
|
163
241
|
initSSEResponse(res);
|
|
164
|
-
const
|
|
242
|
+
const sourceStream = sendMessageStream(anthropicRequest, creds.accessToken, creds.accountId, rotator, creds.email);
|
|
243
|
+
let finalUsage = null;
|
|
244
|
+
const stream = tapUsageEventStream(sourceStream, (usage) => {
|
|
245
|
+
finalUsage = usage;
|
|
246
|
+
recordMessageMetric({
|
|
247
|
+
body: anthropicRequest,
|
|
248
|
+
endpoint: '/v1/messages',
|
|
249
|
+
requestedModel: responseModel,
|
|
250
|
+
upstreamModel: anthropicRequest.model,
|
|
251
|
+
provider: 'openai',
|
|
252
|
+
accountLabel: creds.email,
|
|
253
|
+
stream: true,
|
|
254
|
+
usage,
|
|
255
|
+
startTime,
|
|
256
|
+
status: 200
|
|
257
|
+
});
|
|
258
|
+
});
|
|
165
259
|
await pipeSSEStream(res, stream);
|
|
166
|
-
logger.response(200, { model: anthropicRequest.model, duration: Date.now() - startTime });
|
|
260
|
+
logger.response(200, { model: anthropicRequest.model, usage: finalUsage, duration: Date.now() - startTime });
|
|
167
261
|
}
|
|
168
262
|
|
|
169
263
|
async function _sendDirectWithRotation(res, anthropicRequest, creds, responseModel, startTime, rotator) {
|
|
170
264
|
const response = await sendMessage(anthropicRequest, creds.accessToken, creds.accountId);
|
|
171
265
|
const duration = Date.now() - startTime;
|
|
172
|
-
logger.response(200, { model: anthropicRequest.model,
|
|
266
|
+
logger.response(200, { model: anthropicRequest.model, usage: response.usage, duration });
|
|
267
|
+
recordMessageMetric({
|
|
268
|
+
body: anthropicRequest,
|
|
269
|
+
endpoint: '/v1/messages',
|
|
270
|
+
requestedModel: responseModel,
|
|
271
|
+
upstreamModel: anthropicRequest.model,
|
|
272
|
+
provider: 'openai',
|
|
273
|
+
accountLabel: creds.email,
|
|
274
|
+
stream: false,
|
|
275
|
+
usage: response.usage,
|
|
276
|
+
startTime,
|
|
277
|
+
status: 200,
|
|
278
|
+
duration
|
|
279
|
+
});
|
|
173
280
|
res.json({ ...response, model: responseModel });
|
|
174
281
|
}
|
|
175
282
|
|
|
176
283
|
async function _streamKilo(res, anthropicRequest, kiloTarget, responseModel, startTime) {
|
|
177
284
|
initSSEResponse(res);
|
|
178
|
-
const
|
|
285
|
+
const sourceStream = sendKiloMessageStream(anthropicRequest, kiloTarget);
|
|
286
|
+
let finalUsage = null;
|
|
287
|
+
const stream = tapUsageEventStream(sourceStream, (usage) => {
|
|
288
|
+
finalUsage = usage;
|
|
289
|
+
recordMessageMetric({
|
|
290
|
+
body: anthropicRequest,
|
|
291
|
+
endpoint: '/v1/messages',
|
|
292
|
+
requestedModel: responseModel,
|
|
293
|
+
upstreamModel: kiloTarget,
|
|
294
|
+
provider: 'kilo',
|
|
295
|
+
accountLabel: 'kilo',
|
|
296
|
+
stream: true,
|
|
297
|
+
usage,
|
|
298
|
+
startTime,
|
|
299
|
+
status: 200
|
|
300
|
+
});
|
|
301
|
+
});
|
|
179
302
|
await pipeSSEStream(res, stream);
|
|
180
|
-
logger.response(200, { model: kiloTarget, duration: Date.now() - startTime });
|
|
303
|
+
logger.response(200, { model: kiloTarget, usage: finalUsage, duration: Date.now() - startTime });
|
|
181
304
|
}
|
|
182
305
|
|
|
183
306
|
async function _sendKilo(res, anthropicRequest, kiloTarget, responseModel, startTime) {
|
|
184
307
|
const response = await sendKiloMessage(anthropicRequest, kiloTarget);
|
|
185
308
|
const duration = Date.now() - startTime;
|
|
186
|
-
logger.response(200, { model: kiloTarget,
|
|
309
|
+
logger.response(200, { model: kiloTarget, usage: response.usage, duration });
|
|
310
|
+
recordMessageMetric({
|
|
311
|
+
body: anthropicRequest,
|
|
312
|
+
endpoint: '/v1/messages',
|
|
313
|
+
requestedModel: responseModel,
|
|
314
|
+
upstreamModel: kiloTarget,
|
|
315
|
+
provider: 'kilo',
|
|
316
|
+
accountLabel: 'kilo',
|
|
317
|
+
stream: false,
|
|
318
|
+
usage: response.usage,
|
|
319
|
+
startTime,
|
|
320
|
+
status: 200,
|
|
321
|
+
duration
|
|
322
|
+
});
|
|
187
323
|
res.json({
|
|
188
324
|
id: response.id || undefined,
|
|
189
325
|
type: 'message',
|
|
@@ -200,4 +336,36 @@ function sleep(ms) {
|
|
|
200
336
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
201
337
|
}
|
|
202
338
|
|
|
339
|
+
function recordMessageMetric(options) {
|
|
340
|
+
const body = options.body || {};
|
|
341
|
+
recordUsageEventSafe({
|
|
342
|
+
startedAt: new Date(options.startTime || Date.now()).toISOString(),
|
|
343
|
+
completedAt: new Date().toISOString(),
|
|
344
|
+
endpoint: options.endpoint,
|
|
345
|
+
requestedModel: options.requestedModel,
|
|
346
|
+
upstreamModel: options.upstreamModel,
|
|
347
|
+
accountLabel: options.accountLabel,
|
|
348
|
+
provider: options.provider,
|
|
349
|
+
stream: options.stream ?? body.stream !== false,
|
|
350
|
+
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
351
|
+
toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
|
|
352
|
+
usage: options.usage,
|
|
353
|
+
status: options.status,
|
|
354
|
+
errorType: options.errorType,
|
|
355
|
+
durationMs: options.duration ?? Date.now() - (options.startTime || Date.now())
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function classifyMetricError(error) {
|
|
360
|
+
const message = error?.message || '';
|
|
361
|
+
if (message.startsWith('RATE_LIMITED:') || message.startsWith('RESOURCE_EXHAUSTED:')) return 'rate_limited';
|
|
362
|
+
if (message.includes('AUTH_EXPIRED')) return 'auth_expired';
|
|
363
|
+
if (message.startsWith('CLOUDFLARE_BLOCKED:')) return 'cloudflare_blocked';
|
|
364
|
+
if (message.startsWith('FORBIDDEN:')) return 'forbidden';
|
|
365
|
+
if (message.startsWith('INVALID_REQUEST:')) return 'invalid_request';
|
|
366
|
+
if (message.startsWith('KILO_API_ERROR:')) return 'kilo_api_error';
|
|
367
|
+
if (message.startsWith('API_ERROR:')) return 'api_error';
|
|
368
|
+
return 'unknown_error';
|
|
369
|
+
}
|
|
370
|
+
|
|
203
371
|
export default { handleMessages };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { getUsageMetricsStore } from '../usage-metrics.js';
|
|
2
|
+
|
|
3
|
+
const VALID_RANGES = new Set(['24h', '7d', '30d', 'all']);
|
|
4
|
+
const VALID_STATUSES = new Set(['success', 'error']);
|
|
5
|
+
|
|
6
|
+
function parseFilters(query = {}) {
|
|
7
|
+
const limit = Number(query.limit);
|
|
8
|
+
const status = String(query.status || '');
|
|
9
|
+
return {
|
|
10
|
+
range: VALID_RANGES.has(query.range) ? query.range : '24h',
|
|
11
|
+
model: typeof query.model === 'string' && query.model.trim() ? query.model.trim() : undefined,
|
|
12
|
+
account: typeof query.account === 'string' && query.account.trim() ? query.account.trim() : undefined,
|
|
13
|
+
status: VALID_STATUSES.has(status) || /^\d+$/.test(status) ? status : undefined,
|
|
14
|
+
limit: Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.trunc(limit))) : 50
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function routeStore(options = {}) {
|
|
19
|
+
return options.metricsStore || getUsageMetricsStore();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function handleGetMetricsSummary(req, res, options = {}) {
|
|
23
|
+
const { limit, ...filters } = parseFilters(req.query);
|
|
24
|
+
const summary = await routeStore(options).getSummary(filters);
|
|
25
|
+
res.json({ success: true, summary });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function handleGetMetricsRecent(req, res, options = {}) {
|
|
29
|
+
const filters = parseFilters(req.query);
|
|
30
|
+
const recent = await routeStore(options).getRecentEvents(filters);
|
|
31
|
+
res.json({ success: true, ...recent });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function handleGetMetricsStorage(req, res, options = {}) {
|
|
35
|
+
const storage = await routeStore(options).getStorageInfo();
|
|
36
|
+
res.json({ success: true, storage });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
handleGetMetricsSummary,
|
|
41
|
+
handleGetMetricsRecent,
|
|
42
|
+
handleGetMetricsStorage
|
|
43
|
+
};
|
|
@@ -3,16 +3,39 @@
|
|
|
3
3
|
* Handles server settings endpoints:
|
|
4
4
|
* GET /settings/haiku-model
|
|
5
5
|
* POST /settings/haiku-model
|
|
6
|
-
* GET /settings/
|
|
7
|
-
* POST /settings/
|
|
6
|
+
* GET /settings/claude-proxy
|
|
7
|
+
* POST /settings/claude-proxy
|
|
8
8
|
* GET /settings/kilo-models
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { getServerSettings,
|
|
11
|
+
import { getServerSettings, setServerSettings } from '../server-settings.js';
|
|
12
12
|
import { fetchFreeModels } from '../kilo-models.js';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
CLAUDE_MODEL_ALIASES,
|
|
15
|
+
DEFAULT_MODEL_MAPPINGS,
|
|
16
|
+
DEFAULT_REASONING_MAPPINGS,
|
|
17
|
+
OPENAI_MODEL_OPTIONS,
|
|
18
|
+
REASONING_LEVEL_OPTIONS,
|
|
19
|
+
isKiloEnabled,
|
|
20
|
+
normalizeModelMappings,
|
|
21
|
+
normalizeReasoningMappings
|
|
22
|
+
} from '../model-mapper.js';
|
|
14
23
|
|
|
15
|
-
const
|
|
24
|
+
const VALID_OPENAI_MODEL_IDS = new Set(OPENAI_MODEL_OPTIONS.map((model) => model.id));
|
|
25
|
+
const VALID_REASONING_LEVEL_IDS = new Set(REASONING_LEVEL_OPTIONS.map((level) => level.id));
|
|
26
|
+
|
|
27
|
+
function modelMappingsPayload(modelMappings, reasoningMappings) {
|
|
28
|
+
return {
|
|
29
|
+
success: true,
|
|
30
|
+
aliases: CLAUDE_MODEL_ALIASES,
|
|
31
|
+
models: OPENAI_MODEL_OPTIONS,
|
|
32
|
+
reasoningLevels: REASONING_LEVEL_OPTIONS,
|
|
33
|
+
defaults: DEFAULT_MODEL_MAPPINGS,
|
|
34
|
+
reasoningDefaults: DEFAULT_REASONING_MAPPINGS,
|
|
35
|
+
modelMappings: normalizeModelMappings(modelMappings),
|
|
36
|
+
reasoningMappings: normalizeReasoningMappings(reasoningMappings)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
16
39
|
|
|
17
40
|
/**
|
|
18
41
|
* GET /settings/haiku-model
|
|
@@ -100,37 +123,118 @@ export async function handleGetKiloModels(req, res) {
|
|
|
100
123
|
}
|
|
101
124
|
|
|
102
125
|
/**
|
|
103
|
-
* GET /settings/
|
|
104
|
-
* Returns
|
|
126
|
+
* GET /settings/model-mappings
|
|
127
|
+
* Returns editable Claude alias -> upstream GPT model mappings.
|
|
128
|
+
*/
|
|
129
|
+
export function handleGetModelMappings(req, res) {
|
|
130
|
+
const settings = getServerSettings();
|
|
131
|
+
res.json(modelMappingsPayload(settings.modelMappings, settings.reasoningMappings));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* POST /settings/model-mappings
|
|
136
|
+
* Updates one or more Claude alias mappings.
|
|
137
|
+
*/
|
|
138
|
+
export function handleSetModelMappings(req, res) {
|
|
139
|
+
const { modelMappings, reasoningMappings } = req.body || {};
|
|
140
|
+
const hasModelMappings = modelMappings !== undefined;
|
|
141
|
+
const hasReasoningMappings = reasoningMappings !== undefined;
|
|
142
|
+
|
|
143
|
+
if (!hasModelMappings && !hasReasoningMappings) {
|
|
144
|
+
return res.status(400).json({
|
|
145
|
+
success: false,
|
|
146
|
+
error: 'modelMappings or reasoningMappings is required'
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (hasModelMappings && (!modelMappings || typeof modelMappings !== 'object' || Array.isArray(modelMappings))) {
|
|
151
|
+
return res.status(400).json({
|
|
152
|
+
success: false,
|
|
153
|
+
error: 'modelMappings is required and must be an object'
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (hasReasoningMappings && (!reasoningMappings || typeof reasoningMappings !== 'object' || Array.isArray(reasoningMappings))) {
|
|
158
|
+
return res.status(400).json({
|
|
159
|
+
success: false,
|
|
160
|
+
error: 'reasoningMappings must be an object'
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const modelAliases = hasModelMappings ? Object.keys(modelMappings) : [];
|
|
165
|
+
const reasoningAliases = hasReasoningMappings ? Object.keys(reasoningMappings) : [];
|
|
166
|
+
const unknownAliases = [...modelAliases, ...reasoningAliases].filter((alias) => !CLAUDE_MODEL_ALIASES.includes(alias));
|
|
167
|
+
if (unknownAliases.length > 0) {
|
|
168
|
+
return res.status(400).json({
|
|
169
|
+
success: false,
|
|
170
|
+
error: `Invalid model mapping alias. Use one of: ${CLAUDE_MODEL_ALIASES.join(', ')}`
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const [alias, model] of Object.entries(modelMappings || {})) {
|
|
175
|
+
if (typeof model !== 'string' || !VALID_OPENAI_MODEL_IDS.has(model)) {
|
|
176
|
+
return res.status(400).json({
|
|
177
|
+
success: false,
|
|
178
|
+
error: `Invalid model for ${alias}. Use one of: ${OPENAI_MODEL_OPTIONS.map((option) => option.id).join(', ')}`
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const [alias, reasoning] of Object.entries(reasoningMappings || {})) {
|
|
184
|
+
if (typeof reasoning !== 'string' || !VALID_REASONING_LEVEL_IDS.has(reasoning)) {
|
|
185
|
+
return res.status(400).json({
|
|
186
|
+
success: false,
|
|
187
|
+
error: `Invalid reasoning level for ${alias}. Use one of: ${REASONING_LEVEL_OPTIONS.map((option) => option.id).join(', ')}`
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const settings = getServerSettings();
|
|
193
|
+
const nextMappings = {
|
|
194
|
+
...normalizeModelMappings(settings.modelMappings),
|
|
195
|
+
...(modelMappings || {})
|
|
196
|
+
};
|
|
197
|
+
const nextReasoningMappings = {
|
|
198
|
+
...normalizeReasoningMappings(settings.reasoningMappings),
|
|
199
|
+
...(reasoningMappings || {})
|
|
200
|
+
};
|
|
201
|
+
const nextSettings = setServerSettings({
|
|
202
|
+
modelMappings: nextMappings,
|
|
203
|
+
reasoningMappings: nextReasoningMappings
|
|
204
|
+
});
|
|
205
|
+
res.json(modelMappingsPayload(nextSettings.modelMappings, nextSettings.reasoningMappings));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* GET /settings/claude-proxy
|
|
210
|
+
* Returns Claude proxy configuration preferences.
|
|
105
211
|
*/
|
|
106
|
-
export function
|
|
212
|
+
export function handleGetClaudeProxySetting(req, res) {
|
|
107
213
|
const settings = getServerSettings();
|
|
108
214
|
res.json({
|
|
109
215
|
success: true,
|
|
110
|
-
|
|
111
|
-
rotationEnabled: isMultiAccountRotationEnabled()
|
|
216
|
+
configureClaudeOnStartup: settings.configureClaudeOnStartup === true
|
|
112
217
|
});
|
|
113
218
|
}
|
|
114
219
|
|
|
115
220
|
/**
|
|
116
|
-
* POST /settings/
|
|
117
|
-
* Updates
|
|
221
|
+
* POST /settings/claude-proxy
|
|
222
|
+
* Updates Claude proxy configuration preferences.
|
|
118
223
|
*/
|
|
119
|
-
export function
|
|
120
|
-
const {
|
|
224
|
+
export function handleSetClaudeProxySetting(req, res) {
|
|
225
|
+
const { configureClaudeOnStartup } = req.body || {};
|
|
121
226
|
|
|
122
|
-
if (
|
|
227
|
+
if (typeof configureClaudeOnStartup !== 'boolean') {
|
|
123
228
|
return res.status(400).json({
|
|
124
229
|
success: false,
|
|
125
|
-
error:
|
|
230
|
+
error: 'configureClaudeOnStartup is required and must be a boolean'
|
|
126
231
|
});
|
|
127
232
|
}
|
|
128
233
|
|
|
129
|
-
const settings = setServerSettings({
|
|
234
|
+
const settings = setServerSettings({ configureClaudeOnStartup });
|
|
130
235
|
res.json({
|
|
131
236
|
success: true,
|
|
132
|
-
|
|
133
|
-
rotationEnabled: isMultiAccountRotationEnabled()
|
|
237
|
+
configureClaudeOnStartup: settings.configureClaudeOnStartup === true
|
|
134
238
|
});
|
|
135
239
|
}
|
|
136
240
|
|
|
@@ -138,6 +242,8 @@ export default {
|
|
|
138
242
|
handleGetHaikuModel,
|
|
139
243
|
handleSetHaikuModel,
|
|
140
244
|
handleGetKiloModels,
|
|
141
|
-
|
|
142
|
-
|
|
245
|
+
handleGetModelMappings,
|
|
246
|
+
handleSetModelMappings,
|
|
247
|
+
handleGetClaudeProxySetting,
|
|
248
|
+
handleSetClaudeProxySetting
|
|
143
249
|
};
|
package/src/security.js
CHANGED
|
@@ -3,7 +3,8 @@ const CONTROL_PATH_PREFIXES = [
|
|
|
3
3
|
'/accounts',
|
|
4
4
|
'/settings',
|
|
5
5
|
'/claude/config',
|
|
6
|
-
'/api/logs'
|
|
6
|
+
'/api/logs',
|
|
7
|
+
'/api/metrics'
|
|
7
8
|
];
|
|
8
9
|
const SAFE_FETCH_SITES = new Set(['same-origin', 'same-site', 'none']);
|
|
9
10
|
const SENSITIVE_KEY_RE = /(api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|secret|password)/i;
|
package/src/server-settings.js
CHANGED
|
@@ -7,9 +7,43 @@ const MULTI_ACCOUNT_ROTATION_ENV = 'CODEX_CLAUDE_PROXY_ENABLE_MULTI_ACCOUNT_ROTA
|
|
|
7
7
|
|
|
8
8
|
const DEFAULT_SETTINGS = {
|
|
9
9
|
haikuKiloModel: 'minimax/minimax-m2.5:free',
|
|
10
|
-
|
|
10
|
+
configureClaudeOnStartup: false,
|
|
11
|
+
modelMappings: {
|
|
12
|
+
opus: 'gpt-5.5',
|
|
13
|
+
sonnet: 'gpt-5.5',
|
|
14
|
+
haiku: 'gpt-5.4-mini'
|
|
15
|
+
},
|
|
16
|
+
reasoningMappings: {
|
|
17
|
+
opus: 'high',
|
|
18
|
+
sonnet: 'medium',
|
|
19
|
+
haiku: 'low'
|
|
20
|
+
}
|
|
11
21
|
};
|
|
12
22
|
|
|
23
|
+
export function normalizeSettings(data = {}) {
|
|
24
|
+
const modelMappings = data?.modelMappings && typeof data.modelMappings === 'object' && !Array.isArray(data.modelMappings)
|
|
25
|
+
? data.modelMappings
|
|
26
|
+
: {};
|
|
27
|
+
const reasoningMappings = data?.reasoningMappings && typeof data.reasoningMappings === 'object' && !Array.isArray(data.reasoningMappings)
|
|
28
|
+
? data.reasoningMappings
|
|
29
|
+
: {};
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
haikuKiloModel: typeof data.haikuKiloModel === 'string'
|
|
33
|
+
? data.haikuKiloModel
|
|
34
|
+
: DEFAULT_SETTINGS.haikuKiloModel,
|
|
35
|
+
configureClaudeOnStartup: data.configureClaudeOnStartup === true,
|
|
36
|
+
modelMappings: {
|
|
37
|
+
...DEFAULT_SETTINGS.modelMappings,
|
|
38
|
+
...modelMappings
|
|
39
|
+
},
|
|
40
|
+
reasoningMappings: {
|
|
41
|
+
...DEFAULT_SETTINGS.reasoningMappings,
|
|
42
|
+
...reasoningMappings
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
13
47
|
function ensureConfigDir() {
|
|
14
48
|
if (!existsSync(CONFIG_DIR)) {
|
|
15
49
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -20,21 +54,21 @@ export function getServerSettings() {
|
|
|
20
54
|
ensureConfigDir();
|
|
21
55
|
|
|
22
56
|
if (!existsSync(SETTINGS_FILE)) {
|
|
23
|
-
return
|
|
57
|
+
return normalizeSettings();
|
|
24
58
|
}
|
|
25
59
|
|
|
26
60
|
try {
|
|
27
61
|
const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
|
|
28
|
-
return
|
|
62
|
+
return normalizeSettings(data);
|
|
29
63
|
} catch (error) {
|
|
30
64
|
console.error('[ServerSettings] Failed to read settings:', error.message);
|
|
31
|
-
return
|
|
65
|
+
return normalizeSettings();
|
|
32
66
|
}
|
|
33
67
|
}
|
|
34
68
|
|
|
35
69
|
export function setServerSettings(patch = {}) {
|
|
36
70
|
const current = getServerSettings();
|
|
37
|
-
const next = { ...current, ...patch };
|
|
71
|
+
const next = normalizeSettings({ ...current, ...patch });
|
|
38
72
|
|
|
39
73
|
ensureConfigDir();
|
|
40
74
|
writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
@@ -50,6 +84,7 @@ export { SETTINGS_FILE, MULTI_ACCOUNT_ROTATION_ENV };
|
|
|
50
84
|
export default {
|
|
51
85
|
getServerSettings,
|
|
52
86
|
setServerSettings,
|
|
87
|
+
normalizeSettings,
|
|
53
88
|
isMultiAccountRotationEnabled,
|
|
54
89
|
MULTI_ACCOUNT_ROTATION_ENV,
|
|
55
90
|
SETTINGS_FILE
|
package/src/server.js
CHANGED
|
@@ -9,8 +9,16 @@ import cors from 'cors';
|
|
|
9
9
|
import { ensureAccountsPersist, startAutoRefresh } from './account-manager.js';
|
|
10
10
|
import { registerApiRoutes } from './routes/api-routes.js';
|
|
11
11
|
import { buildAllowedOrigins, securityMiddleware } from './security.js';
|
|
12
|
+
import { getServerSettings } from './server-settings.js';
|
|
13
|
+
import { setProxyMode } from './claude-config.js';
|
|
12
14
|
|
|
13
15
|
export const DEFAULT_HOST = '127.0.0.1';
|
|
16
|
+
const CLAUDE_PROXY_MODELS = {
|
|
17
|
+
default: 'claude-sonnet-4-6',
|
|
18
|
+
opus: 'claude-opus-4-6',
|
|
19
|
+
sonnet: 'claude-sonnet-4-6',
|
|
20
|
+
haiku: 'claude-haiku-4-5'
|
|
21
|
+
};
|
|
14
22
|
|
|
15
23
|
export function createServer({ port, host = DEFAULT_HOST }) {
|
|
16
24
|
ensureAccountsPersist();
|
|
@@ -52,7 +60,24 @@ export function createServer({ port, host = DEFAULT_HOST }) {
|
|
|
52
60
|
|
|
53
61
|
export function startServer({ port, host = process.env.HOST || DEFAULT_HOST }) {
|
|
54
62
|
const app = createServer({ port, host });
|
|
55
|
-
|
|
63
|
+
const server = app.listen(port, host);
|
|
64
|
+
server.once('listening', () => {
|
|
65
|
+
configureClaudeOnStartup({ port }).catch((error) => {
|
|
66
|
+
console.error('[ClaudeConfig] Startup proxy configuration failed:', error.message);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
return server;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function configureClaudeOnStartup({ port }) {
|
|
73
|
+
const settings = getServerSettings();
|
|
74
|
+
if (settings.configureClaudeOnStartup !== true) {
|
|
75
|
+
return { configured: false, reason: 'disabled' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const proxyUrl = `http://localhost:${port}`;
|
|
79
|
+
await setProxyMode(proxyUrl, CLAUDE_PROXY_MODELS);
|
|
80
|
+
return { configured: true, proxyUrl };
|
|
56
81
|
}
|
|
57
82
|
|
|
58
|
-
export default { createServer, startServer };
|
|
83
|
+
export default { createServer, startServer, configureClaudeOnStartup };
|