@pikoloo/codex-proxy 1.0.6
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/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/cli.js +118 -0
- package/docs/ACCOUNTS.md +202 -0
- package/docs/API.md +289 -0
- package/docs/ARCHITECTURE.md +129 -0
- package/docs/CLAUDE_INTEGRATION.md +163 -0
- package/docs/OAUTH.md +85 -0
- package/docs/OPENCLAW.md +34 -0
- package/docs/legal.md +11 -0
- package/images/dashboard-screenshot.png +0 -0
- package/images/demo-screenshot.png +0 -0
- package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
- package/package.json +61 -0
- package/public/css/style.css +1502 -0
- package/public/index.html +827 -0
- package/public/js/app.js +601 -0
- package/src/account-manager.js +528 -0
- package/src/account-rotation/index.js +93 -0
- package/src/account-rotation/rate-limits.js +293 -0
- package/src/account-rotation/strategies/base-strategy.js +48 -0
- package/src/account-rotation/strategies/index.js +31 -0
- package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
- package/src/account-rotation/strategies/sticky-strategy.js +97 -0
- package/src/claude-config.js +153 -0
- package/src/cli/accounts.js +557 -0
- package/src/direct-api.js +164 -0
- package/src/format-converter.js +420 -0
- package/src/index.js +46 -0
- package/src/kilo-api.js +68 -0
- package/src/kilo-format-converter.js +285 -0
- package/src/kilo-models.js +103 -0
- package/src/kilo-streamer.js +243 -0
- package/src/middleware/credentials.js +116 -0
- package/src/middleware/sse.js +96 -0
- package/src/model-api.js +189 -0
- package/src/model-mapper.js +157 -0
- package/src/oauth.js +666 -0
- package/src/response-streamer.js +409 -0
- package/src/routes/accounts-route.js +332 -0
- package/src/routes/api-routes.js +98 -0
- package/src/routes/chat-route.js +229 -0
- package/src/routes/claude-config-route.js +121 -0
- package/src/routes/logs-route.js +43 -0
- package/src/routes/messages-route.js +203 -0
- package/src/routes/models-route.js +119 -0
- package/src/routes/settings-route.js +143 -0
- package/src/security.js +142 -0
- package/src/server-settings.js +56 -0
- package/src/server.js +58 -0
- package/src/signature-cache.js +106 -0
- package/src/thinking-utils.js +312 -0
- package/src/utils/logger.js +156 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Config Route
|
|
3
|
+
* Handles Claude CLI configuration endpoints:
|
|
4
|
+
* GET /claude/config
|
|
5
|
+
* POST /claude/config/proxy
|
|
6
|
+
* POST /claude/config/direct
|
|
7
|
+
* POST /claude/config/set
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
readClaudeConfig,
|
|
12
|
+
setProxyMode,
|
|
13
|
+
setDirectMode,
|
|
14
|
+
setApiEndpoint,
|
|
15
|
+
getClaudeConfigPath
|
|
16
|
+
} from '../claude-config.js';
|
|
17
|
+
import { isAllowedApiEndpoint, redactSensitiveConfig } from '../security.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* GET /claude/config
|
|
21
|
+
* Returns the current Claude CLI configuration.
|
|
22
|
+
*/
|
|
23
|
+
export async function handleGetClaudeConfig(req, res) {
|
|
24
|
+
try {
|
|
25
|
+
const config = await readClaudeConfig();
|
|
26
|
+
const configPath = getClaudeConfigPath();
|
|
27
|
+
res.json({ success: true, configPath, config: redactSensitiveConfig(config) });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
res.status(500).json({ success: false, error: error.message });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* POST /claude/config/proxy
|
|
35
|
+
* Configures Claude CLI to use this proxy server.
|
|
36
|
+
*/
|
|
37
|
+
export async function handleSetProxyMode(req, res, { port }) {
|
|
38
|
+
try {
|
|
39
|
+
const proxyUrl = `http://localhost:${port}`;
|
|
40
|
+
const models = {
|
|
41
|
+
default: 'claude-sonnet-4-6',
|
|
42
|
+
opus: 'claude-opus-4-6',
|
|
43
|
+
sonnet: 'claude-sonnet-4-6',
|
|
44
|
+
haiku: 'claude-haiku-4-5'
|
|
45
|
+
};
|
|
46
|
+
const config = await setProxyMode(proxyUrl, models);
|
|
47
|
+
res.json({
|
|
48
|
+
success: true,
|
|
49
|
+
message: `Claude CLI configured to use proxy at ${proxyUrl}`,
|
|
50
|
+
config: redactSensitiveConfig(config)
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
res.status(500).json({ success: false, error: error.message });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* POST /claude/config/direct
|
|
59
|
+
* Configures Claude CLI to use the Anthropic API directly.
|
|
60
|
+
*/
|
|
61
|
+
export async function handleSetDirectMode(req, res) {
|
|
62
|
+
const { apiKey } = req.body || {};
|
|
63
|
+
if (!apiKey) {
|
|
64
|
+
return res.status(400).json({ success: false, error: 'API key required' });
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const config = await setDirectMode(apiKey);
|
|
68
|
+
res.json({
|
|
69
|
+
success: true,
|
|
70
|
+
message: 'Claude CLI configured to use direct Anthropic API',
|
|
71
|
+
config: redactSensitiveConfig(config)
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
res.status(500).json({ success: false, error: error.message });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function handleSetClaudeApiEndpoint(req, res) {
|
|
79
|
+
const { apiUrl, apiKey } = req.body || {};
|
|
80
|
+
|
|
81
|
+
if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
|
|
82
|
+
return res.status(400).json({ success: false, error: 'apiUrl is required' });
|
|
83
|
+
}
|
|
84
|
+
if (typeof apiKey !== 'string' || !apiKey.trim()) {
|
|
85
|
+
return res.status(400).json({ success: false, error: 'apiKey is required' });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(apiUrl);
|
|
91
|
+
} catch {
|
|
92
|
+
return res.status(400).json({ success: false, error: 'apiUrl must be a valid URL' });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const normalizedApiUrl = parsed.toString().replace(/\/$/, '');
|
|
96
|
+
|
|
97
|
+
if (!isAllowedApiEndpoint(normalizedApiUrl)) {
|
|
98
|
+
return res.status(400).json({
|
|
99
|
+
success: false,
|
|
100
|
+
error: 'apiUrl must be a loopback URL unless CODEX_CLAUDE_PROXY_ALLOW_EXTERNAL_ENDPOINTS=true is set'
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const config = await setApiEndpoint({ apiUrl: normalizedApiUrl, apiKey });
|
|
106
|
+
res.json({
|
|
107
|
+
success: true,
|
|
108
|
+
message: 'Claude CLI API endpoint updated',
|
|
109
|
+
config: redactSensitiveConfig(config)
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
res.status(500).json({ success: false, error: error.message });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default {
|
|
117
|
+
handleGetClaudeConfig,
|
|
118
|
+
handleSetProxyMode,
|
|
119
|
+
handleSetDirectMode,
|
|
120
|
+
handleSetClaudeApiEndpoint
|
|
121
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logs Route
|
|
3
|
+
* Handles log retrieval and live streaming:
|
|
4
|
+
* GET /api/logs
|
|
5
|
+
* GET /api/logs/stream
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from '../utils/logger.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/logs
|
|
12
|
+
* Returns the in-memory log history as JSON.
|
|
13
|
+
*/
|
|
14
|
+
export function handleGetLogs(req, res) {
|
|
15
|
+
res.json({ status: 'ok', logs: logger.getHistory() });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* GET /api/logs/stream
|
|
20
|
+
* Streams live log events as Server-Sent Events.
|
|
21
|
+
* Pass ?history=true to replay existing log history before streaming live events.
|
|
22
|
+
*/
|
|
23
|
+
export function handleStreamLogs(req, res) {
|
|
24
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
25
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
26
|
+
res.setHeader('Connection', 'keep-alive');
|
|
27
|
+
|
|
28
|
+
const sendLog = (log) => {
|
|
29
|
+
res.write(`data: ${JSON.stringify(log)}\n\n`);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (req.query.history === 'true') {
|
|
33
|
+
logger.getHistory().forEach(sendLog);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
logger.on('log', sendLog);
|
|
37
|
+
|
|
38
|
+
req.on('close', () => {
|
|
39
|
+
logger.off('log', sendLog);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default { handleGetLogs, handleStreamLogs };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { sendMessageStream, sendMessage } from '../direct-api.js';
|
|
2
|
+
import { sendKiloMessageStream, sendKiloMessage } from '../kilo-api.js';
|
|
3
|
+
import { DEFAULT_OPENAI_MODEL, isKiloEnabled, resolveModelRouting } from '../model-mapper.js';
|
|
4
|
+
import { sendAuthError, getCredentialsOrError, getCredentialsForAccount } from '../middleware/credentials.js';
|
|
5
|
+
import { initSSEResponse, pipeSSEStream, handleStreamError } from '../middleware/sse.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
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';
|
|
10
|
+
|
|
11
|
+
const MAX_RETRIES = 5;
|
|
12
|
+
const MAX_WAIT_BEFORE_ERROR_MS = 120000;
|
|
13
|
+
const SHORT_RATE_LIMIT_THRESHOLD_MS = 5000;
|
|
14
|
+
|
|
15
|
+
let accountRotator = null;
|
|
16
|
+
let currentStrategy = null;
|
|
17
|
+
|
|
18
|
+
function getAccountRotator() {
|
|
19
|
+
const settings = getServerSettings();
|
|
20
|
+
const strategy = settings.accountStrategy || 'sticky';
|
|
21
|
+
|
|
22
|
+
if (!accountRotator || currentStrategy !== strategy) {
|
|
23
|
+
accountRotator = new AccountRotator({
|
|
24
|
+
listAccounts,
|
|
25
|
+
save,
|
|
26
|
+
getActiveAccount
|
|
27
|
+
}, strategy);
|
|
28
|
+
currentStrategy = strategy;
|
|
29
|
+
logger.info(`[Messages] Account strategy: ${strategy}`);
|
|
30
|
+
}
|
|
31
|
+
return accountRotator;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function handleMessages(req, res) {
|
|
35
|
+
const startTime = Date.now();
|
|
36
|
+
const body = req.body;
|
|
37
|
+
const requestedModel = body.model || DEFAULT_OPENAI_MODEL;
|
|
38
|
+
const isStreaming = body.stream !== false;
|
|
39
|
+
|
|
40
|
+
const { isKilo, kiloTarget, upstreamModel } = resolveModelRouting(requestedModel);
|
|
41
|
+
|
|
42
|
+
if (isKilo) {
|
|
43
|
+
if (!isKiloEnabled()) {
|
|
44
|
+
return res.status(403).json({
|
|
45
|
+
type: 'error',
|
|
46
|
+
error: {
|
|
47
|
+
type: 'invalid_request_error',
|
|
48
|
+
code: 'kilo_disabled',
|
|
49
|
+
message: 'Kilo routing is disabled. Set CODEX_CLAUDE_PROXY_ENABLE_KILO=true to enable third-party Kilo model routing.'
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return isStreaming
|
|
55
|
+
? _streamKilo(res, { ...body, model: upstreamModel }, kiloTarget, requestedModel, startTime)
|
|
56
|
+
: _sendKilo(res, { ...body, model: upstreamModel }, kiloTarget, requestedModel, startTime);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isMultiAccountRotationEnabled()) {
|
|
60
|
+
const creds = await getCredentialsOrError();
|
|
61
|
+
if (!creds) {
|
|
62
|
+
return sendAuthError(res);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const anthropicRequest = { ...body, model: upstreamModel };
|
|
66
|
+
try {
|
|
67
|
+
if (isStreaming) {
|
|
68
|
+
await _streamDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, null);
|
|
69
|
+
} else {
|
|
70
|
+
await _sendDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, null);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return handleStreamError(res, error, requestedModel, startTime);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rotator = getAccountRotator();
|
|
79
|
+
const accountSnapshot = listAccounts();
|
|
80
|
+
|
|
81
|
+
if (accountSnapshot.total === 0) {
|
|
82
|
+
return sendAuthError(res, 'No active account with valid credentials. Add an account via /accounts/add');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
rotator.clearExpiredLimits();
|
|
86
|
+
|
|
87
|
+
const maxAttempts = Math.max(MAX_RETRIES, accountSnapshot.total);
|
|
88
|
+
|
|
89
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
90
|
+
if (rotator.isAllRateLimited(upstreamModel)) {
|
|
91
|
+
const minWait = rotator.getMinWaitTimeMs(upstreamModel);
|
|
92
|
+
|
|
93
|
+
if (minWait > MAX_WAIT_BEFORE_ERROR_MS) {
|
|
94
|
+
return handleStreamError(res, new Error(`RESOURCE_EXHAUSTED: All accounts rate-limited. Wait ${Math.round(minWait/1000)}s`), requestedModel, startTime);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
logger.info(`[Messages] All accounts rate-limited, waiting ${Math.round(minWait/1000)}s...`);
|
|
98
|
+
await sleep(minWait + 500);
|
|
99
|
+
rotator.clearExpiredLimits();
|
|
100
|
+
attempt--;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { account, waitMs } = rotator.selectAccount(upstreamModel);
|
|
105
|
+
|
|
106
|
+
if (!account) {
|
|
107
|
+
if (waitMs > 0) {
|
|
108
|
+
await sleep(waitMs);
|
|
109
|
+
attempt--;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
return sendAuthError(res, 'No available accounts');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const creds = await getCredentialsForAccount(account.email);
|
|
116
|
+
if (!creds) {
|
|
117
|
+
rotator.markInvalid(account.email, 'Failed to get credentials');
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const anthropicRequest = { ...body, model: upstreamModel };
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
if (isStreaming) {
|
|
125
|
+
await _streamDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, rotator);
|
|
126
|
+
} else {
|
|
127
|
+
await _sendDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, rotator);
|
|
128
|
+
}
|
|
129
|
+
rotator.notifySuccess(account, upstreamModel);
|
|
130
|
+
return;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error.message.startsWith('RATE_LIMITED:')) {
|
|
133
|
+
const parts = error.message.split(':');
|
|
134
|
+
const resetMs = parseInt(parts[1], 10);
|
|
135
|
+
const errorText = parts.slice(2).join(':');
|
|
136
|
+
|
|
137
|
+
rotator.notifyRateLimit(account, upstreamModel);
|
|
138
|
+
|
|
139
|
+
if (resetMs <= SHORT_RATE_LIMIT_THRESHOLD_MS) {
|
|
140
|
+
logger.info(`[Messages] Short rate limit on ${account.email}, waiting ${resetMs}ms...`);
|
|
141
|
+
await sleep(resetMs);
|
|
142
|
+
attempt--;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
logger.info(`[Messages] Rate limit on ${account.email}, switching account...`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (error.message.includes('AUTH_EXPIRED')) {
|
|
151
|
+
rotator.markInvalid(account.email, 'Auth expired');
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return handleStreamError(res, error, requestedModel, startTime);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return handleStreamError(res, new Error('Max retries exceeded'), requestedModel, startTime);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function _streamDirectWithRotation(res, anthropicRequest, creds, responseModel, startTime, rotator) {
|
|
163
|
+
initSSEResponse(res);
|
|
164
|
+
const stream = sendMessageStream(anthropicRequest, creds.accessToken, creds.accountId, rotator, creds.email);
|
|
165
|
+
await pipeSSEStream(res, stream);
|
|
166
|
+
logger.response(200, { model: anthropicRequest.model, duration: Date.now() - startTime });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function _sendDirectWithRotation(res, anthropicRequest, creds, responseModel, startTime, rotator) {
|
|
170
|
+
const response = await sendMessage(anthropicRequest, creds.accessToken, creds.accountId);
|
|
171
|
+
const duration = Date.now() - startTime;
|
|
172
|
+
logger.response(200, { model: anthropicRequest.model, tokens: response.usage?.output_tokens || 0, duration });
|
|
173
|
+
res.json({ ...response, model: responseModel });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function _streamKilo(res, anthropicRequest, kiloTarget, responseModel, startTime) {
|
|
177
|
+
initSSEResponse(res);
|
|
178
|
+
const stream = sendKiloMessageStream(anthropicRequest, kiloTarget);
|
|
179
|
+
await pipeSSEStream(res, stream);
|
|
180
|
+
logger.response(200, { model: kiloTarget, duration: Date.now() - startTime });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function _sendKilo(res, anthropicRequest, kiloTarget, responseModel, startTime) {
|
|
184
|
+
const response = await sendKiloMessage(anthropicRequest, kiloTarget);
|
|
185
|
+
const duration = Date.now() - startTime;
|
|
186
|
+
logger.response(200, { model: kiloTarget, tokens: response.usage?.output_tokens || 0, duration });
|
|
187
|
+
res.json({
|
|
188
|
+
id: response.id || undefined,
|
|
189
|
+
type: 'message',
|
|
190
|
+
role: 'assistant',
|
|
191
|
+
content: response.content,
|
|
192
|
+
model: responseModel,
|
|
193
|
+
stop_reason: response.stopReason,
|
|
194
|
+
stop_sequence: null,
|
|
195
|
+
usage: response.usage
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sleep(ms) {
|
|
200
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export default { handleMessages };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models Route
|
|
3
|
+
* Handles:
|
|
4
|
+
* GET /v1/models — OpenAI-compatible model list
|
|
5
|
+
* GET /accounts/models — Raw model list for the active/specified account
|
|
6
|
+
* GET /accounts/usage — Usage stats for the active/specified account
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fetchModels, fetchUsage } from '../model-api.js';
|
|
10
|
+
import { getActiveAccount, loadAccounts } from '../account-manager.js';
|
|
11
|
+
import { logger } from '../utils/logger.js';
|
|
12
|
+
import { getCredentialsOrError } from '../middleware/credentials.js';
|
|
13
|
+
import { DEFAULT_OPENAI_MODEL, DEFAULT_SMALL_OPENAI_MODEL, LATEST_CODEX_MODEL } from '../model-mapper.js';
|
|
14
|
+
|
|
15
|
+
const FALLBACK_MODELS = [
|
|
16
|
+
// OpenAI upstream models
|
|
17
|
+
{ id: DEFAULT_OPENAI_MODEL, object: 'model', owned_by: 'openai' },
|
|
18
|
+
{ id: 'gpt-5.4', object: 'model', owned_by: 'openai' },
|
|
19
|
+
{ id: DEFAULT_SMALL_OPENAI_MODEL, object: 'model', owned_by: 'openai' },
|
|
20
|
+
{ id: 'gpt-5.4-nano', object: 'model', owned_by: 'openai' },
|
|
21
|
+
{ id: LATEST_CODEX_MODEL, object: 'model', owned_by: 'openai' },
|
|
22
|
+
{ id: 'gpt-5.1-codex', object: 'model', owned_by: 'openai' },
|
|
23
|
+
{ id: 'gpt-5.2', object: 'model', owned_by: 'openai' },
|
|
24
|
+
// Current Claude 4.6 models
|
|
25
|
+
{ id: 'claude-opus-4-6', object: 'model', owned_by: 'anthropic' },
|
|
26
|
+
{ id: 'claude-sonnet-4-6', object: 'model', owned_by: 'anthropic' },
|
|
27
|
+
{ id: 'claude-haiku-4-5', object: 'model', owned_by: 'anthropic' },
|
|
28
|
+
// 1M context variants
|
|
29
|
+
{ id: 'claude-opus-4-6-1m', object: 'model', owned_by: 'anthropic' },
|
|
30
|
+
{ id: 'claude-sonnet-4-6-1m', object: 'model', owned_by: 'anthropic' },
|
|
31
|
+
// Legacy models (still supported)
|
|
32
|
+
{ id: 'claude-opus-4-5', object: 'model', owned_by: 'anthropic' },
|
|
33
|
+
{ id: 'claude-sonnet-4-5', object: 'model', owned_by: 'anthropic' }
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* GET /v1/models
|
|
38
|
+
* Returns an OpenAI-compatible model list. Falls back to a static list on error.
|
|
39
|
+
*/
|
|
40
|
+
export async function handleListModels(req, res) {
|
|
41
|
+
const creds = await getCredentialsOrError();
|
|
42
|
+
|
|
43
|
+
if (!creds) {
|
|
44
|
+
return res.json({ object: 'list', data: FALLBACK_MODELS });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const models = await fetchModels(creds.accessToken, creds.accountId);
|
|
49
|
+
const modelList = models.map(m => ({
|
|
50
|
+
id: m.id,
|
|
51
|
+
object: 'model',
|
|
52
|
+
created: Math.floor(Date.now() / 1000),
|
|
53
|
+
owned_by: 'openai',
|
|
54
|
+
description: m.description
|
|
55
|
+
}));
|
|
56
|
+
res.json({ object: 'list', data: modelList });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logger.error(`Failed to fetch models: ${error.message}`);
|
|
59
|
+
res.json({ object: 'list', data: FALLBACK_MODELS });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* GET /accounts/models
|
|
65
|
+
* Returns the raw model list for the active or specified account.
|
|
66
|
+
*/
|
|
67
|
+
export async function handleAccountModels(req, res) {
|
|
68
|
+
const account = _resolveAccount(req.query.email);
|
|
69
|
+
|
|
70
|
+
if (!account) {
|
|
71
|
+
return res.status(404).json({
|
|
72
|
+
success: false,
|
|
73
|
+
error: req.query.email ? `Account not found: ${req.query.email}` : 'No active account'
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const models = await fetchModels(account.accessToken, account.accountId);
|
|
79
|
+
res.json({ success: true, email: account.email, models });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.error(`Failed to fetch models: ${error.message}`);
|
|
82
|
+
res.status(500).json({ success: false, error: error.message });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* GET /accounts/usage
|
|
88
|
+
* Returns usage stats for the active or specified account.
|
|
89
|
+
*/
|
|
90
|
+
export async function handleAccountUsage(req, res) {
|
|
91
|
+
const account = _resolveAccount(req.query.email);
|
|
92
|
+
|
|
93
|
+
if (!account) {
|
|
94
|
+
return res.status(404).json({
|
|
95
|
+
success: false,
|
|
96
|
+
error: req.query.email ? `Account not found: ${req.query.email}` : 'No active account'
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const usage = await fetchUsage(account.accessToken, account.accountId);
|
|
102
|
+
res.json({ success: true, email: account.email, usage });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.error(`Failed to fetch usage: ${error.message}`);
|
|
105
|
+
res.status(500).json({ success: false, error: error.message });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function _resolveAccount(email) {
|
|
112
|
+
if (email) {
|
|
113
|
+
const data = loadAccounts();
|
|
114
|
+
return data.accounts.find(a => a.email === email) || null;
|
|
115
|
+
}
|
|
116
|
+
return getActiveAccount();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default { handleListModels, handleAccountModels, handleAccountUsage };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings Route
|
|
3
|
+
* Handles server settings endpoints:
|
|
4
|
+
* GET /settings/haiku-model
|
|
5
|
+
* POST /settings/haiku-model
|
|
6
|
+
* GET /settings/account-strategy
|
|
7
|
+
* POST /settings/account-strategy
|
|
8
|
+
* GET /settings/kilo-models
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getServerSettings, isMultiAccountRotationEnabled, setServerSettings } from '../server-settings.js';
|
|
12
|
+
import { fetchFreeModels } from '../kilo-models.js';
|
|
13
|
+
import { isKiloEnabled } from '../model-mapper.js';
|
|
14
|
+
|
|
15
|
+
const VALID_STRATEGIES = ['sticky', 'round-robin'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* GET /settings/haiku-model
|
|
19
|
+
* Returns the current explicit Kilo target selection.
|
|
20
|
+
*/
|
|
21
|
+
export function handleGetHaikuModel(req, res) {
|
|
22
|
+
const settings = getServerSettings();
|
|
23
|
+
res.json({
|
|
24
|
+
success: true,
|
|
25
|
+
haikuKiloModel: settings.haikuKiloModel,
|
|
26
|
+
kiloEnabled: isKiloEnabled()
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* POST /settings/haiku-model
|
|
32
|
+
* Updates the explicit Kilo target selection.
|
|
33
|
+
* Accepts any model ID string — the UI filters to only show free models.
|
|
34
|
+
*/
|
|
35
|
+
export async function handleSetHaikuModel(req, res) {
|
|
36
|
+
const { haikuKiloModel } = req.body || {};
|
|
37
|
+
|
|
38
|
+
if (!haikuKiloModel || typeof haikuKiloModel !== 'string') {
|
|
39
|
+
return res.status(400).json({
|
|
40
|
+
success: false,
|
|
41
|
+
error: 'haikuKiloModel is required and must be a string'
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!isKiloEnabled()) {
|
|
46
|
+
return res.status(403).json({
|
|
47
|
+
success: false,
|
|
48
|
+
error: 'Kilo routing is disabled. Set CODEX_CLAUDE_PROXY_ENABLE_KILO=true to enable third-party Kilo model routing.'
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validate against live free models from Kilo API
|
|
53
|
+
try {
|
|
54
|
+
const freeModels = await fetchFreeModels();
|
|
55
|
+
const validIds = freeModels.map(m => m.id);
|
|
56
|
+
if (!validIds.includes(haikuKiloModel)) {
|
|
57
|
+
return res.status(400).json({
|
|
58
|
+
success: false,
|
|
59
|
+
error: `Model "${haikuKiloModel}" is not a free model. Available: ${validIds.join(', ')}`
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// If API is unreachable, allow any value (user may know what they're doing)
|
|
64
|
+
console.warn(`[Settings] Could not validate model against Kilo API: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const settings = setServerSettings({ haikuKiloModel });
|
|
68
|
+
res.json({ success: true, haikuKiloModel: settings.haikuKiloModel, kiloEnabled: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* GET /settings/kilo-models
|
|
73
|
+
* Returns the list of free Kilo models from the API.
|
|
74
|
+
*/
|
|
75
|
+
export async function handleGetKiloModels(req, res) {
|
|
76
|
+
const settings = getServerSettings();
|
|
77
|
+
if (!isKiloEnabled()) {
|
|
78
|
+
return res.json({
|
|
79
|
+
success: true,
|
|
80
|
+
enabled: false,
|
|
81
|
+
models: [],
|
|
82
|
+
current: settings.haikuKiloModel
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const freeModels = await fetchFreeModels();
|
|
88
|
+
res.json({
|
|
89
|
+
success: true,
|
|
90
|
+
enabled: true,
|
|
91
|
+
models: freeModels,
|
|
92
|
+
current: settings.haikuKiloModel
|
|
93
|
+
});
|
|
94
|
+
} catch (error) {
|
|
95
|
+
res.status(500).json({
|
|
96
|
+
success: false,
|
|
97
|
+
error: `Failed to fetch models: ${error.message}`
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* GET /settings/account-strategy
|
|
104
|
+
* Returns the current account selection strategy.
|
|
105
|
+
*/
|
|
106
|
+
export function handleGetAccountStrategy(req, res) {
|
|
107
|
+
const settings = getServerSettings();
|
|
108
|
+
res.json({
|
|
109
|
+
success: true,
|
|
110
|
+
accountStrategy: settings.accountStrategy,
|
|
111
|
+
rotationEnabled: isMultiAccountRotationEnabled()
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* POST /settings/account-strategy
|
|
117
|
+
* Updates the account selection strategy.
|
|
118
|
+
*/
|
|
119
|
+
export function handleSetAccountStrategy(req, res) {
|
|
120
|
+
const { accountStrategy } = req.body || {};
|
|
121
|
+
|
|
122
|
+
if (!VALID_STRATEGIES.includes(accountStrategy)) {
|
|
123
|
+
return res.status(400).json({
|
|
124
|
+
success: false,
|
|
125
|
+
error: `Invalid accountStrategy. Use one of: ${VALID_STRATEGIES.join(', ')}`
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const settings = setServerSettings({ accountStrategy });
|
|
130
|
+
res.json({
|
|
131
|
+
success: true,
|
|
132
|
+
accountStrategy: settings.accountStrategy,
|
|
133
|
+
rotationEnabled: isMultiAccountRotationEnabled()
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default {
|
|
138
|
+
handleGetHaikuModel,
|
|
139
|
+
handleSetHaikuModel,
|
|
140
|
+
handleGetKiloModels,
|
|
141
|
+
handleGetAccountStrategy,
|
|
142
|
+
handleSetAccountStrategy
|
|
143
|
+
};
|