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.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/bin/cli.js +113 -0
- package/docs/ACCOUNTS.md +202 -0
- package/docs/API.md +244 -0
- package/docs/ARCHITECTURE.md +119 -0
- package/docs/CLAUDE_INTEGRATION.md +163 -0
- package/docs/OAUTH.md +83 -0
- package/docs/OPENCLAW.md +33 -0
- package/docs/legal.md +9 -0
- package/images/demo-screenshot.png +0 -0
- package/images/f757093f-507b-4453-994e-f8275f8b07a9.png +0 -0
- package/package.json +56 -0
- package/public/css/style.css +791 -0
- package/public/index.html +838 -0
- package/public/js/app.js +619 -0
- package/src/account-manager.js +526 -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 +154 -0
- package/src/cli/accounts.js +551 -0
- package/src/direct-api.js +214 -0
- package/src/format-converter.js +563 -0
- package/src/index.js +45 -0
- package/src/middleware/credentials.js +116 -0
- package/src/middleware/sse.js +130 -0
- package/src/model-api.js +189 -0
- package/src/model-mapper.js +88 -0
- package/src/oauth.js +623 -0
- package/src/response-streamer.js +469 -0
- package/src/routes/accounts-route.js +296 -0
- package/src/routes/api-routes.js +102 -0
- package/src/routes/chat-route.js +239 -0
- package/src/routes/claude-config-route.js +114 -0
- package/src/routes/logs-route.js +43 -0
- package/src/routes/messages-route.js +164 -0
- package/src/routes/models-route.js +115 -0
- package/src/routes/settings-route.js +154 -0
- package/src/server-settings.js +70 -0
- package/src/server.js +57 -0
- package/src/signature-cache.js +106 -0
- package/src/thinking-utils.js +312 -0
- 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
|
+
};
|