@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/bin/cli.js +118 -0
  4. package/docs/ACCOUNTS.md +202 -0
  5. package/docs/API.md +289 -0
  6. package/docs/ARCHITECTURE.md +129 -0
  7. package/docs/CLAUDE_INTEGRATION.md +163 -0
  8. package/docs/OAUTH.md +85 -0
  9. package/docs/OPENCLAW.md +34 -0
  10. package/docs/legal.md +11 -0
  11. package/images/dashboard-screenshot.png +0 -0
  12. package/images/demo-screenshot.png +0 -0
  13. package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
  14. package/package.json +61 -0
  15. package/public/css/style.css +1502 -0
  16. package/public/index.html +827 -0
  17. package/public/js/app.js +601 -0
  18. package/src/account-manager.js +528 -0
  19. package/src/account-rotation/index.js +93 -0
  20. package/src/account-rotation/rate-limits.js +293 -0
  21. package/src/account-rotation/strategies/base-strategy.js +48 -0
  22. package/src/account-rotation/strategies/index.js +31 -0
  23. package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
  24. package/src/account-rotation/strategies/sticky-strategy.js +97 -0
  25. package/src/claude-config.js +153 -0
  26. package/src/cli/accounts.js +557 -0
  27. package/src/direct-api.js +164 -0
  28. package/src/format-converter.js +420 -0
  29. package/src/index.js +46 -0
  30. package/src/kilo-api.js +68 -0
  31. package/src/kilo-format-converter.js +285 -0
  32. package/src/kilo-models.js +103 -0
  33. package/src/kilo-streamer.js +243 -0
  34. package/src/middleware/credentials.js +116 -0
  35. package/src/middleware/sse.js +96 -0
  36. package/src/model-api.js +189 -0
  37. package/src/model-mapper.js +157 -0
  38. package/src/oauth.js +666 -0
  39. package/src/response-streamer.js +409 -0
  40. package/src/routes/accounts-route.js +332 -0
  41. package/src/routes/api-routes.js +98 -0
  42. package/src/routes/chat-route.js +229 -0
  43. package/src/routes/claude-config-route.js +121 -0
  44. package/src/routes/logs-route.js +43 -0
  45. package/src/routes/messages-route.js +203 -0
  46. package/src/routes/models-route.js +119 -0
  47. package/src/routes/settings-route.js +143 -0
  48. package/src/security.js +142 -0
  49. package/src/server-settings.js +56 -0
  50. package/src/server.js +58 -0
  51. package/src/signature-cache.js +106 -0
  52. package/src/thinking-utils.js +312 -0
  53. 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
+ };