@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.
@@ -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 stream = sendMessageStream(anthropicRequest, creds.accessToken, creds.accountId, rotator, creds.email);
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 stream = sendKiloMessageStream(anthropicRequest, kiloTarget);
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 { isKiloEnabled } from '../model-mapper.js';
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;