@pikoloo/codex-proxy 1.0.6 → 1.0.7
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/images/dashboard-screenshot.png +0 -0
- package/images/readme-cover.png +0 -0
- package/images/settings-screenshot.png +0 -0
- package/package.json +2 -1
- package/public/css/style.css +247 -0
- package/public/index.html +311 -15
- package/public/js/app.js +275 -14
- 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 +21 -1
- package/src/routes/chat-route.js +76 -3
- package/src/routes/messages-route.js +175 -5
- package/src/routes/metrics-route.js +43 -0
- package/src/routes/settings-route.js +148 -2
- package/src/security.js +2 -1
- package/src/server-settings.js +40 -4
- package/src/server.js +27 -2
- package/src/usage-metrics.js +472 -0
package/src/routes/chat-route.js
CHANGED
|
@@ -10,6 +10,7 @@ import { DEFAULT_OPENAI_MODEL, isKiloEnabled, resolveModelRouting } from '../mod
|
|
|
10
10
|
import { getCredentialsOrError, sendAuthError } from '../middleware/credentials.js';
|
|
11
11
|
import { handleStreamError } from '../middleware/sse.js';
|
|
12
12
|
import { logger } from '../utils/logger.js';
|
|
13
|
+
import { recordUsageEventSafe } from '../usage-metrics.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* POST /v1/chat/completions
|
|
@@ -21,9 +22,19 @@ export async function handleChatCompletion(req, res) {
|
|
|
21
22
|
const body = req.body;
|
|
22
23
|
const requestedModel = body.model || DEFAULT_OPENAI_MODEL;
|
|
23
24
|
|
|
24
|
-
const { isKilo, kiloTarget, upstreamModel } = resolveModelRouting(requestedModel);
|
|
25
|
+
const { isKilo, kiloTarget, upstreamModel, reasoningLevel } = resolveModelRouting(requestedModel);
|
|
25
26
|
|
|
26
27
|
if (isKilo && !isKiloEnabled()) {
|
|
28
|
+
recordChatMetric({
|
|
29
|
+
body,
|
|
30
|
+
requestedModel,
|
|
31
|
+
upstreamModel,
|
|
32
|
+
provider: 'kilo',
|
|
33
|
+
accountLabel: 'kilo',
|
|
34
|
+
startTime,
|
|
35
|
+
status: 403,
|
|
36
|
+
errorType: 'kilo_disabled'
|
|
37
|
+
});
|
|
27
38
|
return res.status(403).json({
|
|
28
39
|
error: {
|
|
29
40
|
message: 'Kilo routing is disabled. Set CODEX_CLAUDE_PROXY_ENABLE_KILO=true to enable third-party Kilo model routing.',
|
|
@@ -38,11 +49,20 @@ export async function handleChatCompletion(req, res) {
|
|
|
38
49
|
creds = await getCredentialsOrError();
|
|
39
50
|
if (!creds) {
|
|
40
51
|
logger.response(401, { error: 'No active account' });
|
|
52
|
+
recordChatMetric({
|
|
53
|
+
body,
|
|
54
|
+
requestedModel,
|
|
55
|
+
upstreamModel,
|
|
56
|
+
provider: 'openai',
|
|
57
|
+
startTime,
|
|
58
|
+
status: 401,
|
|
59
|
+
errorType: 'auth_error'
|
|
60
|
+
});
|
|
41
61
|
return sendAuthError(res, 'No active account. Add an account via /accounts/add');
|
|
42
62
|
}
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
const anthropicRequest = _buildAnthropicRequest(body, upstreamModel);
|
|
65
|
+
const anthropicRequest = _buildAnthropicRequest(body, upstreamModel, reasoningLevel);
|
|
46
66
|
|
|
47
67
|
logger.request('POST', '/v1/chat/completions', {
|
|
48
68
|
model: upstreamModel,
|
|
@@ -58,9 +78,30 @@ export async function handleChatCompletion(req, res) {
|
|
|
58
78
|
|
|
59
79
|
const duration = Date.now() - startTime;
|
|
60
80
|
logger.response(200, { model: upstreamModel, tokens: response.usage?.output_tokens || 0, duration });
|
|
81
|
+
recordChatMetric({
|
|
82
|
+
body,
|
|
83
|
+
requestedModel,
|
|
84
|
+
upstreamModel,
|
|
85
|
+
provider: isKilo ? 'kilo' : 'openai',
|
|
86
|
+
accountLabel: isKilo ? 'kilo' : creds.email,
|
|
87
|
+
usage: response.usage,
|
|
88
|
+
startTime,
|
|
89
|
+
status: 200,
|
|
90
|
+
duration
|
|
91
|
+
});
|
|
61
92
|
|
|
62
93
|
res.json(_buildOpenAIResponse(response, requestedModel));
|
|
63
94
|
} catch (error) {
|
|
95
|
+
recordChatMetric({
|
|
96
|
+
body,
|
|
97
|
+
requestedModel,
|
|
98
|
+
upstreamModel,
|
|
99
|
+
provider: isKilo ? 'kilo' : 'openai',
|
|
100
|
+
accountLabel: isKilo ? 'kilo' : creds?.email,
|
|
101
|
+
startTime,
|
|
102
|
+
status: error.status || 500,
|
|
103
|
+
errorType: classifyMetricError(error)
|
|
104
|
+
});
|
|
64
105
|
handleStreamError(res, error, upstreamModel, startTime);
|
|
65
106
|
}
|
|
66
107
|
}
|
|
@@ -117,7 +158,7 @@ export function handleCountTokens(req, res) {
|
|
|
117
158
|
* @param {string} upstreamModel
|
|
118
159
|
* @returns {object}
|
|
119
160
|
*/
|
|
120
|
-
function _buildAnthropicRequest(body, upstreamModel) {
|
|
161
|
+
function _buildAnthropicRequest(body, upstreamModel, reasoningLevel = null) {
|
|
121
162
|
const anthropicRequest = {
|
|
122
163
|
model: upstreamModel,
|
|
123
164
|
messages: [],
|
|
@@ -125,6 +166,10 @@ function _buildAnthropicRequest(body, upstreamModel) {
|
|
|
125
166
|
stream: false
|
|
126
167
|
};
|
|
127
168
|
|
|
169
|
+
if (reasoningLevel) {
|
|
170
|
+
anthropicRequest.reasoningLevel = reasoningLevel;
|
|
171
|
+
}
|
|
172
|
+
|
|
128
173
|
if (body.messages) {
|
|
129
174
|
const systemMsg = body.messages.find(m => m.role === 'system');
|
|
130
175
|
if (systemMsg) {
|
|
@@ -226,4 +271,32 @@ function _buildOpenAIResponse(response, responseModel) {
|
|
|
226
271
|
};
|
|
227
272
|
}
|
|
228
273
|
|
|
274
|
+
function recordChatMetric(options) {
|
|
275
|
+
const body = options.body || {};
|
|
276
|
+
recordUsageEventSafe({
|
|
277
|
+
startedAt: new Date(options.startTime || Date.now()).toISOString(),
|
|
278
|
+
completedAt: new Date().toISOString(),
|
|
279
|
+
endpoint: '/v1/chat/completions',
|
|
280
|
+
requestedModel: options.requestedModel,
|
|
281
|
+
upstreamModel: options.upstreamModel,
|
|
282
|
+
accountLabel: options.accountLabel,
|
|
283
|
+
provider: options.provider,
|
|
284
|
+
stream: false,
|
|
285
|
+
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
286
|
+
toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
|
|
287
|
+
usage: options.usage,
|
|
288
|
+
status: options.status,
|
|
289
|
+
errorType: options.errorType,
|
|
290
|
+
durationMs: options.duration ?? Date.now() - (options.startTime || Date.now())
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function classifyMetricError(error) {
|
|
295
|
+
const message = error?.message || '';
|
|
296
|
+
if (message.includes('AUTH_EXPIRED')) return 'auth_expired';
|
|
297
|
+
if (message.startsWith('KILO_API_ERROR:')) return 'kilo_api_error';
|
|
298
|
+
if (message.startsWith('API_ERROR:')) return 'api_error';
|
|
299
|
+
return 'unknown_error';
|
|
300
|
+
}
|
|
301
|
+
|
|
229
302
|
export default { handleChatCompletion, handleCountTokens };
|
|
@@ -7,6 +7,7 @@ import { logger } from '../utils/logger.js';
|
|
|
7
7
|
import { AccountRotator } from '../account-rotation/index.js';
|
|
8
8
|
import { listAccounts, getActiveAccount, save } from '../account-manager.js';
|
|
9
9
|
import { getServerSettings, 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;
|
|
@@ -37,10 +38,21 @@ export async function handleMessages(req, res) {
|
|
|
37
38
|
const requestedModel = body.model || DEFAULT_OPENAI_MODEL;
|
|
38
39
|
const isStreaming = body.stream !== false;
|
|
39
40
|
|
|
40
|
-
const { isKilo, kiloTarget, upstreamModel } = resolveModelRouting(requestedModel);
|
|
41
|
+
const { isKilo, kiloTarget, upstreamModel, reasoningLevel } = resolveModelRouting(requestedModel);
|
|
41
42
|
|
|
42
43
|
if (isKilo) {
|
|
43
44
|
if (!isKiloEnabled()) {
|
|
45
|
+
recordMessageMetric({
|
|
46
|
+
body,
|
|
47
|
+
endpoint: '/v1/messages',
|
|
48
|
+
requestedModel,
|
|
49
|
+
upstreamModel,
|
|
50
|
+
provider: 'kilo',
|
|
51
|
+
accountLabel: 'kilo',
|
|
52
|
+
startTime,
|
|
53
|
+
status: 403,
|
|
54
|
+
errorType: 'kilo_disabled'
|
|
55
|
+
});
|
|
44
56
|
return res.status(403).json({
|
|
45
57
|
type: 'error',
|
|
46
58
|
error: {
|
|
@@ -59,10 +71,20 @@ export async function handleMessages(req, res) {
|
|
|
59
71
|
if (!isMultiAccountRotationEnabled()) {
|
|
60
72
|
const creds = await getCredentialsOrError();
|
|
61
73
|
if (!creds) {
|
|
74
|
+
recordMessageMetric({
|
|
75
|
+
body,
|
|
76
|
+
endpoint: '/v1/messages',
|
|
77
|
+
requestedModel,
|
|
78
|
+
upstreamModel,
|
|
79
|
+
provider: 'openai',
|
|
80
|
+
startTime,
|
|
81
|
+
status: 401,
|
|
82
|
+
errorType: 'auth_error'
|
|
83
|
+
});
|
|
62
84
|
return sendAuthError(res);
|
|
63
85
|
}
|
|
64
86
|
|
|
65
|
-
const anthropicRequest = { ...body, model: upstreamModel };
|
|
87
|
+
const anthropicRequest = { ...body, model: upstreamModel, ...(reasoningLevel ? { reasoningLevel } : {}) };
|
|
66
88
|
try {
|
|
67
89
|
if (isStreaming) {
|
|
68
90
|
await _streamDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, null);
|
|
@@ -71,6 +93,17 @@ export async function handleMessages(req, res) {
|
|
|
71
93
|
}
|
|
72
94
|
return;
|
|
73
95
|
} catch (error) {
|
|
96
|
+
recordMessageMetric({
|
|
97
|
+
body,
|
|
98
|
+
endpoint: '/v1/messages',
|
|
99
|
+
requestedModel,
|
|
100
|
+
upstreamModel,
|
|
101
|
+
provider: 'openai',
|
|
102
|
+
accountLabel: creds.email,
|
|
103
|
+
startTime,
|
|
104
|
+
status: error.status || 500,
|
|
105
|
+
errorType: classifyMetricError(error)
|
|
106
|
+
});
|
|
74
107
|
return handleStreamError(res, error, requestedModel, startTime);
|
|
75
108
|
}
|
|
76
109
|
}
|
|
@@ -79,6 +112,16 @@ export async function handleMessages(req, res) {
|
|
|
79
112
|
const accountSnapshot = listAccounts();
|
|
80
113
|
|
|
81
114
|
if (accountSnapshot.total === 0) {
|
|
115
|
+
recordMessageMetric({
|
|
116
|
+
body,
|
|
117
|
+
endpoint: '/v1/messages',
|
|
118
|
+
requestedModel,
|
|
119
|
+
upstreamModel,
|
|
120
|
+
provider: 'openai',
|
|
121
|
+
startTime,
|
|
122
|
+
status: 401,
|
|
123
|
+
errorType: 'auth_error'
|
|
124
|
+
});
|
|
82
125
|
return sendAuthError(res, 'No active account with valid credentials. Add an account via /accounts/add');
|
|
83
126
|
}
|
|
84
127
|
|
|
@@ -91,6 +134,16 @@ export async function handleMessages(req, res) {
|
|
|
91
134
|
const minWait = rotator.getMinWaitTimeMs(upstreamModel);
|
|
92
135
|
|
|
93
136
|
if (minWait > MAX_WAIT_BEFORE_ERROR_MS) {
|
|
137
|
+
recordMessageMetric({
|
|
138
|
+
body,
|
|
139
|
+
endpoint: '/v1/messages',
|
|
140
|
+
requestedModel,
|
|
141
|
+
upstreamModel,
|
|
142
|
+
provider: 'openai',
|
|
143
|
+
startTime,
|
|
144
|
+
status: 429,
|
|
145
|
+
errorType: 'rate_limited'
|
|
146
|
+
});
|
|
94
147
|
return handleStreamError(res, new Error(`RESOURCE_EXHAUSTED: All accounts rate-limited. Wait ${Math.round(minWait/1000)}s`), requestedModel, startTime);
|
|
95
148
|
}
|
|
96
149
|
|
|
@@ -109,6 +162,16 @@ export async function handleMessages(req, res) {
|
|
|
109
162
|
attempt--;
|
|
110
163
|
continue;
|
|
111
164
|
}
|
|
165
|
+
recordMessageMetric({
|
|
166
|
+
body,
|
|
167
|
+
endpoint: '/v1/messages',
|
|
168
|
+
requestedModel,
|
|
169
|
+
upstreamModel,
|
|
170
|
+
provider: 'openai',
|
|
171
|
+
startTime,
|
|
172
|
+
status: 401,
|
|
173
|
+
errorType: 'auth_error'
|
|
174
|
+
});
|
|
112
175
|
return sendAuthError(res, 'No available accounts');
|
|
113
176
|
}
|
|
114
177
|
|
|
@@ -118,7 +181,7 @@ export async function handleMessages(req, res) {
|
|
|
118
181
|
continue;
|
|
119
182
|
}
|
|
120
183
|
|
|
121
|
-
const anthropicRequest = { ...body, model: upstreamModel };
|
|
184
|
+
const anthropicRequest = { ...body, model: upstreamModel, ...(reasoningLevel ? { reasoningLevel } : {}) };
|
|
122
185
|
|
|
123
186
|
try {
|
|
124
187
|
if (isStreaming) {
|
|
@@ -152,16 +215,51 @@ export async function handleMessages(req, res) {
|
|
|
152
215
|
continue;
|
|
153
216
|
}
|
|
154
217
|
|
|
218
|
+
recordMessageMetric({
|
|
219
|
+
body,
|
|
220
|
+
endpoint: '/v1/messages',
|
|
221
|
+
requestedModel,
|
|
222
|
+
upstreamModel,
|
|
223
|
+
provider: 'openai',
|
|
224
|
+
accountLabel: account.email,
|
|
225
|
+
startTime,
|
|
226
|
+
status: error.status || 500,
|
|
227
|
+
errorType: classifyMetricError(error)
|
|
228
|
+
});
|
|
155
229
|
return handleStreamError(res, error, requestedModel, startTime);
|
|
156
230
|
}
|
|
157
231
|
}
|
|
158
232
|
|
|
233
|
+
recordMessageMetric({
|
|
234
|
+
body,
|
|
235
|
+
endpoint: '/v1/messages',
|
|
236
|
+
requestedModel,
|
|
237
|
+
upstreamModel,
|
|
238
|
+
provider: 'openai',
|
|
239
|
+
startTime,
|
|
240
|
+
status: 500,
|
|
241
|
+
errorType: 'max_retries'
|
|
242
|
+
});
|
|
159
243
|
return handleStreamError(res, new Error('Max retries exceeded'), requestedModel, startTime);
|
|
160
244
|
}
|
|
161
245
|
|
|
162
246
|
async function _streamDirectWithRotation(res, anthropicRequest, creds, responseModel, startTime, rotator) {
|
|
163
247
|
initSSEResponse(res);
|
|
164
|
-
const
|
|
248
|
+
const sourceStream = sendMessageStream(anthropicRequest, creds.accessToken, creds.accountId, rotator, creds.email);
|
|
249
|
+
const stream = tapUsageEventStream(sourceStream, (usage) => {
|
|
250
|
+
recordMessageMetric({
|
|
251
|
+
body: anthropicRequest,
|
|
252
|
+
endpoint: '/v1/messages',
|
|
253
|
+
requestedModel: responseModel,
|
|
254
|
+
upstreamModel: anthropicRequest.model,
|
|
255
|
+
provider: 'openai',
|
|
256
|
+
accountLabel: creds.email,
|
|
257
|
+
stream: true,
|
|
258
|
+
usage,
|
|
259
|
+
startTime,
|
|
260
|
+
status: 200
|
|
261
|
+
});
|
|
262
|
+
});
|
|
165
263
|
await pipeSSEStream(res, stream);
|
|
166
264
|
logger.response(200, { model: anthropicRequest.model, duration: Date.now() - startTime });
|
|
167
265
|
}
|
|
@@ -170,12 +268,39 @@ async function _sendDirectWithRotation(res, anthropicRequest, creds, responseMod
|
|
|
170
268
|
const response = await sendMessage(anthropicRequest, creds.accessToken, creds.accountId);
|
|
171
269
|
const duration = Date.now() - startTime;
|
|
172
270
|
logger.response(200, { model: anthropicRequest.model, tokens: response.usage?.output_tokens || 0, duration });
|
|
271
|
+
recordMessageMetric({
|
|
272
|
+
body: anthropicRequest,
|
|
273
|
+
endpoint: '/v1/messages',
|
|
274
|
+
requestedModel: responseModel,
|
|
275
|
+
upstreamModel: anthropicRequest.model,
|
|
276
|
+
provider: 'openai',
|
|
277
|
+
accountLabel: creds.email,
|
|
278
|
+
stream: false,
|
|
279
|
+
usage: response.usage,
|
|
280
|
+
startTime,
|
|
281
|
+
status: 200,
|
|
282
|
+
duration
|
|
283
|
+
});
|
|
173
284
|
res.json({ ...response, model: responseModel });
|
|
174
285
|
}
|
|
175
286
|
|
|
176
287
|
async function _streamKilo(res, anthropicRequest, kiloTarget, responseModel, startTime) {
|
|
177
288
|
initSSEResponse(res);
|
|
178
|
-
const
|
|
289
|
+
const sourceStream = sendKiloMessageStream(anthropicRequest, kiloTarget);
|
|
290
|
+
const stream = tapUsageEventStream(sourceStream, (usage) => {
|
|
291
|
+
recordMessageMetric({
|
|
292
|
+
body: anthropicRequest,
|
|
293
|
+
endpoint: '/v1/messages',
|
|
294
|
+
requestedModel: responseModel,
|
|
295
|
+
upstreamModel: kiloTarget,
|
|
296
|
+
provider: 'kilo',
|
|
297
|
+
accountLabel: 'kilo',
|
|
298
|
+
stream: true,
|
|
299
|
+
usage,
|
|
300
|
+
startTime,
|
|
301
|
+
status: 200
|
|
302
|
+
});
|
|
303
|
+
});
|
|
179
304
|
await pipeSSEStream(res, stream);
|
|
180
305
|
logger.response(200, { model: kiloTarget, duration: Date.now() - startTime });
|
|
181
306
|
}
|
|
@@ -184,6 +309,19 @@ async function _sendKilo(res, anthropicRequest, kiloTarget, responseModel, start
|
|
|
184
309
|
const response = await sendKiloMessage(anthropicRequest, kiloTarget);
|
|
185
310
|
const duration = Date.now() - startTime;
|
|
186
311
|
logger.response(200, { model: kiloTarget, tokens: response.usage?.output_tokens || 0, duration });
|
|
312
|
+
recordMessageMetric({
|
|
313
|
+
body: anthropicRequest,
|
|
314
|
+
endpoint: '/v1/messages',
|
|
315
|
+
requestedModel: responseModel,
|
|
316
|
+
upstreamModel: kiloTarget,
|
|
317
|
+
provider: 'kilo',
|
|
318
|
+
accountLabel: 'kilo',
|
|
319
|
+
stream: false,
|
|
320
|
+
usage: response.usage,
|
|
321
|
+
startTime,
|
|
322
|
+
status: 200,
|
|
323
|
+
duration
|
|
324
|
+
});
|
|
187
325
|
res.json({
|
|
188
326
|
id: response.id || undefined,
|
|
189
327
|
type: 'message',
|
|
@@ -200,4 +338,36 @@ function sleep(ms) {
|
|
|
200
338
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
201
339
|
}
|
|
202
340
|
|
|
341
|
+
function recordMessageMetric(options) {
|
|
342
|
+
const body = options.body || {};
|
|
343
|
+
recordUsageEventSafe({
|
|
344
|
+
startedAt: new Date(options.startTime || Date.now()).toISOString(),
|
|
345
|
+
completedAt: new Date().toISOString(),
|
|
346
|
+
endpoint: options.endpoint,
|
|
347
|
+
requestedModel: options.requestedModel,
|
|
348
|
+
upstreamModel: options.upstreamModel,
|
|
349
|
+
accountLabel: options.accountLabel,
|
|
350
|
+
provider: options.provider,
|
|
351
|
+
stream: options.stream ?? body.stream !== false,
|
|
352
|
+
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
353
|
+
toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
|
|
354
|
+
usage: options.usage,
|
|
355
|
+
status: options.status,
|
|
356
|
+
errorType: options.errorType,
|
|
357
|
+
durationMs: options.duration ?? Date.now() - (options.startTime || Date.now())
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function classifyMetricError(error) {
|
|
362
|
+
const message = error?.message || '';
|
|
363
|
+
if (message.startsWith('RATE_LIMITED:') || message.startsWith('RESOURCE_EXHAUSTED:')) return 'rate_limited';
|
|
364
|
+
if (message.includes('AUTH_EXPIRED')) return 'auth_expired';
|
|
365
|
+
if (message.startsWith('CLOUDFLARE_BLOCKED:')) return 'cloudflare_blocked';
|
|
366
|
+
if (message.startsWith('FORBIDDEN:')) return 'forbidden';
|
|
367
|
+
if (message.startsWith('INVALID_REQUEST:')) return 'invalid_request';
|
|
368
|
+
if (message.startsWith('KILO_API_ERROR:')) return 'kilo_api_error';
|
|
369
|
+
if (message.startsWith('API_ERROR:')) return 'api_error';
|
|
370
|
+
return 'unknown_error';
|
|
371
|
+
}
|
|
372
|
+
|
|
203
373
|
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
|
+
};
|
|
@@ -5,14 +5,40 @@
|
|
|
5
5
|
* POST /settings/haiku-model
|
|
6
6
|
* GET /settings/account-strategy
|
|
7
7
|
* POST /settings/account-strategy
|
|
8
|
+
* GET /settings/claude-proxy
|
|
9
|
+
* POST /settings/claude-proxy
|
|
8
10
|
* GET /settings/kilo-models
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { getServerSettings, isMultiAccountRotationEnabled, setServerSettings } from '../server-settings.js';
|
|
12
14
|
import { fetchFreeModels } from '../kilo-models.js';
|
|
13
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
CLAUDE_MODEL_ALIASES,
|
|
17
|
+
DEFAULT_MODEL_MAPPINGS,
|
|
18
|
+
DEFAULT_REASONING_MAPPINGS,
|
|
19
|
+
OPENAI_MODEL_OPTIONS,
|
|
20
|
+
REASONING_LEVEL_OPTIONS,
|
|
21
|
+
isKiloEnabled,
|
|
22
|
+
normalizeModelMappings,
|
|
23
|
+
normalizeReasoningMappings
|
|
24
|
+
} from '../model-mapper.js';
|
|
14
25
|
|
|
15
26
|
const VALID_STRATEGIES = ['sticky', 'round-robin'];
|
|
27
|
+
const VALID_OPENAI_MODEL_IDS = new Set(OPENAI_MODEL_OPTIONS.map((model) => model.id));
|
|
28
|
+
const VALID_REASONING_LEVEL_IDS = new Set(REASONING_LEVEL_OPTIONS.map((level) => level.id));
|
|
29
|
+
|
|
30
|
+
function modelMappingsPayload(modelMappings, reasoningMappings) {
|
|
31
|
+
return {
|
|
32
|
+
success: true,
|
|
33
|
+
aliases: CLAUDE_MODEL_ALIASES,
|
|
34
|
+
models: OPENAI_MODEL_OPTIONS,
|
|
35
|
+
reasoningLevels: REASONING_LEVEL_OPTIONS,
|
|
36
|
+
defaults: DEFAULT_MODEL_MAPPINGS,
|
|
37
|
+
reasoningDefaults: DEFAULT_REASONING_MAPPINGS,
|
|
38
|
+
modelMappings: normalizeModelMappings(modelMappings),
|
|
39
|
+
reasoningMappings: normalizeReasoningMappings(reasoningMappings)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
16
42
|
|
|
17
43
|
/**
|
|
18
44
|
* GET /settings/haiku-model
|
|
@@ -99,6 +125,89 @@ export async function handleGetKiloModels(req, res) {
|
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
127
|
|
|
128
|
+
/**
|
|
129
|
+
* GET /settings/model-mappings
|
|
130
|
+
* Returns editable Claude alias -> upstream GPT model mappings.
|
|
131
|
+
*/
|
|
132
|
+
export function handleGetModelMappings(req, res) {
|
|
133
|
+
const settings = getServerSettings();
|
|
134
|
+
res.json(modelMappingsPayload(settings.modelMappings, settings.reasoningMappings));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* POST /settings/model-mappings
|
|
139
|
+
* Updates one or more Claude alias mappings.
|
|
140
|
+
*/
|
|
141
|
+
export function handleSetModelMappings(req, res) {
|
|
142
|
+
const { modelMappings, reasoningMappings } = req.body || {};
|
|
143
|
+
const hasModelMappings = modelMappings !== undefined;
|
|
144
|
+
const hasReasoningMappings = reasoningMappings !== undefined;
|
|
145
|
+
|
|
146
|
+
if (!hasModelMappings && !hasReasoningMappings) {
|
|
147
|
+
return res.status(400).json({
|
|
148
|
+
success: false,
|
|
149
|
+
error: 'modelMappings or reasoningMappings is required'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (hasModelMappings && (!modelMappings || typeof modelMappings !== 'object' || Array.isArray(modelMappings))) {
|
|
154
|
+
return res.status(400).json({
|
|
155
|
+
success: false,
|
|
156
|
+
error: 'modelMappings is required and must be an object'
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (hasReasoningMappings && (!reasoningMappings || typeof reasoningMappings !== 'object' || Array.isArray(reasoningMappings))) {
|
|
161
|
+
return res.status(400).json({
|
|
162
|
+
success: false,
|
|
163
|
+
error: 'reasoningMappings must be an object'
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const modelAliases = hasModelMappings ? Object.keys(modelMappings) : [];
|
|
168
|
+
const reasoningAliases = hasReasoningMappings ? Object.keys(reasoningMappings) : [];
|
|
169
|
+
const unknownAliases = [...modelAliases, ...reasoningAliases].filter((alias) => !CLAUDE_MODEL_ALIASES.includes(alias));
|
|
170
|
+
if (unknownAliases.length > 0) {
|
|
171
|
+
return res.status(400).json({
|
|
172
|
+
success: false,
|
|
173
|
+
error: `Invalid model mapping alias. Use one of: ${CLAUDE_MODEL_ALIASES.join(', ')}`
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const [alias, model] of Object.entries(modelMappings || {})) {
|
|
178
|
+
if (typeof model !== 'string' || !VALID_OPENAI_MODEL_IDS.has(model)) {
|
|
179
|
+
return res.status(400).json({
|
|
180
|
+
success: false,
|
|
181
|
+
error: `Invalid model for ${alias}. Use one of: ${OPENAI_MODEL_OPTIONS.map((option) => option.id).join(', ')}`
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [alias, reasoning] of Object.entries(reasoningMappings || {})) {
|
|
187
|
+
if (typeof reasoning !== 'string' || !VALID_REASONING_LEVEL_IDS.has(reasoning)) {
|
|
188
|
+
return res.status(400).json({
|
|
189
|
+
success: false,
|
|
190
|
+
error: `Invalid reasoning level for ${alias}. Use one of: ${REASONING_LEVEL_OPTIONS.map((option) => option.id).join(', ')}`
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const settings = getServerSettings();
|
|
196
|
+
const nextMappings = {
|
|
197
|
+
...normalizeModelMappings(settings.modelMappings),
|
|
198
|
+
...(modelMappings || {})
|
|
199
|
+
};
|
|
200
|
+
const nextReasoningMappings = {
|
|
201
|
+
...normalizeReasoningMappings(settings.reasoningMappings),
|
|
202
|
+
...(reasoningMappings || {})
|
|
203
|
+
};
|
|
204
|
+
const nextSettings = setServerSettings({
|
|
205
|
+
modelMappings: nextMappings,
|
|
206
|
+
reasoningMappings: nextReasoningMappings
|
|
207
|
+
});
|
|
208
|
+
res.json(modelMappingsPayload(nextSettings.modelMappings, nextSettings.reasoningMappings));
|
|
209
|
+
}
|
|
210
|
+
|
|
102
211
|
/**
|
|
103
212
|
* GET /settings/account-strategy
|
|
104
213
|
* Returns the current account selection strategy.
|
|
@@ -134,10 +243,47 @@ export function handleSetAccountStrategy(req, res) {
|
|
|
134
243
|
});
|
|
135
244
|
}
|
|
136
245
|
|
|
246
|
+
/**
|
|
247
|
+
* GET /settings/claude-proxy
|
|
248
|
+
* Returns Claude proxy configuration preferences.
|
|
249
|
+
*/
|
|
250
|
+
export function handleGetClaudeProxySetting(req, res) {
|
|
251
|
+
const settings = getServerSettings();
|
|
252
|
+
res.json({
|
|
253
|
+
success: true,
|
|
254
|
+
configureClaudeOnStartup: settings.configureClaudeOnStartup === true
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* POST /settings/claude-proxy
|
|
260
|
+
* Updates Claude proxy configuration preferences.
|
|
261
|
+
*/
|
|
262
|
+
export function handleSetClaudeProxySetting(req, res) {
|
|
263
|
+
const { configureClaudeOnStartup } = req.body || {};
|
|
264
|
+
|
|
265
|
+
if (typeof configureClaudeOnStartup !== 'boolean') {
|
|
266
|
+
return res.status(400).json({
|
|
267
|
+
success: false,
|
|
268
|
+
error: 'configureClaudeOnStartup is required and must be a boolean'
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const settings = setServerSettings({ configureClaudeOnStartup });
|
|
273
|
+
res.json({
|
|
274
|
+
success: true,
|
|
275
|
+
configureClaudeOnStartup: settings.configureClaudeOnStartup === true
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
137
279
|
export default {
|
|
138
280
|
handleGetHaikuModel,
|
|
139
281
|
handleSetHaikuModel,
|
|
140
282
|
handleGetKiloModels,
|
|
283
|
+
handleGetModelMappings,
|
|
284
|
+
handleSetModelMappings,
|
|
141
285
|
handleGetAccountStrategy,
|
|
142
|
-
handleSetAccountStrategy
|
|
286
|
+
handleSetAccountStrategy,
|
|
287
|
+
handleGetClaudeProxySetting,
|
|
288
|
+
handleSetClaudeProxySetting
|
|
143
289
|
};
|
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;
|