@pikoloo/codex-proxy 1.0.6 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/docs/API.md +0 -15
- package/images/dashboard-screenshot.png +0 -0
- package/images/readme-cover.png +0 -0
- package/images/settings-screenshot.png +0 -0
- package/package.json +11 -3
- package/public/css/style.css +1097 -40
- package/public/index.html +439 -184
- package/public/js/app.js +384 -66
- package/src/account-rotation/index.js +64 -27
- package/src/format-converter.js +5 -1
- package/src/index.js +1 -1
- package/src/model-mapper.js +145 -22
- package/src/routes/api-routes.js +19 -3
- package/src/routes/chat-route.js +77 -4
- package/src/routes/messages-route.js +189 -21
- package/src/routes/metrics-route.js +43 -0
- package/src/routes/settings-route.js +127 -21
- package/src/security.js +2 -1
- package/src/server-settings.js +40 -5
- package/src/server.js +27 -2
- package/src/usage-metrics.js +472 -0
- package/src/utils/logger.js +14 -1
- package/images/demo-screenshot.png +0 -0
- package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
- package/src/account-rotation/strategies/base-strategy.js +0 -48
- package/src/account-rotation/strategies/index.js +0 -31
- package/src/account-rotation/strategies/round-robin-strategy.js +0 -42
- package/src/account-rotation/strategies/sticky-strategy.js +0 -97
|
@@ -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
|
-
|
|
11
|
+
const MAX_WAIT_BEFORE_ERROR_MS = 120000;
|
|
11
12
|
|
|
12
13
|
export class AccountRotator {
|
|
13
|
-
constructor(accountManager
|
|
14
|
+
constructor(accountManager) {
|
|
14
15
|
this.accountManager = accountManager;
|
|
15
|
-
this.strategy = createStrategy(strategyName);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
selectAccount(modelId
|
|
18
|
+
selectAccount(modelId) {
|
|
19
19
|
const { accounts } = this.accountManager.listAccounts();
|
|
20
|
-
return
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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,
|
package/src/format-converter.js
CHANGED
|
@@ -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
|
|
17
|
+
║ Codex Claude Proxy v1.1.0 ║
|
|
18
18
|
║ (Direct API Mode) ║
|
|
19
19
|
╠══════════════════════════════════════════════════════════════╣
|
|
20
20
|
║ Server: http://${HOST}:${PORT} ║
|
package/src/model-mapper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
package/src/routes/api-routes.js
CHANGED
|
@@ -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 {
|
|
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/
|
|
68
|
-
app.post('/settings/
|
|
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 };
|
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,
|
|
@@ -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,
|
|
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 };
|