@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.
@@ -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, getActiveAccount, save } from '../account-manager.js';
9
- import { getServerSettings, isMultiAccountRotationEnabled } from '../server-settings.js';
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
- const settings = getServerSettings();
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
- getActiveAccount
27
- }, strategy);
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 stream = sendMessageStream(anthropicRequest, creds.accessToken, creds.accountId, rotator, creds.email);
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, tokens: response.usage?.output_tokens || 0, duration });
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 stream = sendKiloMessageStream(anthropicRequest, kiloTarget);
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, tokens: response.usage?.output_tokens || 0, duration });
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/account-strategy
7
- * POST /settings/account-strategy
6
+ * GET /settings/claude-proxy
7
+ * POST /settings/claude-proxy
8
8
  * GET /settings/kilo-models
9
9
  */
10
10
 
11
- import { getServerSettings, isMultiAccountRotationEnabled, setServerSettings } from '../server-settings.js';
11
+ import { getServerSettings, setServerSettings } from '../server-settings.js';
12
12
  import { fetchFreeModels } from '../kilo-models.js';
13
- import { isKiloEnabled } from '../model-mapper.js';
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 VALID_STRATEGIES = ['sticky', 'round-robin'];
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/account-strategy
104
- * Returns the current account selection strategy.
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 handleGetAccountStrategy(req, res) {
212
+ export function handleGetClaudeProxySetting(req, res) {
107
213
  const settings = getServerSettings();
108
214
  res.json({
109
215
  success: true,
110
- accountStrategy: settings.accountStrategy,
111
- rotationEnabled: isMultiAccountRotationEnabled()
216
+ configureClaudeOnStartup: settings.configureClaudeOnStartup === true
112
217
  });
113
218
  }
114
219
 
115
220
  /**
116
- * POST /settings/account-strategy
117
- * Updates the account selection strategy.
221
+ * POST /settings/claude-proxy
222
+ * Updates Claude proxy configuration preferences.
118
223
  */
119
- export function handleSetAccountStrategy(req, res) {
120
- const { accountStrategy } = req.body || {};
224
+ export function handleSetClaudeProxySetting(req, res) {
225
+ const { configureClaudeOnStartup } = req.body || {};
121
226
 
122
- if (!VALID_STRATEGIES.includes(accountStrategy)) {
227
+ if (typeof configureClaudeOnStartup !== 'boolean') {
123
228
  return res.status(400).json({
124
229
  success: false,
125
- error: `Invalid accountStrategy. Use one of: ${VALID_STRATEGIES.join(', ')}`
230
+ error: 'configureClaudeOnStartup is required and must be a boolean'
126
231
  });
127
232
  }
128
233
 
129
- const settings = setServerSettings({ accountStrategy });
234
+ const settings = setServerSettings({ configureClaudeOnStartup });
130
235
  res.json({
131
236
  success: true,
132
- accountStrategy: settings.accountStrategy,
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
- handleGetAccountStrategy,
142
- handleSetAccountStrategy
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;
@@ -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
- accountStrategy: 'sticky'
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 { ...DEFAULT_SETTINGS };
57
+ return normalizeSettings();
24
58
  }
25
59
 
26
60
  try {
27
61
  const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
28
- return { ...DEFAULT_SETTINGS, ...data };
62
+ return normalizeSettings(data);
29
63
  } catch (error) {
30
64
  console.error('[ServerSettings] Failed to read settings:', error.message);
31
- return { ...DEFAULT_SETTINGS };
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
- return app.listen(port, host);
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 };