claude-codex-proxy 1.0.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/bin/cli.js +113 -0
  4. package/docs/ACCOUNTS.md +202 -0
  5. package/docs/API.md +244 -0
  6. package/docs/ARCHITECTURE.md +119 -0
  7. package/docs/CLAUDE_INTEGRATION.md +163 -0
  8. package/docs/OAUTH.md +83 -0
  9. package/docs/OPENCLAW.md +33 -0
  10. package/docs/legal.md +9 -0
  11. package/images/demo-screenshot.png +0 -0
  12. package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
  13. package/package.json +56 -0
  14. package/public/css/style.css +791 -0
  15. package/public/index.html +838 -0
  16. package/public/js/app.js +619 -0
  17. package/src/account-manager.js +526 -0
  18. package/src/account-rotation/index.js +93 -0
  19. package/src/account-rotation/rate-limits.js +293 -0
  20. package/src/account-rotation/strategies/base-strategy.js +48 -0
  21. package/src/account-rotation/strategies/index.js +31 -0
  22. package/src/account-rotation/strategies/round-robin-strategy.js +42 -0
  23. package/src/account-rotation/strategies/sticky-strategy.js +97 -0
  24. package/src/claude-config.js +154 -0
  25. package/src/cli/accounts.js +551 -0
  26. package/src/direct-api.js +214 -0
  27. package/src/format-converter.js +563 -0
  28. package/src/index.js +45 -0
  29. package/src/middleware/credentials.js +116 -0
  30. package/src/middleware/sse.js +130 -0
  31. package/src/model-api.js +189 -0
  32. package/src/model-mapper.js +88 -0
  33. package/src/oauth.js +623 -0
  34. package/src/response-streamer.js +469 -0
  35. package/src/routes/accounts-route.js +296 -0
  36. package/src/routes/api-routes.js +102 -0
  37. package/src/routes/chat-route.js +239 -0
  38. package/src/routes/claude-config-route.js +114 -0
  39. package/src/routes/logs-route.js +43 -0
  40. package/src/routes/messages-route.js +164 -0
  41. package/src/routes/models-route.js +115 -0
  42. package/src/routes/settings-route.js +154 -0
  43. package/src/server-settings.js +70 -0
  44. package/src/server.js +57 -0
  45. package/src/signature-cache.js +106 -0
  46. package/src/thinking-utils.js +312 -0
  47. package/src/utils/logger.js +170 -0
@@ -0,0 +1,114 @@
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
+
18
+ /**
19
+ * GET /claude/config
20
+ * Returns the current Claude CLI configuration.
21
+ */
22
+ export async function handleGetClaudeConfig(req, res) {
23
+ try {
24
+ const config = await readClaudeConfig();
25
+ const configPath = getClaudeConfigPath();
26
+ res.json({ success: true, configPath, config });
27
+ } catch (error) {
28
+ res.status(500).json({ success: false, error: error.message });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * POST /claude/config/proxy
34
+ * Configures Claude CLI to use this proxy server.
35
+ */
36
+ export async function handleSetProxyMode(req, res, { port }) {
37
+ try {
38
+ const proxyUrl = `http://localhost:${port}`;
39
+ const models = {
40
+ opus: 'claude-opus',
41
+ sonnet: 'claude-sonnet',
42
+ haiku: 'claude-haiku'
43
+ };
44
+ const config = await setProxyMode(proxyUrl, models);
45
+ res.json({
46
+ success: true,
47
+ message: `Claude CLI configured to use proxy at ${proxyUrl}`,
48
+ config
49
+ });
50
+ } catch (error) {
51
+ res.status(500).json({ success: false, error: error.message });
52
+ }
53
+ }
54
+
55
+ /**
56
+ * POST /claude/config/direct
57
+ * Configures Claude CLI to use the Anthropic API directly.
58
+ */
59
+ export async function handleSetDirectMode(req, res) {
60
+ const { apiKey } = req.body || {};
61
+ if (!apiKey) {
62
+ return res.status(400).json({ success: false, error: 'API key required' });
63
+ }
64
+ try {
65
+ const config = await setDirectMode(apiKey);
66
+ res.json({
67
+ success: true,
68
+ message: 'Claude CLI configured to use direct Anthropic API',
69
+ config
70
+ });
71
+ } catch (error) {
72
+ res.status(500).json({ success: false, error: error.message });
73
+ }
74
+ }
75
+
76
+ export async function handleSetClaudeApiEndpoint(req, res) {
77
+ const { apiUrl, apiKey } = req.body || {};
78
+
79
+ if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
80
+ return res.status(400).json({ success: false, error: 'apiUrl is required' });
81
+ }
82
+ if (typeof apiKey !== 'string' || !apiKey.trim()) {
83
+ return res.status(400).json({ success: false, error: 'apiKey is required' });
84
+ }
85
+
86
+ let parsed;
87
+ try {
88
+ parsed = new URL(apiUrl);
89
+ } catch {
90
+ return res.status(400).json({ success: false, error: 'apiUrl must be a valid URL' });
91
+ }
92
+
93
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
94
+ return res.status(400).json({ success: false, error: 'apiUrl must use http or https' });
95
+ }
96
+
97
+ try {
98
+ const config = await setApiEndpoint({ apiUrl: parsed.toString().replace(/\/$/, ''), apiKey });
99
+ res.json({
100
+ success: true,
101
+ message: 'Claude CLI API endpoint updated',
102
+ config
103
+ });
104
+ } catch (error) {
105
+ res.status(500).json({ success: false, error: error.message });
106
+ }
107
+ }
108
+
109
+ export default {
110
+ handleGetClaudeConfig,
111
+ handleSetProxyMode,
112
+ handleSetDirectMode,
113
+ handleSetClaudeApiEndpoint
114
+ };
@@ -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,164 @@
1
+ import { createMessageStream, sendMessage } from '../direct-api.js';
2
+ import { resolveModelRouting } from '../model-mapper.js';
3
+ import { sendAuthError, getCredentialsForAccount } from '../middleware/credentials.js';
4
+ import { initSSEResponse, pipeSSEStream, handleStreamError } from '../middleware/sse.js';
5
+ import { logger } from '../utils/logger.js';
6
+ import { AccountRotator } from '../account-rotation/index.js';
7
+ import { listAccounts, getActiveAccount, save } from '../account-manager.js';
8
+ import { getServerSettings } from '../server-settings.js';
9
+
10
+ const MAX_RETRIES = 5;
11
+ const MAX_WAIT_BEFORE_ERROR_MS = 120000;
12
+ const SHORT_RATE_LIMIT_THRESHOLD_MS = 5000;
13
+
14
+ let accountRotator = null;
15
+ let currentStrategy = null;
16
+
17
+ function getRequestContext(req) {
18
+ try {
19
+ const metadata = JSON.parse(req.body?.metadata?.user_id || '{}');
20
+ return { sessionId: metadata.session_id };
21
+ } catch (_) {
22
+ return { sessionId: undefined };
23
+ }
24
+ }
25
+
26
+ function getAccountRotator() {
27
+ const settings = getServerSettings();
28
+ const strategy = settings.accountStrategy || 'sticky';
29
+
30
+ if (!accountRotator || currentStrategy !== strategy) {
31
+ accountRotator = new AccountRotator({
32
+ listAccounts,
33
+ save,
34
+ getActiveAccount
35
+ }, strategy);
36
+ currentStrategy = strategy;
37
+ logger.info(`[Messages] Account strategy: ${strategy}`);
38
+ }
39
+ return accountRotator;
40
+ }
41
+
42
+ export async function handleMessages(req, res) {
43
+ const startTime = Date.now();
44
+ const body = req.body;
45
+ const settings = getServerSettings();
46
+ if (settings.logRawProxyPayloads === true) {
47
+ logger.info('[Claude Request]', body);
48
+ }
49
+ const requestedModel = body.model || 'gpt-5.2';
50
+ const isStreaming = body.stream !== false;
51
+
52
+ const { upstreamModel } = resolveModelRouting(requestedModel);
53
+
54
+ const rotator = getAccountRotator();
55
+
56
+ rotator.clearExpiredLimits();
57
+
58
+ const maxAttempts = Math.max(MAX_RETRIES, listAccounts().total);
59
+
60
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
61
+ if (rotator.isAllRateLimited(upstreamModel)) {
62
+ const minWait = rotator.getMinWaitTimeMs(upstreamModel);
63
+
64
+ if (minWait > MAX_WAIT_BEFORE_ERROR_MS) {
65
+ return handleStreamError(res, new Error(`RESOURCE_EXHAUSTED: All accounts rate-limited. Wait ${Math.round(minWait/1000)}s`), upstreamModel, startTime);
66
+ }
67
+
68
+ logger.info(`[Messages] All accounts rate-limited, waiting ${Math.round(minWait/1000)}s...`);
69
+ await sleep(minWait + 500);
70
+ rotator.clearExpiredLimits();
71
+ attempt--;
72
+ continue;
73
+ }
74
+
75
+ const { account, waitMs } = rotator.selectAccount(upstreamModel);
76
+
77
+ if (!account) {
78
+ if (waitMs > 0) {
79
+ await sleep(waitMs);
80
+ attempt--;
81
+ continue;
82
+ }
83
+ return sendAuthError(res, 'No available accounts');
84
+ }
85
+
86
+ const creds = await getCredentialsForAccount(account.email);
87
+ if (!creds) {
88
+ rotator.markInvalid(account.email, 'Failed to get credentials');
89
+ continue;
90
+ }
91
+
92
+ const anthropicRequest = { ...body, model: upstreamModel };
93
+ const requestContext = getRequestContext(req);
94
+
95
+ try {
96
+ if (isStreaming) {
97
+ await _streamDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, rotator, requestContext);
98
+ } else {
99
+ await _sendDirectWithRotation(res, anthropicRequest, creds, requestedModel, startTime, rotator, requestContext);
100
+ }
101
+ rotator.notifySuccess(account, upstreamModel);
102
+ return;
103
+ } catch (error) {
104
+ if (error.message.startsWith('RATE_LIMITED:')) {
105
+ const parts = error.message.split(':');
106
+ const resetMs = parseInt(parts[1], 10);
107
+ const errorText = parts.slice(2).join(':');
108
+
109
+ rotator.notifyRateLimit(account, upstreamModel);
110
+
111
+ if (resetMs <= SHORT_RATE_LIMIT_THRESHOLD_MS) {
112
+ logger.info(`[Messages] Short rate limit on ${account.email}, waiting ${resetMs}ms...`);
113
+ await sleep(resetMs);
114
+ attempt--;
115
+ continue;
116
+ }
117
+
118
+ logger.info(`[Messages] Rate limit on ${account.email}, switching account...`);
119
+ continue;
120
+ }
121
+
122
+ if (error.message.includes('AUTH_EXPIRED')) {
123
+ rotator.markInvalid(account.email, 'Auth expired');
124
+ continue;
125
+ }
126
+
127
+ return handleStreamError(res, error, upstreamModel, startTime);
128
+ }
129
+ }
130
+
131
+ return handleStreamError(res, new Error('Max retries exceeded'), upstreamModel, startTime);
132
+ }
133
+
134
+ async function _streamDirectWithRotation(res, anthropicRequest, creds, responseModel, startTime, rotator, requestContext) {
135
+ const stream = await createMessageStream(anthropicRequest, creds.accessToken, creds.accountId, rotator, creds.email, requestContext, responseModel);
136
+ initSSEResponse(res);
137
+ const usage = await pipeSSEStream(res, stream);
138
+ logger.response(200, { model: anthropicRequest.model, ...getTokenDetails(usage), duration: Date.now() - startTime });
139
+ }
140
+
141
+ async function _sendDirectWithRotation(res, anthropicRequest, creds, responseModel, startTime, rotator, requestContext) {
142
+ const response = await sendMessage(anthropicRequest, creds.accessToken, creds.accountId, requestContext);
143
+ const duration = Date.now() - startTime;
144
+ logger.response(200, { model: anthropicRequest.model, ...getTokenDetails(response.usage), duration });
145
+ res.json({ ...response, model: responseModel });
146
+ }
147
+
148
+ function getTokenDetails(usage = {}) {
149
+ const inputTokens = usage?.input_tokens || 0;
150
+ const outputTokens = usage?.output_tokens || 0;
151
+ const cacheReadInputTokens = usage?.cache_read_input_tokens || 0;
152
+ return {
153
+ tokens: inputTokens + outputTokens,
154
+ inputTokens,
155
+ outputTokens,
156
+ cacheReadInputTokens
157
+ };
158
+ }
159
+
160
+ function sleep(ms) {
161
+ return new Promise(resolve => setTimeout(resolve, ms));
162
+ }
163
+
164
+ export default { handleMessages };
@@ -0,0 +1,115 @@
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
+
14
+ const FALLBACK_MODELS = [
15
+ // OpenAI upstream models
16
+ { id: 'gpt-5.3-codex', object: 'model', owned_by: 'openai' },
17
+ { id: 'gpt-5.2-codex', object: 'model', owned_by: 'openai' },
18
+ { id: 'gpt-5.1-codex', object: 'model', owned_by: 'openai' },
19
+ { id: 'gpt-5.2', object: 'model', owned_by: 'openai' },
20
+ // Current Claude 4.6 models
21
+ { id: 'claude-opus-4-6', object: 'model', owned_by: 'anthropic' },
22
+ { id: 'claude-sonnet-4-6', object: 'model', owned_by: 'anthropic' },
23
+ { id: 'claude-haiku-4-5', object: 'model', owned_by: 'anthropic' },
24
+ // 1M context variants
25
+ { id: 'claude-opus-4-6-1m', object: 'model', owned_by: 'anthropic' },
26
+ { id: 'claude-sonnet-4-6-1m', object: 'model', owned_by: 'anthropic' },
27
+ // Legacy models (still supported)
28
+ { id: 'claude-opus-4-5', object: 'model', owned_by: 'anthropic' },
29
+ { id: 'claude-sonnet-4-5', object: 'model', owned_by: 'anthropic' }
30
+ ];
31
+
32
+ /**
33
+ * GET /v1/models
34
+ * Returns an OpenAI-compatible model list. Falls back to a static list on error.
35
+ */
36
+ export async function handleListModels(req, res) {
37
+ const creds = await getCredentialsOrError();
38
+
39
+ if (!creds) {
40
+ return res.json({ object: 'list', data: FALLBACK_MODELS });
41
+ }
42
+
43
+ try {
44
+ const models = await fetchModels(creds.accessToken, creds.accountId);
45
+ const modelList = models.map(m => ({
46
+ id: m.id,
47
+ object: 'model',
48
+ created: Math.floor(Date.now() / 1000),
49
+ owned_by: 'openai',
50
+ description: m.description
51
+ }));
52
+ res.json({ object: 'list', data: modelList });
53
+ } catch (error) {
54
+ logger.error(`Failed to fetch models: ${error.message}`);
55
+ res.json({ object: 'list', data: FALLBACK_MODELS });
56
+ }
57
+ }
58
+
59
+ /**
60
+ * GET /accounts/models
61
+ * Returns the raw model list for the active or specified account.
62
+ */
63
+ export async function handleAccountModels(req, res) {
64
+ const account = _resolveAccount(req.query.email);
65
+
66
+ if (!account) {
67
+ return res.status(404).json({
68
+ success: false,
69
+ error: req.query.email ? `Account not found: ${req.query.email}` : 'No active account'
70
+ });
71
+ }
72
+
73
+ try {
74
+ const models = await fetchModels(account.accessToken, account.accountId);
75
+ res.json({ success: true, email: account.email, models });
76
+ } catch (error) {
77
+ logger.error(`Failed to fetch models: ${error.message}`);
78
+ res.status(500).json({ success: false, error: error.message });
79
+ }
80
+ }
81
+
82
+ /**
83
+ * GET /accounts/usage
84
+ * Returns usage stats for the active or specified account.
85
+ */
86
+ export async function handleAccountUsage(req, res) {
87
+ const account = _resolveAccount(req.query.email);
88
+
89
+ if (!account) {
90
+ return res.status(404).json({
91
+ success: false,
92
+ error: req.query.email ? `Account not found: ${req.query.email}` : 'No active account'
93
+ });
94
+ }
95
+
96
+ try {
97
+ const usage = await fetchUsage(account.accessToken, account.accountId);
98
+ res.json({ success: true, email: account.email, usage });
99
+ } catch (error) {
100
+ logger.error(`Failed to fetch usage: ${error.message}`);
101
+ res.status(500).json({ success: false, error: error.message });
102
+ }
103
+ }
104
+
105
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
106
+
107
+ function _resolveAccount(email) {
108
+ if (email) {
109
+ const data = loadAccounts();
110
+ return data.accounts.find(a => a.email === email) || null;
111
+ }
112
+ return getActiveAccount();
113
+ }
114
+
115
+ export default { handleListModels, handleAccountModels, handleAccountUsage };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Settings Route
3
+ * Handles server settings endpoints:
4
+ * GET /settings/account-strategy
5
+ * POST /settings/account-strategy
6
+ * GET /settings/model-map
7
+ * POST /settings/model-map
8
+ * GET /settings/raw-proxy-logs
9
+ * POST /settings/raw-proxy-logs
10
+ */
11
+
12
+ import { getServerSettings, setServerSettings, DEFAULT_MODEL_MAP } from '../server-settings.js';
13
+
14
+ const VALID_STRATEGIES = ['sticky', 'round-robin'];
15
+
16
+ /**
17
+ * GET /settings/account-strategy
18
+ * Returns the current account selection strategy.
19
+ */
20
+ export function handleGetAccountStrategy(req, res) {
21
+ const settings = getServerSettings();
22
+ res.json({ success: true, accountStrategy: settings.accountStrategy });
23
+ }
24
+
25
+ /**
26
+ * POST /settings/account-strategy
27
+ * Updates the account selection strategy.
28
+ */
29
+ export function handleSetAccountStrategy(req, res) {
30
+ const { accountStrategy } = req.body || {};
31
+
32
+ if (!VALID_STRATEGIES.includes(accountStrategy)) {
33
+ return res.status(400).json({
34
+ success: false,
35
+ error: `Invalid accountStrategy. Use one of: ${VALID_STRATEGIES.join(', ')}`
36
+ });
37
+ }
38
+
39
+ const settings = setServerSettings({ accountStrategy });
40
+ res.json({ success: true, accountStrategy: settings.accountStrategy });
41
+ }
42
+
43
+ /**
44
+ * GET /settings/model-map
45
+ * Returns the current model mapping configuration.
46
+ */
47
+ export function handleGetModelMap(req, res) {
48
+ const settings = getServerSettings();
49
+
50
+ res.json({
51
+ success: true,
52
+ modelMap: settings.modelMap,
53
+ defaultCodexModel: settings.defaultCodexModel
54
+ });
55
+ }
56
+
57
+ /**
58
+ * POST /settings/model-map
59
+ * Updates model mapping configuration.
60
+ *
61
+ * Body:
62
+ * {
63
+ * "modelMap": { "sonnet": "gpt-5.5", "opus": "gpt-5.5" },
64
+ * "defaultCodexModel": "gpt-5.2",
65
+ * "reset": false
66
+ * }
67
+ *
68
+ * By default, modelMap is merged into the existing map. Use reset=true to
69
+ * reset to built-in defaults before applying the provided modelMap.
70
+ */
71
+ export function handleSetModelMap(req, res) {
72
+ const { modelMap, defaultCodexModel, reset = false } = req.body || {};
73
+ const current = getServerSettings();
74
+ const patch = {};
75
+
76
+ if (defaultCodexModel !== undefined) {
77
+ if (!defaultCodexModel || typeof defaultCodexModel !== 'string') {
78
+ return res.status(400).json({
79
+ success: false,
80
+ error: 'defaultCodexModel must be a non-empty string'
81
+ });
82
+ }
83
+ patch.defaultCodexModel = defaultCodexModel;
84
+ }
85
+
86
+ if (modelMap !== undefined) {
87
+ if (typeof modelMap !== 'object' || modelMap === null || Array.isArray(modelMap)) {
88
+ return res.status(400).json({
89
+ success: false,
90
+ error: 'modelMap must be an object of { requestedModel: upstreamModel }'
91
+ });
92
+ }
93
+
94
+ for (const [key, value] of Object.entries(modelMap)) {
95
+ if (!key || typeof key !== 'string' || !value || typeof value !== 'string') {
96
+ return res.status(400).json({
97
+ success: false,
98
+ error: 'modelMap keys and values must be non-empty strings'
99
+ });
100
+ }
101
+ }
102
+
103
+ patch.modelMap = {
104
+ ...(reset ? DEFAULT_MODEL_MAP : current.modelMap),
105
+ ...modelMap
106
+ };
107
+ } else if (reset) {
108
+ patch.modelMap = DEFAULT_MODEL_MAP;
109
+ }
110
+
111
+ const settings = setServerSettings(patch);
112
+
113
+ res.json({
114
+ success: true,
115
+ modelMap: settings.modelMap,
116
+ defaultCodexModel: settings.defaultCodexModel
117
+ });
118
+ }
119
+
120
+ /**
121
+ * GET /settings/raw-proxy-logs
122
+ * Returns whether raw proxy payload logging is enabled.
123
+ */
124
+ export function handleGetRawProxyLogs(req, res) {
125
+ const settings = getServerSettings();
126
+ res.json({ success: true, logRawProxyPayloads: settings.logRawProxyPayloads === true });
127
+ }
128
+
129
+ /**
130
+ * POST /settings/raw-proxy-logs
131
+ * Enables or disables raw proxy payload logging.
132
+ */
133
+ export function handleSetRawProxyLogs(req, res) {
134
+ const { enabled } = req.body || {};
135
+
136
+ if (typeof enabled !== 'boolean') {
137
+ return res.status(400).json({
138
+ success: false,
139
+ error: 'enabled must be a boolean'
140
+ });
141
+ }
142
+
143
+ const settings = setServerSettings({ logRawProxyPayloads: enabled });
144
+ res.json({ success: true, logRawProxyPayloads: settings.logRawProxyPayloads === true });
145
+ }
146
+
147
+ export default {
148
+ handleGetAccountStrategy,
149
+ handleSetAccountStrategy,
150
+ handleGetModelMap,
151
+ handleSetModelMap,
152
+ handleGetRawProxyLogs,
153
+ handleSetRawProxyLogs
154
+ };
@@ -0,0 +1,70 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { CONFIG_DIR } from './account-manager.js';
4
+
5
+ const SETTINGS_FILE = join(CONFIG_DIR, 'settings.json');
6
+
7
+ export const DEFAULT_MODEL_MAP = {
8
+ sonnet: 'gpt-5.5',
9
+ opus: 'gpt-5.5',
10
+ haiku: 'gpt-5.4-mini'
11
+ };
12
+
13
+ export const DEFAULT_SETTINGS = {
14
+ accountStrategy: 'sticky',
15
+ defaultCodexModel: 'gpt-5.5',
16
+ logRawProxyPayloads: false,
17
+ modelMap: DEFAULT_MODEL_MAP
18
+ };
19
+
20
+ function ensureConfigDir() {
21
+ if (!existsSync(CONFIG_DIR)) {
22
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
23
+ }
24
+ }
25
+
26
+ function mergeSettings(data = {}) {
27
+ return {
28
+ ...DEFAULT_SETTINGS,
29
+ ...data,
30
+ modelMap: {
31
+ ...DEFAULT_MODEL_MAP,
32
+ ...(data.modelMap || {})
33
+ }
34
+ };
35
+ }
36
+
37
+ export function getServerSettings() {
38
+ ensureConfigDir();
39
+
40
+ if (!existsSync(SETTINGS_FILE)) {
41
+ return mergeSettings();
42
+ }
43
+
44
+ try {
45
+ const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8'));
46
+ return mergeSettings(data);
47
+ } catch (error) {
48
+ console.error('[ServerSettings] Failed to read settings:', error.message);
49
+ return mergeSettings();
50
+ }
51
+ }
52
+
53
+ export function setServerSettings(patch = {}) {
54
+ const current = getServerSettings();
55
+ const next = mergeSettings({ ...current, ...patch });
56
+
57
+ ensureConfigDir();
58
+ writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2), { mode: 0o600 });
59
+ return next;
60
+ }
61
+
62
+ export { SETTINGS_FILE };
63
+
64
+ export default {
65
+ getServerSettings,
66
+ setServerSettings,
67
+ SETTINGS_FILE,
68
+ DEFAULT_SETTINGS,
69
+ DEFAULT_MODEL_MAP
70
+ };