@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.
@@ -4,20 +4,20 @@ import {
4
4
  clearInvalid,
5
5
  isAllRateLimited,
6
6
  getMinWaitTimeMs,
7
- clearExpiredLimits
7
+ clearExpiredLimits,
8
+ isAccountCoolingDown
8
9
  } from './rate-limits.js';
9
10
 
10
- import { createStrategy, STRATEGIES } from './strategies/index.js';
11
+ const MAX_WAIT_BEFORE_ERROR_MS = 120000;
11
12
 
12
13
  export class AccountRotator {
13
- constructor(accountManager, strategyName = 'sticky') {
14
+ constructor(accountManager) {
14
15
  this.accountManager = accountManager;
15
- this.strategy = createStrategy(strategyName);
16
16
  }
17
17
 
18
- selectAccount(modelId, options = {}) {
18
+ selectAccount(modelId) {
19
19
  const { accounts } = this.accountManager.listAccounts();
20
- return this.strategy.selectAccount(accounts, modelId, options);
20
+ return selectAccount(accounts, modelId);
21
21
  }
22
22
 
23
23
  markRateLimited(email, resetMs, modelId) {
@@ -48,23 +48,11 @@ export class AccountRotator {
48
48
  return getMinWaitTimeMs(accounts, modelId);
49
49
  }
50
50
 
51
- notifySuccess(account, modelId) {
52
- if (this.strategy.notifySuccess) {
53
- this.strategy.notifySuccess(account, modelId);
54
- }
55
- }
51
+ notifySuccess(account, modelId) {}
56
52
 
57
- notifyRateLimit(account, modelId) {
58
- if (this.strategy.notifyRateLimit) {
59
- this.strategy.notifyRateLimit(account, modelId);
60
- }
61
- }
53
+ notifyRateLimit(account, modelId) {}
62
54
 
63
- notifyFailure(account, modelId) {
64
- if (this.strategy.notifyFailure) {
65
- this.strategy.notifyFailure(account, modelId);
66
- }
67
- }
55
+ notifyFailure(account, modelId) {}
68
56
 
69
57
  clearExpiredLimits() {
70
58
  const { accounts } = this.accountManager.listAccounts();
@@ -72,18 +60,67 @@ export class AccountRotator {
72
60
  this.accountManager.save();
73
61
  }
74
62
 
75
- getStrategyName() {
76
- return this.strategy.name;
63
+ }
64
+
65
+ function selectAccount(accounts, modelId) {
66
+ if (!accounts || accounts.length === 0) {
67
+ return { account: null, index: 0, waitMs: 0 };
68
+ }
69
+
70
+ const activeIndex = accounts.findIndex((account) => account.isActive);
71
+ const startIndex = activeIndex >= 0 ? activeIndex : 0;
72
+
73
+ for (let offset = 0; offset < accounts.length; offset++) {
74
+ const index = (startIndex + offset) % accounts.length;
75
+ const account = accounts[index];
76
+
77
+ if (isAccountUsable(account, modelId)) {
78
+ account.lastUsed = Date.now();
79
+ return { account, index, waitMs: 0 };
80
+ }
77
81
  }
78
82
 
79
- getStrategyLabel() {
80
- return this.strategy.label;
83
+ const waitMs = getAccountWaitMs(accounts[startIndex], modelId);
84
+ if (waitMs > 0 && waitMs <= MAX_WAIT_BEFORE_ERROR_MS) {
85
+ return { account: null, index: startIndex, waitMs };
81
86
  }
87
+
88
+ return { account: null, index: startIndex, waitMs: 0 };
89
+ }
90
+
91
+ function isAccountUsable(account, modelId) {
92
+ if (!account) return false;
93
+ if (account.isInvalid) return false;
94
+ if (account.enabled === false) return false;
95
+ if (isAccountCoolingDown(account)) return false;
96
+
97
+ const waitMs = getModelRateLimitWaitMs(account, modelId);
98
+ return waitMs === 0;
99
+ }
100
+
101
+ function getAccountWaitMs(account, modelId) {
102
+ if (!account) return 0;
103
+ if (account.isInvalid) return 0;
104
+ if (account.enabled === false) return 0;
105
+ if (isAccountCoolingDown(account)) return 0;
106
+
107
+ return getModelRateLimitWaitMs(account, modelId);
108
+ }
109
+
110
+ function getModelRateLimitWaitMs(account, modelId) {
111
+ if (!modelId || !account?.modelRateLimits?.[modelId]) {
112
+ return 0;
113
+ }
114
+
115
+ const limit = account.modelRateLimits[modelId];
116
+ if (!limit?.isRateLimited || !limit.resetTime || limit.resetTime <= Date.now()) {
117
+ return 0;
118
+ }
119
+
120
+ return limit.resetTime - Date.now();
82
121
  }
83
122
 
84
123
  export {
85
- createStrategy,
86
- STRATEGIES,
87
124
  markRateLimited,
88
125
  markInvalid,
89
126
  clearInvalid,
@@ -64,7 +64,7 @@ function extractSystemPrompt(system) {
64
64
  * Convert Anthropic Messages API request to OpenAI Responses API format
65
65
  */
66
66
  export function convertAnthropicToResponsesAPI(anthropicRequest) {
67
- const { model, messages, system, tools, tool_choice } = anthropicRequest;
67
+ const { model, messages, system, tools, tool_choice, reasoningLevel } = anthropicRequest;
68
68
 
69
69
  // [CRITICAL] Clean cache_control from all messages FIRST
70
70
  // Claude Code CLI sends cache_control fields that the API rejects
@@ -89,6 +89,10 @@ export function convertAnthropicToResponsesAPI(anthropicRequest) {
89
89
  request.instructions = '';
90
90
  }
91
91
 
92
+ if (reasoningLevel) {
93
+ request.reasoning = { effort: reasoningLevel };
94
+ }
95
+
92
96
  return request;
93
97
  }
94
98
 
package/src/index.js CHANGED
@@ -14,7 +14,7 @@ startServer({ port: PORT, host: HOST });
14
14
 
15
15
  console.log(`
16
16
  ╔══════════════════════════════════════════════════════════════╗
17
- ║ Codex Claude Proxy v1.0.6
17
+ ║ Codex Claude Proxy v1.1.0 ║
18
18
  ║ (Direct API Mode) ║
19
19
  ╠══════════════════════════════════════════════════════════════╣
20
20
  ║ Server: http://${HOST}:${PORT} ║
@@ -9,6 +9,43 @@ const DEFAULT_OPENAI_MODEL = 'gpt-5.5';
9
9
  const DEFAULT_SMALL_OPENAI_MODEL = 'gpt-5.4-mini';
10
10
  const LATEST_CODEX_MODEL = 'gpt-5.3-codex';
11
11
  const KILO_ENABLED_ENV = 'CODEX_CLAUDE_PROXY_ENABLE_KILO';
12
+ const CLAUDE_MODEL_ALIASES = ['opus', 'sonnet', 'haiku'];
13
+ const OPENAI_MODEL_OPTIONS = [
14
+ { id: 'gpt-5.5', name: 'GPT-5.5' },
15
+ { id: 'gpt-5.5-2026-04-23', name: 'GPT-5.5 Snapshot' },
16
+ { id: 'gpt-5.4', name: 'GPT-5.4' },
17
+ { id: 'gpt-5.4-2026-03-05', name: 'GPT-5.4 Snapshot' },
18
+ { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini' },
19
+ { id: 'gpt-5.4-nano', name: 'GPT-5.4 Nano' },
20
+ { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' },
21
+ { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex' },
22
+ { id: 'gpt-5.2', name: 'GPT-5.2' },
23
+ { id: 'gpt-5.1', name: 'GPT-5.1' },
24
+ { id: 'gpt-5', name: 'GPT-5' },
25
+ { id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max' },
26
+ { id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex' },
27
+ { id: 'gpt-5-codex', name: 'GPT-5 Codex' },
28
+ { id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini' },
29
+ { id: 'gpt-5-codex-mini', name: 'GPT-5 Codex Mini' }
30
+ ];
31
+ const OPENAI_MODEL_IDS = new Set(OPENAI_MODEL_OPTIONS.map((model) => model.id));
32
+ const REASONING_LEVEL_OPTIONS = [
33
+ { id: 'low', name: 'Low', description: 'Fast responses with lighter reasoning' },
34
+ { id: 'medium', name: 'Medium', description: 'Balanced speed and reasoning depth' },
35
+ { id: 'high', name: 'High', description: 'Greater reasoning depth for complex work' },
36
+ { id: 'xhigh', name: 'Extra High', description: 'Extra high reasoning depth for complex work' }
37
+ ];
38
+ const REASONING_LEVEL_IDS = new Set(REASONING_LEVEL_OPTIONS.map((level) => level.id));
39
+ const DEFAULT_MODEL_MAPPINGS = {
40
+ opus: DEFAULT_OPENAI_MODEL,
41
+ sonnet: DEFAULT_OPENAI_MODEL,
42
+ haiku: DEFAULT_SMALL_OPENAI_MODEL
43
+ };
44
+ const DEFAULT_REASONING_MAPPINGS = {
45
+ opus: 'high',
46
+ sonnet: 'medium',
47
+ haiku: 'low'
48
+ };
12
49
 
13
50
  const CLAUDE_MODEL_MAP = {
14
51
  // Current Claude 4.6 models (Feb 2026)
@@ -64,41 +101,114 @@ const CLAUDE_MODEL_MAP = {
64
101
  'gpt-5-codex-mini': 'gpt-5-codex-mini'
65
102
  };
66
103
 
104
+ /**
105
+ * Normalizes persisted Claude alias mappings against supported GPT targets.
106
+ * @param {Record<string, string>} modelMappings
107
+ * @returns {{ opus: string, sonnet: string, haiku: string }}
108
+ */
109
+ export function normalizeModelMappings(modelMappings = {}) {
110
+ const normalized = { ...DEFAULT_MODEL_MAPPINGS };
111
+ if (!modelMappings || typeof modelMappings !== 'object' || Array.isArray(modelMappings)) {
112
+ return normalized;
113
+ }
114
+
115
+ for (const alias of CLAUDE_MODEL_ALIASES) {
116
+ const candidate = modelMappings[alias];
117
+ if (typeof candidate === 'string' && OPENAI_MODEL_IDS.has(candidate)) {
118
+ normalized[alias] = candidate;
119
+ }
120
+ }
121
+
122
+ return normalized;
123
+ }
124
+
125
+ /**
126
+ * Normalizes persisted Claude alias reasoning mappings against supported efforts.
127
+ * @param {Record<string, string>} reasoningMappings
128
+ * @returns {{ opus: string, sonnet: string, haiku: string }}
129
+ */
130
+ export function normalizeReasoningMappings(reasoningMappings = {}) {
131
+ const normalized = { ...DEFAULT_REASONING_MAPPINGS };
132
+ if (!reasoningMappings || typeof reasoningMappings !== 'object' || Array.isArray(reasoningMappings)) {
133
+ return normalized;
134
+ }
135
+
136
+ for (const alias of CLAUDE_MODEL_ALIASES) {
137
+ const candidate = reasoningMappings[alias];
138
+ if (typeof candidate === 'string' && REASONING_LEVEL_IDS.has(candidate)) {
139
+ normalized[alias] = candidate;
140
+ }
141
+ }
142
+
143
+ return normalized;
144
+ }
145
+
146
+ function inferClaudeAlias(modelLower) {
147
+ for (const alias of CLAUDE_MODEL_ALIASES) {
148
+ if (modelLower === alias || modelLower.includes(alias)) {
149
+ return alias;
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+
67
155
  /**
68
156
  * Maps a Claude/Anthropic model name to the upstream model identifier.
69
157
  * Falls back to the current OpenAI flagship model for unknown models.
70
158
  * @param {string} model
159
+ * @param {{ modelMappings?: Record<string, string> }} settings
71
160
  * @returns {string}
72
161
  */
73
- export function mapClaudeModel(model) {
162
+ export function mapClaudeModel(model, settings = getServerSettings()) {
74
163
  if (!model) return DEFAULT_OPENAI_MODEL;
75
164
 
76
- if (CLAUDE_MODEL_MAP[model]) {
77
- return CLAUDE_MODEL_MAP[model];
78
- }
79
-
80
- const modelLower = model.toLowerCase();
165
+ const modelLower = String(model).toLowerCase();
81
166
 
82
167
  if (modelLower.startsWith('gpt-')) {
83
168
  return modelLower;
84
169
  }
85
170
 
86
- if (modelLower.startsWith('claude-')) {
87
- const cleanModel = modelLower.replace(/^claude-/, '');
88
- if (cleanModel.includes('opus')) return DEFAULT_OPENAI_MODEL;
89
- if (cleanModel.includes('sonnet')) return DEFAULT_OPENAI_MODEL;
90
- if (cleanModel.includes('haiku')) return DEFAULT_SMALL_OPENAI_MODEL;
171
+ if (modelLower === 'codex') {
172
+ return LATEST_CODEX_MODEL;
91
173
  }
92
174
 
93
- for (const [key, value] of Object.entries(CLAUDE_MODEL_MAP)) {
94
- if (modelLower.includes(key.toLowerCase())) {
95
- return value;
96
- }
175
+ if (modelLower === 'kilo') {
176
+ return 'kilo';
177
+ }
178
+
179
+ const mappedAlias = inferClaudeAlias(modelLower);
180
+ if (mappedAlias) {
181
+ return normalizeModelMappings(settings?.modelMappings)[mappedAlias];
182
+ }
183
+
184
+ if (CLAUDE_MODEL_MAP[modelLower]) {
185
+ return CLAUDE_MODEL_MAP[modelLower];
97
186
  }
98
187
 
99
188
  return DEFAULT_OPENAI_MODEL;
100
189
  }
101
190
 
191
+ /**
192
+ * Resolves the configured reasoning effort for Claude aliases.
193
+ * Direct GPT, codex, kilo, and unknown model requests do not force a reasoning level.
194
+ * @param {string} model
195
+ * @param {{ reasoningMappings?: Record<string, string> }} settings
196
+ * @returns {string|null}
197
+ */
198
+ export function mapClaudeReasoningLevel(model, settings = getServerSettings()) {
199
+ if (!model) return null;
200
+
201
+ const modelLower = String(model).toLowerCase();
202
+ if (modelLower.startsWith('gpt-') || modelLower === 'codex' || modelLower === 'kilo') {
203
+ return null;
204
+ }
205
+
206
+ const mappedAlias = inferClaudeAlias(modelLower);
207
+ if (!mappedAlias) return null;
208
+
209
+ return normalizeReasoningMappings(settings?.reasoningMappings)[mappedAlias];
210
+ }
211
+
102
212
  /**
103
213
  * Returns true if the mapped model should be routed through Kilo.
104
214
  * @param {string} mappedModel
@@ -117,40 +227,53 @@ export function isKiloEnabled() {
117
227
  * The setting stores the full Kilo model ID (e.g. 'minimax/minimax-m2.5:free').
118
228
  * @returns {string}
119
229
  */
120
- export function resolveKiloModel() {
121
- const settings = getServerSettings();
230
+ export function resolveKiloModel(settings = getServerSettings()) {
122
231
  return settings.haikuKiloModel || 'minimax/minimax-m2.5:free';
123
232
  }
124
233
 
125
234
  /**
126
235
  * Resolves all model routing info from a requested model name.
127
236
  * @param {string} requestedModel
128
- * @returns {{ mappedModel: string, isKilo: boolean, kiloTarget: string|null, upstreamModel: string }}
237
+ * @returns {{ mappedModel: string, isKilo: boolean, kiloTarget: string|null, upstreamModel: string, reasoningLevel: string|null }}
129
238
  */
130
- export function resolveModelRouting(requestedModel) {
131
- const mappedModel = mapClaudeModel(requestedModel || DEFAULT_OPENAI_MODEL);
239
+ export function resolveModelRouting(requestedModel, settings = getServerSettings()) {
240
+ const mappedModel = mapClaudeModel(requestedModel || DEFAULT_OPENAI_MODEL, settings);
241
+ const reasoningLevel = mapClaudeReasoningLevel(requestedModel, settings);
132
242
  const isKilo = isKiloModel(mappedModel);
133
- const kiloTarget = isKilo ? resolveKiloModel() : null;
243
+ const kiloTarget = isKilo ? resolveKiloModel(settings) : null;
134
244
  const upstreamModel = isKilo ? kiloTarget : mappedModel;
135
- return { mappedModel, isKilo, kiloTarget, upstreamModel };
245
+ return { mappedModel, isKilo, kiloTarget, upstreamModel, reasoningLevel };
136
246
  }
137
247
 
138
248
  export {
139
249
  CLAUDE_MODEL_MAP,
140
250
  DEFAULT_OPENAI_MODEL,
141
251
  DEFAULT_SMALL_OPENAI_MODEL,
252
+ DEFAULT_MODEL_MAPPINGS,
253
+ DEFAULT_REASONING_MAPPINGS,
254
+ CLAUDE_MODEL_ALIASES,
255
+ OPENAI_MODEL_OPTIONS,
256
+ REASONING_LEVEL_OPTIONS,
142
257
  LATEST_CODEX_MODEL,
143
258
  KILO_ENABLED_ENV
144
259
  };
145
260
 
146
261
  export default {
147
262
  mapClaudeModel,
263
+ mapClaudeReasoningLevel,
148
264
  isKiloModel,
149
265
  resolveKiloModel,
150
266
  resolveModelRouting,
151
267
  CLAUDE_MODEL_MAP,
152
268
  DEFAULT_OPENAI_MODEL,
153
269
  DEFAULT_SMALL_OPENAI_MODEL,
270
+ DEFAULT_MODEL_MAPPINGS,
271
+ DEFAULT_REASONING_MAPPINGS,
272
+ CLAUDE_MODEL_ALIASES,
273
+ OPENAI_MODEL_OPTIONS,
274
+ REASONING_LEVEL_OPTIONS,
275
+ normalizeModelMappings,
276
+ normalizeReasoningMappings,
154
277
  LATEST_CODEX_MODEL,
155
278
  KILO_ENABLED_ENV,
156
279
  isKiloEnabled
@@ -15,8 +15,17 @@ import { getStatus, ACCOUNTS_FILE } from '../account-manager.js';
15
15
  import { handleMessages } from './messages-route.js';
16
16
  import { handleChatCompletion, handleCountTokens } from './chat-route.js';
17
17
  import { handleListModels, handleAccountModels, handleAccountUsage } from './models-route.js';
18
- import { handleGetHaikuModel, handleSetHaikuModel, handleGetKiloModels, handleGetAccountStrategy, handleSetAccountStrategy } from './settings-route.js';
18
+ import {
19
+ handleGetHaikuModel,
20
+ handleSetHaikuModel,
21
+ handleGetKiloModels,
22
+ handleGetModelMappings,
23
+ handleSetModelMappings,
24
+ handleGetClaudeProxySetting,
25
+ handleSetClaudeProxySetting
26
+ } from './settings-route.js';
19
27
  import { handleGetLogs, handleStreamLogs } from './logs-route.js';
28
+ import { handleGetMetricsRecent, handleGetMetricsStorage, handleGetMetricsSummary } from './metrics-route.js';
20
29
  import { handleGetClaudeConfig, handleSetProxyMode, handleSetDirectMode, handleSetClaudeApiEndpoint } from './claude-config-route.js';
21
30
  import {
22
31
  handleListAccounts,
@@ -64,8 +73,10 @@ export function registerApiRoutes(app, { port }) {
64
73
  app.get('/settings/haiku-model', handleGetHaikuModel);
65
74
  app.post('/settings/haiku-model', handleSetHaikuModel);
66
75
  app.get('/settings/kilo-models', handleGetKiloModels);
67
- app.get('/settings/account-strategy', handleGetAccountStrategy);
68
- app.post('/settings/account-strategy', handleSetAccountStrategy);
76
+ app.get('/settings/model-mappings', handleGetModelMappings);
77
+ app.post('/settings/model-mappings', handleSetModelMappings);
78
+ app.get('/settings/claude-proxy', handleGetClaudeProxySetting);
79
+ app.post('/settings/claude-proxy', handleSetClaudeProxySetting);
69
80
 
70
81
  // ─── Account Management ───────────────────────────────────────────────────
71
82
  app.get('/accounts', handleListAccounts);
@@ -93,6 +104,11 @@ export function registerApiRoutes(app, { port }) {
93
104
  // ─── Logs ──────────────────────────────────────────────────────────────────
94
105
  app.get('/api/logs', handleGetLogs);
95
106
  app.get('/api/logs/stream', handleStreamLogs);
107
+
108
+ // ─── Metrics ───────────────────────────────────────────────────────────────
109
+ app.get('/api/metrics/summary', handleGetMetricsSummary);
110
+ app.get('/api/metrics/recent', handleGetMetricsRecent);
111
+ app.get('/api/metrics/storage', handleGetMetricsStorage);
96
112
  }
97
113
 
98
114
  export default { registerApiRoutes };
@@ -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,
@@ -57,10 +77,31 @@ export async function handleChatCompletion(req, res) {
57
77
  : await sendMessage(anthropicRequest, creds.accessToken, creds.accountId);
58
78
 
59
79
  const duration = Date.now() - startTime;
60
- logger.response(200, { model: upstreamModel, tokens: response.usage?.output_tokens || 0, duration });
80
+ logger.response(200, { model: upstreamModel, usage: response.usage, 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 };