@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,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts Route
|
|
3
|
+
* Handles all /accounts/* endpoints:
|
|
4
|
+
* GET /accounts
|
|
5
|
+
* GET /accounts/status
|
|
6
|
+
* GET /accounts/quota
|
|
7
|
+
* GET /accounts/quota/all
|
|
8
|
+
* POST /accounts/add
|
|
9
|
+
* POST /accounts/add/manual
|
|
10
|
+
* POST /accounts/switch
|
|
11
|
+
* POST /accounts/import
|
|
12
|
+
* POST /accounts/refresh
|
|
13
|
+
* POST /accounts/refresh/all
|
|
14
|
+
* POST /accounts/:email/refresh
|
|
15
|
+
* POST /accounts/oauth/cleanup
|
|
16
|
+
* DELETE /accounts/:email
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
getActiveAccount,
|
|
21
|
+
setActiveAccount,
|
|
22
|
+
removeAccount,
|
|
23
|
+
listAccounts,
|
|
24
|
+
refreshActiveAccount,
|
|
25
|
+
refreshAccountToken,
|
|
26
|
+
refreshAllAccounts,
|
|
27
|
+
importFromCodex,
|
|
28
|
+
getStatus,
|
|
29
|
+
loadAccounts,
|
|
30
|
+
saveAccounts,
|
|
31
|
+
updateAccountAuth,
|
|
32
|
+
updateAccountQuota,
|
|
33
|
+
getAccountQuota
|
|
34
|
+
} from '../account-manager.js';
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
getAuthorizationUrl,
|
|
38
|
+
generatePKCE,
|
|
39
|
+
generateState,
|
|
40
|
+
startCallbackServer,
|
|
41
|
+
exchangeCodeForTokens,
|
|
42
|
+
OAUTH_CONFIG,
|
|
43
|
+
extractCodeFromInput,
|
|
44
|
+
extractAccountInfo,
|
|
45
|
+
getPKCEData
|
|
46
|
+
} from '../oauth.js';
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
getAccountQuota as fetchAccountQuota
|
|
50
|
+
} from '../model-api.js';
|
|
51
|
+
|
|
52
|
+
import { logger } from '../utils/logger.js';
|
|
53
|
+
|
|
54
|
+
// Tracks active OAuth callback servers keyed by port
|
|
55
|
+
const activeCallbackServers = new Map();
|
|
56
|
+
|
|
57
|
+
// ─── Route Handlers ──────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export function handleListAccounts(req, res) {
|
|
60
|
+
res.json(listAccounts());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function handleAccountStatus(req, res) {
|
|
64
|
+
res.json(getStatus());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function handleOAuthCleanup(req, res) {
|
|
68
|
+
for (const [, callback] of activeCallbackServers) {
|
|
69
|
+
try { callback.abort(); } catch { /* ignore */ }
|
|
70
|
+
}
|
|
71
|
+
activeCallbackServers.clear();
|
|
72
|
+
res.json({ success: true, message: 'OAuth servers cleaned up' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function handleAddAccount(req, res) {
|
|
76
|
+
const { port } = req.body || {};
|
|
77
|
+
const callbackPort = port || OAUTH_CONFIG.callbackPort;
|
|
78
|
+
|
|
79
|
+
const { verifier } = generatePKCE();
|
|
80
|
+
const state = generateState();
|
|
81
|
+
|
|
82
|
+
// Close any existing server on this port
|
|
83
|
+
if (activeCallbackServers.has(callbackPort)) {
|
|
84
|
+
const existing = activeCallbackServers.get(callbackPort);
|
|
85
|
+
if (existing.abort) existing.abort();
|
|
86
|
+
activeCallbackServers.delete(callbackPort);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let serverResult;
|
|
90
|
+
let actualPort;
|
|
91
|
+
try {
|
|
92
|
+
serverResult = startCallbackServer(state, 120000, { port: callbackPort });
|
|
93
|
+
actualPort = await serverResult.ready;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return res.status(500).json({
|
|
96
|
+
error: 'Failed to start OAuth callback server',
|
|
97
|
+
message: err.message,
|
|
98
|
+
status: 'error'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const oauthUrl = getAuthorizationUrl(verifier, state, actualPort);
|
|
103
|
+
|
|
104
|
+
activeCallbackServers.set(actualPort, serverResult);
|
|
105
|
+
|
|
106
|
+
serverResult.promise
|
|
107
|
+
.then(result => {
|
|
108
|
+
activeCallbackServers.delete(actualPort);
|
|
109
|
+
if (result?.code) {
|
|
110
|
+
return exchangeCodeForTokens(result.code, verifier, actualPort)
|
|
111
|
+
.then(async tokens => {
|
|
112
|
+
const accountInfo = _buildAccountInfo(tokens);
|
|
113
|
+
await _upsertAccount(accountInfo);
|
|
114
|
+
logger.info(`Added account: ${accountInfo.email}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
.catch(err => {
|
|
119
|
+
activeCallbackServers.delete(actualPort);
|
|
120
|
+
logger.error(`OAuth token exchange failed: ${err.message}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
res.json({
|
|
124
|
+
status: 'oauth_url',
|
|
125
|
+
oauth_url: oauthUrl,
|
|
126
|
+
callback_port: actualPort
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function handleAddAccountManual(req, res) {
|
|
131
|
+
const { code, verifier, port } = req.body || {};
|
|
132
|
+
|
|
133
|
+
if (!code) {
|
|
134
|
+
return res.status(400).json({ success: false, error: 'Code is required' });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const { code: extractedCode, state, port: callbackUrlPort } = extractCodeFromInput(code);
|
|
139
|
+
const pkceData = state ? getPKCEData(state) : null;
|
|
140
|
+
const codeVerifier = verifier || pkceData?.verifier;
|
|
141
|
+
const callbackPort = port || callbackUrlPort || pkceData?.port || OAUTH_CONFIG.callbackPort;
|
|
142
|
+
|
|
143
|
+
if (!codeVerifier) {
|
|
144
|
+
return res.status(400).json({
|
|
145
|
+
success: false,
|
|
146
|
+
error: 'Verifier is required unless a callback URL with a valid state is provided'
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const tokens = await exchangeCodeForTokens(extractedCode, codeVerifier, callbackPort);
|
|
151
|
+
const accountInfo = _buildAccountInfo(tokens);
|
|
152
|
+
|
|
153
|
+
await _upsertAccount(accountInfo);
|
|
154
|
+
const callback = activeCallbackServers.get(callbackPort);
|
|
155
|
+
if (callback?.abort) callback.abort();
|
|
156
|
+
activeCallbackServers.delete(callbackPort);
|
|
157
|
+
logger.info(`Added account via manual OAuth: ${accountInfo.email}`);
|
|
158
|
+
res.json({ success: true, message: `Account ${accountInfo.email} added successfully` });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
logger.error(`Manual OAuth failed: ${err.message}`);
|
|
161
|
+
res.status(400).json({ success: false, error: err.message });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function handleSwitchAccount(req, res) {
|
|
166
|
+
const { email } = req.body || {};
|
|
167
|
+
if (!email) {
|
|
168
|
+
return res.status(400).json({ success: false, message: 'Email is required' });
|
|
169
|
+
}
|
|
170
|
+
const result = setActiveAccount(email);
|
|
171
|
+
if (result.success) {
|
|
172
|
+
logger.info(`Switched to account: ${email}`);
|
|
173
|
+
}
|
|
174
|
+
res.json(result);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function handleRefreshAccount(req, res) {
|
|
178
|
+
const email = decodeURIComponent(req.params.email);
|
|
179
|
+
const result = await refreshAccountToken(email);
|
|
180
|
+
if (result.success) {
|
|
181
|
+
logger.info(`Refreshed token for: ${email}`);
|
|
182
|
+
}
|
|
183
|
+
res.json(result);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function handleRefreshAllAccounts(req, res) {
|
|
187
|
+
const result = await refreshAllAccounts();
|
|
188
|
+
res.json(result);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function handleRefreshActiveAccount(req, res) {
|
|
192
|
+
const result = await refreshActiveAccount();
|
|
193
|
+
res.json(result);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function handleRemoveAccount(req, res) {
|
|
197
|
+
const email = decodeURIComponent(req.params.email);
|
|
198
|
+
const result = removeAccount(email);
|
|
199
|
+
if (result.success) {
|
|
200
|
+
logger.info(`Removed account: ${email}`);
|
|
201
|
+
}
|
|
202
|
+
res.json(result);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function handleImportAccount(req, res) {
|
|
206
|
+
const result = importFromCodex();
|
|
207
|
+
res.json(result);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function handleGetQuota(req, res) {
|
|
211
|
+
const { email, refresh } = req.query;
|
|
212
|
+
const account = email
|
|
213
|
+
? loadAccounts().accounts.find(a => a.email === email)
|
|
214
|
+
: getActiveAccount();
|
|
215
|
+
|
|
216
|
+
if (!account) {
|
|
217
|
+
return res.status(404).json({
|
|
218
|
+
success: false,
|
|
219
|
+
error: email ? `Account not found: ${email}` : 'No active account'
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const cachedQuota = getAccountQuota(account.email);
|
|
224
|
+
const isStale = !cachedQuota ||
|
|
225
|
+
(Date.now() - new Date(cachedQuota.lastChecked).getTime() > 5 * 60 * 1000);
|
|
226
|
+
|
|
227
|
+
if (refresh === 'true' || isStale) {
|
|
228
|
+
try {
|
|
229
|
+
const quotaData = await fetchAccountQuota(account.accessToken, account.accountId);
|
|
230
|
+
updateAccountQuota(account.email, quotaData);
|
|
231
|
+
res.json({ success: true, email: account.email, quota: quotaData, cached: false });
|
|
232
|
+
} catch (error) {
|
|
233
|
+
logger.error(`Failed to fetch quota: ${error.message}`);
|
|
234
|
+
if (cachedQuota) {
|
|
235
|
+
res.json({
|
|
236
|
+
success: true,
|
|
237
|
+
email: account.email,
|
|
238
|
+
quota: cachedQuota,
|
|
239
|
+
cached: true,
|
|
240
|
+
warning: 'Using cached data due to fetch error'
|
|
241
|
+
});
|
|
242
|
+
} else {
|
|
243
|
+
res.status(500).json({ success: false, error: error.message });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
res.json({ success: true, email: account.email, quota: cachedQuota, cached: true });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function handleGetAllQuotas(req, res) {
|
|
252
|
+
const { accounts: accountList } = listAccounts();
|
|
253
|
+
const results = [];
|
|
254
|
+
|
|
255
|
+
for (const account of accountList) {
|
|
256
|
+
try {
|
|
257
|
+
const quota = await getAccountQuota(account.email);
|
|
258
|
+
results.push({ email: account.email, quota: quota || null });
|
|
259
|
+
} catch {
|
|
260
|
+
results.push({ email: account.email, quota: null });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
res.json({ accounts: results });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Inserts or updates an account in the persisted accounts store,
|
|
271
|
+
* and sets it as the active account.
|
|
272
|
+
* @param {object} accountInfo
|
|
273
|
+
*/
|
|
274
|
+
async function _upsertAccount(accountInfo) {
|
|
275
|
+
if (!accountInfo?.email) {
|
|
276
|
+
throw new Error('OAuth response did not include account email');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const data = loadAccounts();
|
|
280
|
+
const existingIndex = data.accounts.findIndex(a => a.email === accountInfo.email);
|
|
281
|
+
|
|
282
|
+
if (existingIndex >= 0) {
|
|
283
|
+
data.accounts[existingIndex] = { ...data.accounts[existingIndex], ...accountInfo };
|
|
284
|
+
} else {
|
|
285
|
+
data.accounts.push(accountInfo);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
data.activeAccount = accountInfo.email;
|
|
289
|
+
saveAccounts(data);
|
|
290
|
+
updateAccountAuth(accountInfo);
|
|
291
|
+
|
|
292
|
+
// Fetch initial quota immediately
|
|
293
|
+
try {
|
|
294
|
+
const quotaData = await fetchAccountQuota(accountInfo.accessToken, accountInfo.accountId);
|
|
295
|
+
updateAccountQuota(accountInfo.email, quotaData);
|
|
296
|
+
logger.info(`Initial quota fetched for: ${accountInfo.email}`);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
logger.warn(`Failed to fetch initial quota for ${accountInfo.email}: ${err.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function _buildAccountInfo(tokens) {
|
|
303
|
+
const tokenInfo = extractAccountInfo(tokens.accessToken);
|
|
304
|
+
return {
|
|
305
|
+
email: tokenInfo?.email || 'unknown',
|
|
306
|
+
accountId: tokenInfo?.accountId,
|
|
307
|
+
planType: tokenInfo?.planType || 'free',
|
|
308
|
+
userId: tokenInfo?.userId,
|
|
309
|
+
accessToken: tokens.accessToken,
|
|
310
|
+
refreshToken: tokens.refreshToken,
|
|
311
|
+
idToken: tokens.idToken,
|
|
312
|
+
expiresAt: tokenInfo?.expiresAt || (Date.now() + tokens.expiresIn * 1000),
|
|
313
|
+
addedAt: new Date().toISOString(),
|
|
314
|
+
lastUsed: null
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export default {
|
|
319
|
+
handleListAccounts,
|
|
320
|
+
handleAccountStatus,
|
|
321
|
+
handleOAuthCleanup,
|
|
322
|
+
handleAddAccount,
|
|
323
|
+
handleAddAccountManual,
|
|
324
|
+
handleSwitchAccount,
|
|
325
|
+
handleRefreshAccount,
|
|
326
|
+
handleRefreshAllAccounts,
|
|
327
|
+
handleRefreshActiveAccount,
|
|
328
|
+
handleRemoveAccount,
|
|
329
|
+
handleImportAccount,
|
|
330
|
+
handleGetQuota,
|
|
331
|
+
handleGetAllQuotas
|
|
332
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Routes
|
|
3
|
+
* Thin registration layer — wires all route modules to the Express app.
|
|
4
|
+
* Business logic lives in the individual route files under src/routes/.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
|
|
12
|
+
import { getStatus, ACCOUNTS_FILE } from '../account-manager.js';
|
|
13
|
+
|
|
14
|
+
// Route handlers
|
|
15
|
+
import { handleMessages } from './messages-route.js';
|
|
16
|
+
import { handleChatCompletion, handleCountTokens } from './chat-route.js';
|
|
17
|
+
import { handleListModels, handleAccountModels, handleAccountUsage } from './models-route.js';
|
|
18
|
+
import { handleGetHaikuModel, handleSetHaikuModel, handleGetKiloModels, handleGetAccountStrategy, handleSetAccountStrategy } from './settings-route.js';
|
|
19
|
+
import { handleGetLogs, handleStreamLogs } from './logs-route.js';
|
|
20
|
+
import { handleGetClaudeConfig, handleSetProxyMode, handleSetDirectMode, handleSetClaudeApiEndpoint } from './claude-config-route.js';
|
|
21
|
+
import {
|
|
22
|
+
handleListAccounts,
|
|
23
|
+
handleAccountStatus,
|
|
24
|
+
handleOAuthCleanup,
|
|
25
|
+
handleAddAccount,
|
|
26
|
+
handleAddAccountManual,
|
|
27
|
+
handleSwitchAccount,
|
|
28
|
+
handleRefreshAccount,
|
|
29
|
+
handleRefreshAllAccounts,
|
|
30
|
+
handleRefreshActiveAccount,
|
|
31
|
+
handleRemoveAccount,
|
|
32
|
+
handleImportAccount,
|
|
33
|
+
handleGetQuota,
|
|
34
|
+
handleGetAllQuotas
|
|
35
|
+
} from './accounts-route.js';
|
|
36
|
+
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const require = createRequire(import.meta.url);
|
|
39
|
+
const alpineDistDir = dirname(require.resolve('alpinejs/dist/cdn.min.js'));
|
|
40
|
+
|
|
41
|
+
export function registerApiRoutes(app, { port }) {
|
|
42
|
+
// ─── Static Web UI ─────────────────────────────────────────────────────────
|
|
43
|
+
app.use('/vendor/alpine', express.static(alpineDistDir));
|
|
44
|
+
app.use(express.static(join(__dirname, '..', '..', 'public')));
|
|
45
|
+
|
|
46
|
+
// ─── Health ────────────────────────────────────────────────────────────────
|
|
47
|
+
app.get('/health', (req, res) => {
|
|
48
|
+
res.json({ status: 'ok', ...getStatus(), configPath: ACCOUNTS_FILE });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ─── Anthropic Messages API ────────────────────────────────────────────────
|
|
52
|
+
app.post('/v1/messages', handleMessages);
|
|
53
|
+
app.post('/v1/messages/count_tokens', handleCountTokens);
|
|
54
|
+
|
|
55
|
+
// ─── OpenAI Chat Completions API ───────────────────────────────────────────
|
|
56
|
+
app.post('/v1/chat/completions', handleChatCompletion);
|
|
57
|
+
|
|
58
|
+
// ─── Models ────────────────────────────────────────────────────────────────
|
|
59
|
+
app.get('/v1/models', handleListModels);
|
|
60
|
+
app.get('/accounts/models', handleAccountModels);
|
|
61
|
+
app.get('/accounts/usage', handleAccountUsage);
|
|
62
|
+
|
|
63
|
+
// ─── Settings ──────────────────────────────────────────────────────────────
|
|
64
|
+
app.get('/settings/haiku-model', handleGetHaikuModel);
|
|
65
|
+
app.post('/settings/haiku-model', handleSetHaikuModel);
|
|
66
|
+
app.get('/settings/kilo-models', handleGetKiloModels);
|
|
67
|
+
app.get('/settings/account-strategy', handleGetAccountStrategy);
|
|
68
|
+
app.post('/settings/account-strategy', handleSetAccountStrategy);
|
|
69
|
+
|
|
70
|
+
// ─── Account Management ───────────────────────────────────────────────────
|
|
71
|
+
app.get('/accounts', handleListAccounts);
|
|
72
|
+
app.get('/accounts/status', handleAccountStatus);
|
|
73
|
+
app.get('/accounts/quota', handleGetQuota);
|
|
74
|
+
app.get('/accounts/quota/all', handleGetAllQuotas);
|
|
75
|
+
|
|
76
|
+
app.post('/accounts/add', handleAddAccount);
|
|
77
|
+
app.post('/accounts/add/manual', handleAddAccountManual);
|
|
78
|
+
app.post('/accounts/switch', handleSwitchAccount);
|
|
79
|
+
app.post('/accounts/import', handleImportAccount);
|
|
80
|
+
app.post('/accounts/refresh', handleRefreshActiveAccount);
|
|
81
|
+
app.post('/accounts/refresh/all', handleRefreshAllAccounts);
|
|
82
|
+
app.post('/accounts/oauth/cleanup', handleOAuthCleanup);
|
|
83
|
+
app.post('/accounts/:email/refresh', handleRefreshAccount);
|
|
84
|
+
|
|
85
|
+
app.delete('/accounts/:email', handleRemoveAccount);
|
|
86
|
+
|
|
87
|
+
// ─── Claude CLI Configuration ──────────────────────────────────────────────
|
|
88
|
+
app.get('/claude/config', handleGetClaudeConfig);
|
|
89
|
+
app.post('/claude/config/proxy', (req, res) => handleSetProxyMode(req, res, { port }));
|
|
90
|
+
app.post('/claude/config/direct', handleSetDirectMode);
|
|
91
|
+
app.post('/claude/config/set', handleSetClaudeApiEndpoint);
|
|
92
|
+
|
|
93
|
+
// ─── Logs ──────────────────────────────────────────────────────────────────
|
|
94
|
+
app.get('/api/logs', handleGetLogs);
|
|
95
|
+
app.get('/api/logs/stream', handleStreamLogs);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default { registerApiRoutes };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Completions Route
|
|
3
|
+
* Handles POST /v1/chat/completions (OpenAI Chat Completions API compatibility)
|
|
4
|
+
* and POST /v1/messages/count_tokens (approximate token counting).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { sendMessage } from '../direct-api.js';
|
|
8
|
+
import { sendKiloMessage } from '../kilo-api.js';
|
|
9
|
+
import { DEFAULT_OPENAI_MODEL, isKiloEnabled, resolveModelRouting } from '../model-mapper.js';
|
|
10
|
+
import { getCredentialsOrError, sendAuthError } from '../middleware/credentials.js';
|
|
11
|
+
import { handleStreamError } from '../middleware/sse.js';
|
|
12
|
+
import { logger } from '../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* POST /v1/chat/completions
|
|
16
|
+
* Converts OpenAI Chat format to Anthropic internally, then routes to Codex or Kilo.
|
|
17
|
+
* Always returns a non-streaming OpenAI-compatible response.
|
|
18
|
+
*/
|
|
19
|
+
export async function handleChatCompletion(req, res) {
|
|
20
|
+
const startTime = Date.now();
|
|
21
|
+
const body = req.body;
|
|
22
|
+
const requestedModel = body.model || DEFAULT_OPENAI_MODEL;
|
|
23
|
+
|
|
24
|
+
const { isKilo, kiloTarget, upstreamModel } = resolveModelRouting(requestedModel);
|
|
25
|
+
|
|
26
|
+
if (isKilo && !isKiloEnabled()) {
|
|
27
|
+
return res.status(403).json({
|
|
28
|
+
error: {
|
|
29
|
+
message: 'Kilo routing is disabled. Set CODEX_CLAUDE_PROXY_ENABLE_KILO=true to enable third-party Kilo model routing.',
|
|
30
|
+
type: 'invalid_request_error',
|
|
31
|
+
code: 'kilo_disabled'
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let creds = null;
|
|
37
|
+
if (!isKilo) {
|
|
38
|
+
creds = await getCredentialsOrError();
|
|
39
|
+
if (!creds) {
|
|
40
|
+
logger.response(401, { error: 'No active account' });
|
|
41
|
+
return sendAuthError(res, 'No active account. Add an account via /accounts/add');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const anthropicRequest = _buildAnthropicRequest(body, upstreamModel);
|
|
46
|
+
|
|
47
|
+
logger.request('POST', '/v1/chat/completions', {
|
|
48
|
+
model: upstreamModel,
|
|
49
|
+
account: isKilo ? 'kilo' : creds.email,
|
|
50
|
+
messages: body.messages?.length || 0,
|
|
51
|
+
tools: body.tools?.length || 0
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const response = isKilo
|
|
56
|
+
? await sendKiloMessage(anthropicRequest, kiloTarget)
|
|
57
|
+
: await sendMessage(anthropicRequest, creds.accessToken, creds.accountId);
|
|
58
|
+
|
|
59
|
+
const duration = Date.now() - startTime;
|
|
60
|
+
logger.response(200, { model: upstreamModel, tokens: response.usage?.output_tokens || 0, duration });
|
|
61
|
+
|
|
62
|
+
res.json(_buildOpenAIResponse(response, requestedModel));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
handleStreamError(res, error, upstreamModel, startTime);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* POST /v1/messages/count_tokens
|
|
70
|
+
* Returns an approximate token count for the given request body.
|
|
71
|
+
*/
|
|
72
|
+
export function handleCountTokens(req, res) {
|
|
73
|
+
const body = req.body;
|
|
74
|
+
let text = '';
|
|
75
|
+
|
|
76
|
+
if (body.system) {
|
|
77
|
+
if (typeof body.system === 'string') {
|
|
78
|
+
text += body.system + ' ';
|
|
79
|
+
} else if (Array.isArray(body.system)) {
|
|
80
|
+
for (const block of body.system) {
|
|
81
|
+
if (block.type === 'text') text += block.text + ' ';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (body.tools) {
|
|
87
|
+
for (const tool of body.tools) {
|
|
88
|
+
text += JSON.stringify(tool) + ' ';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(body.messages)) {
|
|
93
|
+
for (const msg of body.messages) {
|
|
94
|
+
if (typeof msg.content === 'string') {
|
|
95
|
+
text += msg.content + ' ';
|
|
96
|
+
} else if (Array.isArray(msg.content)) {
|
|
97
|
+
for (const block of msg.content) {
|
|
98
|
+
if (block.type === 'text') {
|
|
99
|
+
text += block.text + ' ';
|
|
100
|
+
} else if (block.type === 'tool_use' || block.type === 'tool_result') {
|
|
101
|
+
text += JSON.stringify(block) + ' ';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const approxTokens = Math.ceil(text.length / 4);
|
|
109
|
+
res.json({ input_tokens: approxTokens });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Converts an OpenAI Chat Completions request body into an Anthropic-style request.
|
|
116
|
+
* @param {object} body
|
|
117
|
+
* @param {string} upstreamModel
|
|
118
|
+
* @returns {object}
|
|
119
|
+
*/
|
|
120
|
+
function _buildAnthropicRequest(body, upstreamModel) {
|
|
121
|
+
const anthropicRequest = {
|
|
122
|
+
model: upstreamModel,
|
|
123
|
+
messages: [],
|
|
124
|
+
system: null,
|
|
125
|
+
stream: false
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (body.messages) {
|
|
129
|
+
const systemMsg = body.messages.find(m => m.role === 'system');
|
|
130
|
+
if (systemMsg) {
|
|
131
|
+
anthropicRequest.system = systemMsg.content;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
anthropicRequest.messages = body.messages
|
|
135
|
+
.filter(m => m.role !== 'system')
|
|
136
|
+
.map(m => {
|
|
137
|
+
if (m.role === 'tool') {
|
|
138
|
+
return {
|
|
139
|
+
role: 'user',
|
|
140
|
+
content: [{
|
|
141
|
+
type: 'tool_result',
|
|
142
|
+
tool_use_id: m.tool_call_id,
|
|
143
|
+
content: m.content
|
|
144
|
+
}]
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (m.role === 'assistant' && m.tool_calls) {
|
|
149
|
+
const content = [{ type: 'text', text: m.content || '' }];
|
|
150
|
+
for (const call of m.tool_calls) {
|
|
151
|
+
let input = {};
|
|
152
|
+
try {
|
|
153
|
+
input = typeof call.function.arguments === 'string'
|
|
154
|
+
? JSON.parse(call.function.arguments)
|
|
155
|
+
: call.function.arguments || {};
|
|
156
|
+
} catch {
|
|
157
|
+
input = {};
|
|
158
|
+
}
|
|
159
|
+
content.push({
|
|
160
|
+
type: 'tool_use',
|
|
161
|
+
id: call.id,
|
|
162
|
+
name: call.function.name,
|
|
163
|
+
input
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return { role: 'assistant', content };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return m;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (body.tools) {
|
|
174
|
+
anthropicRequest.tools = body.tools.map(t => ({
|
|
175
|
+
name: t.function.name,
|
|
176
|
+
description: t.function.description,
|
|
177
|
+
input_schema: t.function.parameters
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return anthropicRequest;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Converts an Anthropic-style response into an OpenAI Chat Completions response.
|
|
186
|
+
* @param {object} response
|
|
187
|
+
* @param {string} responseModel
|
|
188
|
+
* @returns {object}
|
|
189
|
+
*/
|
|
190
|
+
function _buildOpenAIResponse(response, responseModel) {
|
|
191
|
+
const content = response.content || [];
|
|
192
|
+
const textContent = content.find(c => c.type === 'text');
|
|
193
|
+
const toolUses = content.filter(c => c.type === 'tool_use');
|
|
194
|
+
|
|
195
|
+
const message = {
|
|
196
|
+
role: 'assistant',
|
|
197
|
+
content: textContent?.text || ''
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if (toolUses.length > 0) {
|
|
201
|
+
message.tool_calls = toolUses.map(t => ({
|
|
202
|
+
id: t.id,
|
|
203
|
+
type: 'function',
|
|
204
|
+
function: {
|
|
205
|
+
name: t.name,
|
|
206
|
+
arguments: JSON.stringify(t.input)
|
|
207
|
+
}
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
id: response.id,
|
|
213
|
+
object: 'chat.completion',
|
|
214
|
+
created: Math.floor(Date.now() / 1000),
|
|
215
|
+
model: responseModel,
|
|
216
|
+
choices: [{
|
|
217
|
+
index: 0,
|
|
218
|
+
message,
|
|
219
|
+
finish_reason: toolUses.length > 0 ? 'tool_calls' : 'stop'
|
|
220
|
+
}],
|
|
221
|
+
usage: {
|
|
222
|
+
prompt_tokens: response.usage?.input_tokens || 0,
|
|
223
|
+
completion_tokens: response.usage?.output_tokens || 0,
|
|
224
|
+
total_tokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0)
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default { handleChatCompletion, handleCountTokens };
|